From 21b2f55b8c3baf4ab27d39194a545d0e331096ed Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 15 Sep 2015 15:56:48 +0930 Subject: [PATCH] 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. --- .../js/forum/src/components/LogInButton.js | 4 +- .../js/forum/src/components/SignUpModal.js | 7 +- ...09_15_000000_create_auth_tokens_table.php} | 12 +-- framework/core/src/Core/Users/AuthToken.php | 98 +++++++++++++++++++ .../Users/Commands/RegisterUserHandler.php | 38 ++++--- framework/core/src/Core/Users/EmailToken.php | 2 +- .../Actions/ExternalAuthenticatorTrait.php | 49 ++++++---- 7 files changed, 162 insertions(+), 48 deletions(-) rename framework/core/migrations/{2015_09_15_000000_make_email_tokens_user_id_column_nullable.php => 2015_09_15_000000_create_auth_tokens_table.php} (61%) create mode 100644 framework/core/src/Core/Users/AuthToken.php diff --git a/framework/core/js/forum/src/components/LogInButton.js b/framework/core/js/forum/src/components/LogInButton.js index 3de450589..f936204fb 100644 --- a/framework/core/js/forum/src/components/LogInButton.js +++ b/framework/core/js/forum/src/components/LogInButton.js @@ -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', diff --git a/framework/core/js/forum/src/components/SignUpModal.js b/framework/core/js/forum/src/components/SignUpModal.js index b66e35f3d..19899560e 100644 --- a/framework/core/js/forum/src/components/SignUpModal.js +++ b/framework/core/js/forum/src/components/SignUpModal.js @@ -82,7 +82,7 @@ export default class SignUpModal extends Modal { + disabled={this.loading || (this.props.token && this.props.email)} /> {this.props.token ? '' : ( @@ -121,7 +121,8 @@ export default class SignUpModal extends Modal { {avatar(user)}

{app.trans('core.welcome_user', {user})}

-

{app.trans('core.confirmation_email_sent', {email: {user.email()}})}

, +

{app.trans('core.confirmation_email_sent', {email: {user.email()}})}

+

{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(); diff --git a/framework/core/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php b/framework/core/migrations/2015_09_15_000000_create_auth_tokens_table.php similarity index 61% rename from framework/core/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php rename to framework/core/migrations/2015_09_15_000000_create_auth_tokens_table.php index 5e8ced5e1..e5e1d55f8 100644 --- a/framework/core/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php +++ b/framework/core/migrations/2015_09_15_000000_create_auth_tokens_table.php @@ -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'); } } diff --git a/framework/core/src/Core/Users/AuthToken.php b/framework/core/src/Core/Users/AuthToken.php new file mode 100644 index 000000000..04c9f7174 --- /dev/null +++ b/framework/core/src/Core/Users/AuthToken.php @@ -0,0 +1,98 @@ + + * + * 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; + } +} diff --git a/framework/core/src/Core/Users/Commands/RegisterUserHandler.php b/framework/core/src/Core/Users/Commands/RegisterUserHandler.php index 132a400e0..9d064c748 100644 --- a/framework/core/src/Core/Users/Commands/RegisterUserHandler.php +++ b/framework/core/src/Core/Users/Commands/RegisterUserHandler.php @@ -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)); diff --git a/framework/core/src/Core/Users/EmailToken.php b/framework/core/src/Core/Users/EmailToken.php index eb61b0cd0..a5d03d4bb 100644 --- a/framework/core/src/Core/Users/EmailToken.php +++ b/framework/core/src/Core/Users/EmailToken.php @@ -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; diff --git a/framework/core/src/Forum/Actions/ExternalAuthenticatorTrait.php b/framework/core/src/Forum/Actions/ExternalAuthenticatorTrait.php index 0ed0337ef..601a8c566 100644 --- a/framework/core/src/Forum/Actions/ExternalAuthenticatorTrait.php +++ b/framework/core/src/Forum/Actions/ExternalAuthenticatorTrait.php @@ -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('