Improve external authentication API

Some providers (e.g. Twitter) don't expose user email addresses, so it
turns out we can't use that as the sole form of identification/account
matching.

This commit introduces a new `auth_tokens` table which stores arbitrary
attributes during the sign up process. For example, when Twitter is
authenticated, a new auth token containing the user's Twitter ID will
be created. When sign up is completed with this token, that Twitter ID
will be set as an attribute on the user's account.
This commit is contained in:
Toby Zerner 2015-09-15 15:56:48 +09:30
parent fd5f53dc09
commit 21b2f55b8c
7 changed files with 162 additions and 48 deletions

View File

@ -13,8 +13,8 @@ export default class LogInButton extends Button {
props.className = (props.className || '') + ' LogInButton';
props.onclick = function() {
const width = 620;
const height = 400;
const width = 1000;
const height = 500;
const $window = $(window);
window.open(app.forum.attribute('baseUrl') + props.path, 'logInPopup',

View File

@ -82,7 +82,7 @@ export default class SignUpModal extends Modal {
<input className="FormControl" name="email" type="email" placeholder={app.trans('core.email')}
value={this.email()}
onchange={m.withAttr('value', this.email)}
disabled={this.loading || this.props.token} />
disabled={this.loading || (this.props.token && this.props.email)} />
</div>
{this.props.token ? '' : (
@ -121,7 +121,8 @@ export default class SignUpModal extends Modal {
{avatar(user)}
<h3>{app.trans('core.welcome_user', {user})}</h3>
<p>{app.trans('core.confirmation_email_sent', {email: <strong>{user.email()}</strong>})}</p>,
<p>{app.trans('core.confirmation_email_sent', {email: <strong>{user.email()}</strong>})}</p>
<p>
<a href={`http://${emailProviderName}`} className="Button Button--primary" target="_blank">
{app.trans('core.go_to', {location: emailProviderName})}
@ -161,7 +162,7 @@ export default class SignUpModal extends Modal {
}
onready() {
if (this.props.username && !this.props.token) {
if (this.props.username && !this.props.email) {
this.$('[name=email]').select();
} else {
this.$('[name=username]').select();

View File

@ -12,7 +12,7 @@
use Flarum\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class MakeEmailTokensUserIdColumnNullable extends Migration
class CreateAuthTokensTable extends Migration
{
/**
* Run the migrations.
@ -21,8 +21,10 @@ class MakeEmailTokensUserIdColumnNullable extends Migration
*/
public function up()
{
$this->schema->table('email_tokens', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
$this->schema->create('auth_tokens', function (Blueprint $table) {
$table->string('id', 100)->primary();
$table->string('payload', 150);
$table->timestamp('created_at');
});
}
@ -33,8 +35,6 @@ class MakeEmailTokensUserIdColumnNullable extends Migration
*/
public function down()
{
$this->schema->table('email_tokens', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->change();
});
$this->schema->drop('auth_tokens');
}
}

View File

@ -0,0 +1,98 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Core\Users;
use Flarum\Core\Model;
use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
use DateTime;
/**
* @todo document database columns with @property
*/
class AuthToken extends Model
{
/**
* {@inheritdoc}
*/
protected $table = 'auth_tokens';
/**
* {@inheritdoc}
*/
protected $dates = ['created_at'];
/**
* Use a custom primary key for this model.
*
* @var bool
*/
public $incrementing = false;
/**
* Generate an email token for the specified user.
*
* @param string $email
*
* @return static
*/
public static function generate($payload)
{
$token = new static;
$token->id = str_random(40);
$token->payload = $payload;
$token->created_at = time();
return $token;
}
/**
* Unserialize the payload attribute from the database's JSON value.
*
* @param string $value
* @return string
*/
public function getPayloadAttribute($value)
{
return json_decode($value, true);
}
/**
* Serialize the payload attribute to be stored in the database as JSON.
*
* @param string $value
*/
public function setPayloadAttribute($value)
{
$this->attributes['payload'] = json_encode($value);
}
/**
* Find the token with the given ID, and assert that it has not expired.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $id
*
* @throws InvalidConfirmationTokenException
*
* @return static
*/
public function scopeValidOrFail($query, $id)
{
$token = $query->find($id);
if (! $token || $token->created_at < new DateTime('-1 day')) {
throw new InvalidConfirmationTokenException;
}
return $token;
}
}

View File

@ -11,7 +11,7 @@
namespace Flarum\Core\Users\Commands;
use Flarum\Core\Users\User;
use Flarum\Core\Users\EmailToken;
use Flarum\Core\Users\AuthToken;
use Flarum\Events\UserWillBeSaved;
use Flarum\Core\Support\DispatchesEvents;
use Flarum\Core\Settings\SettingsRepository;
@ -54,30 +54,36 @@ class RegisterUserHandler
throw new PermissionDeniedException;
}
// If a valid email confirmation token was provided as an attribute,
// then we can create a random password for this user and consider their
// email address confirmed.
if (isset($data['attributes']['token'])) {
$token = EmailToken::whereNull('user_id')->validOrFail($data['attributes']['token']);
$username = array_get($data, 'attributes.username');
$email = array_get($data, 'attributes.email');
$password = array_get($data, 'attributes.password');
$email = $token->email;
$password = array_get($data, 'attributes.password', str_random(20));
} else {
$email = array_get($data, 'attributes.email');
$password = array_get($data, 'attributes.password');
// If a valid authentication token was provided as an attribute,
// then we won't require the user to choose a password.
if (isset($data['attributes']['token'])) {
$token = AuthToken::validOrFail($data['attributes']['token']);
$password = $password ?: str_random(20);
}
// Create the user's new account. If their email was set via token, then
// we can activate their account from the get-go, and they won't need
// to confirm their email address.
$user = User::register(
array_get($data, 'attributes.username'),
$username,
$email,
$password
);
// If a valid authentication token was provided, then we will assign
// the attributes associated with it to the user's account. If this
// includes an email address, then we will activate the user's account
// from the get-go.
if (isset($token)) {
$user->activate();
foreach ($token->payload as $k => $v) {
$user->$k = $v;
}
if (isset($token->payload['email'])) {
$user->activate();
}
}
event(new UserWillBeSaved($user, $actor, $data));

View File

@ -44,7 +44,7 @@ class EmailToken extends Model
*
* @return static
*/
public static function generate($email, $userId = null)
public static function generate($email, $userId)
{
$token = new static;

View File

@ -13,7 +13,7 @@ namespace Flarum\Forum\Actions;
use Flarum\Core\Users\User;
use Zend\Diactoros\Response\HtmlResponse;
use Flarum\Api\Commands\GenerateAccessToken;
use Flarum\Core\Users\EmailToken;
use Flarum\Core\Users\AuthToken;
trait ExternalAuthenticatorTrait
{
@ -25,33 +25,42 @@ trait ExternalAuthenticatorTrait
protected $bus;
/**
* Respond with JavaScript to tell the Flarum app that the user has been
* authenticated, or with information about their sign up status.
* Respond with JavaScript to inform the Flarum app about the user's
* authentication status.
*
* @param string $email The email of the user's account.
* @param string $username A suggested username for the user's account.
* An array of identification attributes must be passed as the first
* argument. These are checked against existing user accounts; if a match is
* found, then the user is authenticated and logged into that account via
* cookie. The Flarum app will then simply refresh the page.
*
* If no matching account is found, then an AuthToken will be generated to
* store the identification attributes. This token, along with an optional
* array of suggestions, will be passed into the Flarum app's sign up modal.
* This results in the user not having to choose a password. When they
* complete their registration, the identification attributes will be
* set on their new user account.
*
* @param array $identification
* @param array $suggestions
* @return HtmlResponse
*/
protected function authenticated($email, $username)
protected function authenticated(array $identification, array $suggestions = [])
{
$user = User::where('email', $email)->first();
$user = User::where($identification)->first();
// If a user with this email address doesn't already exist, then we will
// generate a unique confirmation token for this email address and add
// it to the response, along with the email address and a suggested
// username. Otherwise, we will log in the existing user by generating
// an access token.
if (! $user) {
$token = EmailToken::generate($email);
$token->save();
$payload = compact('email', 'username');
$payload['token'] = $token->id;
} else {
// If a user with these attributes already exists, then we will log them
// in by generating an access token. Otherwise, we will generate a
// unique token for these attributes and add it to the response, along
// with the suggested account information.
if ($user) {
$accessToken = $this->bus->dispatch(new GenerateAccessToken($user->id));
$payload = ['authenticated' => true];
} else {
$token = AuthToken::generate($identification);
$token->save();
$payload = array_merge($identification, $suggestions, ['token' => $token->id]);
}
$content = sprintf('<script>