From 6beb4fe898d16d4220dcd91157e4c22d3348953d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 15 Sep 2015 11:27:31 +0930 Subject: [PATCH] Add external authenticator (social login) API Allows registrations to be completed with a pre-confirmed email address and no password. --- js/admin/src/components/AppearancePage.js | 1 + js/admin/src/components/EditCustomCssModal.js | 1 + js/forum/src/ForumApp.js | 24 ++++ js/forum/src/components/LogInButton.js | 30 +++++ js/forum/src/components/LogInButtons.js | 25 +++++ js/forum/src/components/LogInModal.js | 8 ++ js/forum/src/components/SignUpModal.js | 104 +++++++++++------- js/lib/components/Button.js | 3 +- less/forum/LogInButton.less | 11 ++ less/forum/app.less | 1 + locale/en.yml | 1 - ...02_24_000000_create_email_tokens_table.php | 2 +- ...e_email_tokens_user_id_column_nullable.php | 40 +++++++ .../LoginWithCookieAndCheckAdmin.php | 38 +++---- src/Api/AccessToken.php | 10 +- src/Api/Actions/Users/CreateAction.php | 6 +- src/Api/Middleware/LoginWithHeader.php | 2 +- .../Users/Commands/ConfirmEmailHandler.php | 11 +- .../Users/Commands/RegisterUserHandler.php | 52 +++++++-- src/Core/Users/EmailToken.php | 28 ++++- .../Listeners/EmailConfirmationMailer.php | 9 +- src/Forum/Actions/ClientAction.php | 1 - .../Actions/ExternalAuthenticatorTrait.php | 70 ++++++++++++ src/Forum/Actions/LoginAction.php | 2 +- src/Forum/Actions/RegisterAction.php | 80 ++++++++++++++ src/Forum/ForumServiceProvider.php | 6 + src/Forum/Middleware/LoginWithCookie.php | 46 ++++++-- 27 files changed, 516 insertions(+), 96 deletions(-) create mode 100644 js/forum/src/components/LogInButton.js create mode 100644 js/forum/src/components/LogInButtons.js create mode 100644 less/forum/LogInButton.less create mode 100644 migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php create mode 100644 src/Forum/Actions/ExternalAuthenticatorTrait.php create mode 100644 src/Forum/Actions/RegisterAction.php diff --git a/js/admin/src/components/AppearancePage.js b/js/admin/src/components/AppearancePage.js index 67b1884e0..2b6fd436c 100644 --- a/js/admin/src/components/AppearancePage.js +++ b/js/admin/src/components/AppearancePage.js @@ -44,6 +44,7 @@ export default class AppearancePage extends Component { {Button.component({ className: 'Button Button--primary', + type: 'submit', children: 'Save Changes', loading: this.loading })} diff --git a/js/admin/src/components/EditCustomCssModal.js b/js/admin/src/components/EditCustomCssModal.js index 1619a66ec..651a4f183 100644 --- a/js/admin/src/components/EditCustomCssModal.js +++ b/js/admin/src/components/EditCustomCssModal.js @@ -30,6 +30,7 @@ export default class EditCustomCssModal extends Modal {
{Button.component({ className: 'Button Button--primary', + type: 'submit', children: 'Save Changes', loading: this.loading })} diff --git a/js/forum/src/ForumApp.js b/js/forum/src/ForumApp.js index 9e7edd397..2abeea524 100644 --- a/js/forum/src/ForumApp.js +++ b/js/forum/src/ForumApp.js @@ -4,6 +4,7 @@ import Search from 'flarum/components/Search'; import Composer from 'flarum/components/Composer'; import ReplyComposer from 'flarum/components/ReplyComposer'; import DiscussionPage from 'flarum/components/DiscussionPage'; +import SignUpModal from 'flarum/components/SignUpModal'; export default class ForumApp extends App { constructor(...args) { @@ -76,4 +77,27 @@ export default class ForumApp extends App { return this.current instanceof DiscussionPage && this.current.discussion === discussion; } + + /** + * Callback for when an external authenticator (social login) action has + * completed. + * + * If the payload indicates that the user has been logged in, then the page + * will be reloaded. Otherwise, a SignUpModal will be opened, prefilled + * with the provided details. + * + * @param {Object} payload A dictionary of props to pass into the sign up + * modal. A truthy `authenticated` prop indicates that the user has logged + * in, and thus the page is reloaded. + * @public + */ + authenticationComplete(payload) { + if (payload.authenticated) { + window.location.reload(); + } else { + const modal = new SignUpModal(payload); + this.modal.show(modal); + modal.$('[name=password]').focus(); + } + } } diff --git a/js/forum/src/components/LogInButton.js b/js/forum/src/components/LogInButton.js new file mode 100644 index 000000000..3de450589 --- /dev/null +++ b/js/forum/src/components/LogInButton.js @@ -0,0 +1,30 @@ +import Button from 'flarum/components/Button'; + +/** + * The `LogInButton` component displays a social login button which will open + * a popup window containing the specified path. + * + * ### Props + * +* - `path` + */ +export default class LogInButton extends Button { + static initProps(props) { + props.className = (props.className || '') + ' LogInButton'; + + props.onclick = function() { + const width = 620; + const height = 400; + const $window = $(window); + + window.open(app.forum.attribute('baseUrl') + props.path, 'logInPopup', + `width=${width},` + + `height=${height},` + + `top=${$window.height() / 2 - height / 2},` + + `left=${$window.width() / 2 - width / 2},` + + 'status=no,scrollbars=no,resizable=no'); + }; + + super.initProps(props); + } +} diff --git a/js/forum/src/components/LogInButtons.js b/js/forum/src/components/LogInButtons.js new file mode 100644 index 000000000..ef2c853e9 --- /dev/null +++ b/js/forum/src/components/LogInButtons.js @@ -0,0 +1,25 @@ +import Component from 'flarum/Component'; +import ItemList from 'flarum/utils/ItemList'; + +/** + * The `LogInButtons` component displays a collection of social login buttons. + */ +export default class LogInButtons extends Component { + view() { + return ( +
+ {this.items().toArray()} +
+ ); + } + + /** + * Build a list of LogInButton components. + * + * @return {ItemList} + * @public + */ + items() { + return new ItemList(); + } +} diff --git a/js/forum/src/components/LogInModal.js b/js/forum/src/components/LogInModal.js index ad839a863..ad2ac1769 100644 --- a/js/forum/src/components/LogInModal.js +++ b/js/forum/src/components/LogInModal.js @@ -3,6 +3,7 @@ import ForgotPasswordModal from 'flarum/components/ForgotPasswordModal'; import SignUpModal from 'flarum/components/SignUpModal'; import Alert from 'flarum/components/Alert'; import Button from 'flarum/components/Button'; +import LogInButtons from 'flarum/components/LogInButtons'; /** * The `LogInModal` component displays a modal dialog with a login form. @@ -42,6 +43,8 @@ export default class LogInModal extends Modal { content() { return [
+ +
{app.trans('core.forgot_password_link')}

+ {app.forum.attribute('allowSignUp') ? (

{app.trans('core.before_sign_up_link')}{' '} @@ -84,6 +88,8 @@ export default class LogInModal extends Modal { /** * Open the forgot password modal, prefilling it with an email if the user has * entered one. + * + * @public */ forgotPassword() { const email = this.email(); @@ -95,6 +101,8 @@ export default class LogInModal extends Modal { /** * Open the sign up modal, prefilling it with an email/username/password if * the user has entered one. + * + * @public */ signUp() { const props = {password: this.password()}; diff --git a/js/forum/src/components/SignUpModal.js b/js/forum/src/components/SignUpModal.js index 0cbdb231e..3d6ffb1df 100644 --- a/js/forum/src/components/SignUpModal.js +++ b/js/forum/src/components/SignUpModal.js @@ -2,6 +2,7 @@ import Modal from 'flarum/components/Modal'; import LogInModal from 'flarum/components/LogInModal'; import avatar from 'flarum/helpers/avatar'; import Button from 'flarum/components/Button'; +import LogInButtons from 'flarum/components/LogInButtons'; /** * The `SignUpModal` component displays a modal dialog with a singup form. @@ -11,6 +12,7 @@ import Button from 'flarum/components/Button'; * - `username` * - `email` * - `password` + * - `token` An email token to sign up with. */ export default class SignUpModal extends Modal { constructor(...args) { @@ -65,7 +67,9 @@ export default class SignUpModal extends Modal { } body() { - const body = [( + const body = [ + this.props.token ? '' : , +

+ disabled={this.loading || this.props.token} />
-
- -
+ {this.props.token ? '' : ( +
+ +
+ )}
- {Button.component({ - className: 'Button Button--primary Button--block', - type: 'submit', - loading: this.loading, - children: app.trans('core.sign_up') - })} +
- )]; + ]; if (this.welcomeUser) { const user = this.welcomeUser; @@ -115,20 +121,12 @@ export default class SignUpModal extends Modal { {avatar(user)}

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

- {!user.isActivated() ? [ -

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

, -

- - {app.trans('core.go_to', {location: emailProviderName})} - -

- ] : ( -

- -

- )} +

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

, +

+ + {app.trans('core.go_to', {location: emailProviderName})} + +

@@ -150,6 +148,8 @@ export default class SignUpModal extends Modal { /** * Open the log in modal, prefilling it with an email/username/password if * the user has entered one. + * + * @public */ logIn() { const props = { @@ -161,7 +161,7 @@ export default class SignUpModal extends Modal { } onready() { - if (this.props.username) { + if (this.props.username && !this.props.token) { this.$('[name=email]').select(); } else { super.onready(); @@ -175,24 +175,50 @@ export default class SignUpModal extends Modal { const data = this.submitData(); - app.store.createRecord('users').save(data).then( - user => { - this.welcomeUser = user; - this.loading = false; - m.redraw(); + app.request({ + url: app.forum.attribute('baseUrl') + '/register', + method: 'POST', + data + }).then( + payload => { + const user = app.store.pushPayload(payload); + + // If the user's new account has been activated, then we can assume + // that they have been logged in too. Thus, we will reload the page. + // Otherwise, we will show a message asking them to check their email. + if (user.isActivated()) { + window.location.reload(); + } else { + this.welcomeUser = user; + this.loading = false; + m.redraw(); + } }, response => { this.loading = false; - this.handleErrors(response.errors); + this.handleErrors(response); } ); } + /** + * Get the data that should be submitted in the sign-up request. + * + * @return {Object} + * @public + */ submitData() { - return { + const data = { username: this.username(), - email: this.email(), - password: this.password() + email: this.email() }; + + if (this.props.token) { + data.token = this.props.token; + } else { + data.password = this.password(); + } + + return data; } } diff --git a/js/lib/components/Button.js b/js/lib/components/Button.js index a2ae2d7de..eed25c372 100644 --- a/js/lib/components/Button.js +++ b/js/lib/components/Button.js @@ -25,7 +25,8 @@ export default class Button extends Component { delete attrs.children; - attrs.className = (attrs.className || ''); + attrs.className = attrs.className || ''; + attrs.type = attrs.type || 'button'; const iconName = extract(attrs, 'icon'); if (iconName) attrs.className += ' hasIcon'; diff --git a/less/forum/LogInButton.less b/less/forum/LogInButton.less new file mode 100644 index 000000000..f0fd6c1e9 --- /dev/null +++ b/less/forum/LogInButton.less @@ -0,0 +1,11 @@ +.LogInButton { + &:extend(.Button--block); +} +.LogInButtons { + width: 200px; + margin: 0 auto 20px; + + .LogInButton { + margin-bottom: 5px; + } +} diff --git a/less/forum/app.less b/less/forum/app.less index 40bac2b0a..d9b4a3122 100644 --- a/less/forum/app.less +++ b/less/forum/app.less @@ -10,6 +10,7 @@ @import "EditUserModal.less"; @import "Hero.less"; @import "IndexPage.less"; +@import "LogInButton.less"; @import "LogInModal.less"; @import "NotificationGrid.less"; @import "NotificationList.less"; diff --git a/locale/en.yml b/locale/en.yml index e68aac89a..c40cdc9e7 100644 --- a/locale/en.yml +++ b/locale/en.yml @@ -28,7 +28,6 @@ core: discussion_started: "Started {ago} by {username}" discussion_title: Discussion Title discussions: Discussions - dismiss: Dismiss edit: Edit editing_post: "Post #{number} in {discussion}" email: Email diff --git a/migrations/2015_02_24_000000_create_email_tokens_table.php b/migrations/2015_02_24_000000_create_email_tokens_table.php index e14bff848..a199c3e03 100644 --- a/migrations/2015_02_24_000000_create_email_tokens_table.php +++ b/migrations/2015_02_24_000000_create_email_tokens_table.php @@ -23,8 +23,8 @@ class CreateEmailTokensTable extends Migration { $this->schema->create('email_tokens', function (Blueprint $table) { $table->string('id', 100)->primary(); - $table->integer('user_id')->unsigned(); $table->string('email', 150); + $table->integer('user_id')->unsigned(); $table->timestamp('created_at'); }); } diff --git a/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php b/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php new file mode 100644 index 000000000..5e8ced5e1 --- /dev/null +++ b/migrations/2015_09_15_000000_make_email_tokens_user_id_column_nullable.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Flarum\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; + +class MakeEmailTokensUserIdColumnNullable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + $this->schema->table('email_tokens', function (Blueprint $table) { + $table->integer('user_id')->unsigned()->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $this->schema->table('email_tokens', function (Blueprint $table) { + $table->integer('user_id')->unsigned()->change(); + }); + } +} diff --git a/src/Admin/Middleware/LoginWithCookieAndCheckAdmin.php b/src/Admin/Middleware/LoginWithCookieAndCheckAdmin.php index b1b0d6d72..04169ca3f 100644 --- a/src/Admin/Middleware/LoginWithCookieAndCheckAdmin.php +++ b/src/Admin/Middleware/LoginWithCookieAndCheckAdmin.php @@ -15,36 +15,32 @@ use Illuminate\Contracts\Container\Container; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Stratigility\MiddlewareInterface; +use Flarum\Forum\Middleware\LoginWithCookie; +use Flarum\Core\Exceptions\PermissionDeniedException; -class LoginWithCookieAndCheckAdmin implements MiddlewareInterface +class LoginWithCookieAndCheckAdmin extends LoginWithCookie { - /** - * @var Container - */ - protected $app; - - /** - * @param Container $app - */ - public function __construct(Container $app) - { - $this->app = $app; - } - /** * {@inheritdoc} */ public function __invoke(Request $request, Response $response, callable $out = null) { - if (($token = array_get($request->getCookieParams(), 'flarum_remember')) && - ($accessToken = AccessToken::valid($token)) && - $accessToken->user->isAdmin() - ) { - $this->app->instance('flarum.actor', $accessToken->user); - } else { - die('Access Denied'); + if (! $this->logIn($request)) { + throw new PermissionDeniedException; } return $out ? $out($request, $response) : $response; } + + /** + * {@inheritdoc} + */ + protected function getToken(Request $request) + { + $token = parent::getToken($request); + + if ($token && $token->user && $token->user->isAdmin()) { + return $token; + } + } } diff --git a/src/Api/AccessToken.php b/src/Api/AccessToken.php index fdad6f4ec..9a6efa56e 100644 --- a/src/Api/AccessToken.php +++ b/src/Api/AccessToken.php @@ -12,6 +12,7 @@ namespace Flarum\Api; use Flarum\Core\Model; use DateTime; +use Exception; /** * @todo document database columns with @property @@ -55,14 +56,13 @@ class AccessToken extends Model } /** - * Get the given token only if it is valid. + * Check that the token has not expired. * - * @param string $token - * @return static|null + * @return bool */ - public static function valid($token) + public function isValid() { - return static::where('id', $token)->where('expires_at', '>', new DateTime)->first(); + return $this->expires_at > new DateTime; } /** diff --git a/src/Api/Actions/Users/CreateAction.php b/src/Api/Actions/Users/CreateAction.php index d88117f2f..92b85730e 100644 --- a/src/Api/Actions/Users/CreateAction.php +++ b/src/Api/Actions/Users/CreateAction.php @@ -73,8 +73,12 @@ class CreateAction extends BaseCreateAction */ protected function create(JsonApiRequest $request) { - return $this->bus->dispatch( + $user = $this->bus->dispatch( new RegisterUser($request->actor, $request->get('data')) ); + + $request->actor = $user; + + return $user; } } diff --git a/src/Api/Middleware/LoginWithHeader.php b/src/Api/Middleware/LoginWithHeader.php index 1a66456af..c5873bb40 100644 --- a/src/Api/Middleware/LoginWithHeader.php +++ b/src/Api/Middleware/LoginWithHeader.php @@ -50,7 +50,7 @@ class LoginWithHeader implements MiddlewareInterface if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) { $token = substr($parts[0], strlen($this->prefix)); - if ($accessToken = AccessToken::valid($token)) { + if (($accessToken = AccessToken::find($token)) && $accessToken->isValid()) { $this->app->instance('flarum.actor', $user = $accessToken->user); $user->updateLastSeen()->save(); diff --git a/src/Core/Users/Commands/ConfirmEmailHandler.php b/src/Core/Users/Commands/ConfirmEmailHandler.php index 71f766915..9825f8926 100644 --- a/src/Core/Users/Commands/ConfirmEmailHandler.php +++ b/src/Core/Users/Commands/ConfirmEmailHandler.php @@ -13,7 +13,6 @@ namespace Flarum\Core\Users\Commands; use Flarum\Core\Users\UserRepository; use Flarum\Events\UserWillBeSaved; use Flarum\Core\Support\DispatchesEvents; -use Flarum\Core\Exceptions\InvalidConfirmationTokenException; use Flarum\Core\Users\EmailToken; use DateTime; @@ -36,16 +35,14 @@ class ConfirmEmailHandler /** * @param ConfirmEmail $command - * @return \Flarum\Core\Users\User + * * @throws InvalidConfirmationTokenException + * + * @return \Flarum\Core\Users\User */ public function handle(ConfirmEmail $command) { - $token = EmailToken::find($command->token); - - if (! $token || $token->created_at < new DateTime('-1 day')) { - throw new InvalidConfirmationTokenException; - } + $token = EmailToken::validOrFail($command->token); $user = $token->user; $user->changeEmail($token->email); diff --git a/src/Core/Users/Commands/RegisterUserHandler.php b/src/Core/Users/Commands/RegisterUserHandler.php index b68a5ea34..132a400e0 100644 --- a/src/Core/Users/Commands/RegisterUserHandler.php +++ b/src/Core/Users/Commands/RegisterUserHandler.php @@ -11,17 +11,25 @@ namespace Flarum\Core\Users\Commands; use Flarum\Core\Users\User; +use Flarum\Core\Users\EmailToken; use Flarum\Events\UserWillBeSaved; use Flarum\Core\Support\DispatchesEvents; use Flarum\Core\Settings\SettingsRepository; use Flarum\Core\Exceptions\PermissionDeniedException; +use DateTime; class RegisterUserHandler { use DispatchesEvents; + /** + * @var SettingsRepository + */ protected $settings; + /** + * @param SettingsRepository $settings + */ public function __construct(SettingsRepository $settings) { $this->settings = $settings; @@ -29,27 +37,57 @@ class RegisterUserHandler /** * @param RegisterUser $command + * + * @throws PermissionDeniedException if signup is closed and the actor is + * not an administrator. + * @throws \Flarum\Core\Exceptions\InvalidConfirmationTokenException if an + * email confirmation token is provided but is invalid. + * * @return User - * @throws PermissionDeniedException */ public function handle(RegisterUser $command) { - if (! $this->settings->get('allow_sign_up')) { - throw new PermissionDeniedException; - } - $actor = $command->actor; $data = $command->data; + if (! $this->settings->get('allow_sign_up') && ! $actor->isAdmin()) { + 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']); + + $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'); + } + + // 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'), - array_get($data, 'attributes.email'), - array_get($data, 'attributes.password') + $email, + $password ); + if (isset($token)) { + $user->activate(); + } + event(new UserWillBeSaved($user, $actor, $data)); $user->save(); + + if (isset($token)) { + $token->delete(); + } + $this->dispatchEventsFor($user); return $user; diff --git a/src/Core/Users/EmailToken.php b/src/Core/Users/EmailToken.php index 92acd1bc9..eb61b0cd0 100644 --- a/src/Core/Users/EmailToken.php +++ b/src/Core/Users/EmailToken.php @@ -11,6 +11,8 @@ namespace Flarum\Core\Users; use Flarum\Core\Model; +use Flarum\Core\Exceptions\InvalidConfirmationTokenException; +use DateTime; /** * @todo document database columns with @property @@ -37,11 +39,12 @@ class EmailToken extends Model /** * Generate an email token for the specified user. * - * @param int $userId * @param string $email + * @param int $userId + * * @return static */ - public static function generate($userId, $email) + public static function generate($email, $userId = null) { $token = new static; @@ -62,4 +65,25 @@ class EmailToken extends Model { return $this->belongsTo('Flarum\Core\Users\User'); } + + /** + * 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/src/Core/Users/Listeners/EmailConfirmationMailer.php b/src/Core/Users/Listeners/EmailConfirmationMailer.php index 497285e3a..7ca9eb384 100755 --- a/src/Core/Users/Listeners/EmailConfirmationMailer.php +++ b/src/Core/Users/Listeners/EmailConfirmationMailer.php @@ -57,6 +57,11 @@ class EmailConfirmationMailer public function whenUserWasRegistered(UserWasRegistered $event) { $user = $event->user; + + if ($user->is_activated) { + return; + } + $data = $this->getEmailData($user, $user->email); $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function (Message $message) use ($user) { @@ -82,11 +87,12 @@ class EmailConfirmationMailer /** * @param User $user * @param string $email + * * @return EmailToken */ protected function generateToken(User $user, $email) { - $token = EmailToken::generate($user->id, $email); + $token = EmailToken::generate($email, $user->id); $token->save(); return $token; @@ -97,6 +103,7 @@ class EmailConfirmationMailer * * @param User $user * @param string $email + * * @return array */ protected function getEmailData(User $user, $email) diff --git a/src/Forum/Actions/ClientAction.php b/src/Forum/Actions/ClientAction.php index bf358d1a3..49224baf6 100644 --- a/src/Forum/Actions/ClientAction.php +++ b/src/Forum/Actions/ClientAction.php @@ -56,7 +56,6 @@ class ClientAction extends BaseClientAction 'core.discussion_started', 'core.discussion_title', 'core.discussions', - 'core.dismiss', 'core.edit', 'core.editing_post', 'core.email', diff --git a/src/Forum/Actions/ExternalAuthenticatorTrait.php b/src/Forum/Actions/ExternalAuthenticatorTrait.php new file mode 100644 index 000000000..0ed0337ef --- /dev/null +++ b/src/Forum/Actions/ExternalAuthenticatorTrait.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Forum\Actions; + +use Flarum\Core\Users\User; +use Zend\Diactoros\Response\HtmlResponse; +use Flarum\Api\Commands\GenerateAccessToken; +use Flarum\Core\Users\EmailToken; + +trait ExternalAuthenticatorTrait +{ + use WritesRememberCookie; + + /** + * @var \Illuminate\Contracts\Bus\Dispatcher + */ + protected $bus; + + /** + * Respond with JavaScript to tell the Flarum app that the user has been + * authenticated, or with information about their sign up status. + * + * @param string $email The email of the user's account. + * @param string $username A suggested username for the user's account. + * @return HtmlResponse + */ + protected function authenticated($email, $username) + { + $user = User::where('email', $email)->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 { + $accessToken = $this->bus->dispatch(new GenerateAccessToken($user->id)); + + $payload = ['authenticated' => true]; + } + + $content = sprintf('', json_encode($payload)); + + $response = new HtmlResponse($content); + + if (isset($accessToken)) { + $response = $this->withRememberCookie($response, $accessToken->id); + } + + return $response; + } +} diff --git a/src/Forum/Actions/LoginAction.php b/src/Forum/Actions/LoginAction.php index 847c0c5a0..d089df799 100644 --- a/src/Forum/Actions/LoginAction.php +++ b/src/Forum/Actions/LoginAction.php @@ -47,7 +47,7 @@ class LoginAction extends Action /** * @param Request $request * @param array $routeParams - * @return \Psr\Http\Message\ResponseInterface|EmptyResponse + * @return JsonResponse|EmptyResponse */ public function handle(Request $request, array $routeParams = []) { diff --git a/src/Forum/Actions/RegisterAction.php b/src/Forum/Actions/RegisterAction.php new file mode 100644 index 000000000..db9d74e9e --- /dev/null +++ b/src/Forum/Actions/RegisterAction.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Forum\Actions; + +use Flarum\Api\Client; +use Flarum\Api\AccessToken; +use Flarum\Events\UserLoggedIn; +use Flarum\Support\Action; +use Flarum\Api\Commands\GenerateAccessToken; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\EmptyResponse; +use Zend\Diactoros\Response\JsonResponse; +use Illuminate\Contracts\Bus\Dispatcher; +use DateTime; + +class RegisterAction extends Action +{ + use WritesRememberCookie; + + /** + * @var Dispatcher + */ + protected $bus; + + /** + * @var Client + */ + protected $apiClient; + + /** + * @param Dispatcher $bus + * @param Client $apiClient + */ + public function __construct(Dispatcher $bus, Client $apiClient) + { + $this->bus = $bus; + $this->apiClient = $apiClient; + } + + /** + * @param Request $request + * @param array $routeParams + * + * @return JsonResponse + */ + public function handle(Request $request, array $routeParams = []) + { + $params = ['data' => ['attributes' => $request->getAttributes()]]; + + $apiResponse = $this->apiClient->send(app('flarum.actor'), 'Flarum\Api\Actions\Users\CreateAction', $params); + + $body = $apiResponse->getBody(); + $statusCode = $apiResponse->getStatusCode(); + + $response = new JsonResponse($body, $statusCode); + + if (! empty($body->data->attributes->isActivated)) { + $token = $this->bus->dispatch(new GenerateAccessToken($body->data->id)); + + // Extend the token's expiry to 2 weeks so that we can set a + // remember cookie + AccessToken::where('id', $token->id)->update(['expires_at' => new DateTime('+2 weeks')]); + + return $this->withRememberCookie( + $response, + $token->id + ); + } + + return $response; + } +} diff --git a/src/Forum/ForumServiceProvider.php b/src/Forum/ForumServiceProvider.php index af6d98acf..0edd323e6 100644 --- a/src/Forum/ForumServiceProvider.php +++ b/src/Forum/ForumServiceProvider.php @@ -102,6 +102,12 @@ class ForumServiceProvider extends ServiceProvider $this->action('Flarum\Forum\Actions\LoginAction') ); + $routes->post( + '/register', + 'flarum.forum.register', + $this->action('Flarum\Forum\Actions\RegisterAction') + ); + $routes->get( '/confirm/{token}', 'flarum.forum.confirmEmail', diff --git a/src/Forum/Middleware/LoginWithCookie.php b/src/Forum/Middleware/LoginWithCookie.php index 95e5e9605..78ce75879 100644 --- a/src/Forum/Middleware/LoginWithCookie.php +++ b/src/Forum/Middleware/LoginWithCookie.php @@ -36,14 +36,46 @@ class LoginWithCookie implements MiddlewareInterface */ public function __invoke(Request $request, Response $response, callable $out = null) { - if (($token = array_get($request->getCookieParams(), 'flarum_remember')) && - ($accessToken = AccessToken::valid($token)) - ) { - $this->app->instance('flarum.actor', $user = $accessToken->user); - - $user->updateLastSeen()->save(); - } + $this->logIn($request); return $out ? $out($request, $response) : $response; } + + /** + * Set the application's actor instance according to the request token. + * + * @param Request $request + * @return bool + */ + protected function logIn(Request $request) + { + if ($token = $this->getToken($request)) { + if (! $token->isValid()) { + // TODO: https://github.com/flarum/core/issues/253 + } elseif ($token->user) { + $this->app->instance('flarum.actor', $user = $token->user); + + $user->updateLastSeen()->save(); + + return true; + } + } + + return false; + } + + /** + * Get the access token referred to by the request cookie. + * + * @param Request $request + * @return AccessToken|null + */ + protected function getToken(Request $request) + { + $token = array_get($request->getCookieParams(), 'flarum_remember'); + + if ($token) { + return AccessToken::find($token); + } + } }