From 0c429c1b9ff57f0d889d54ac0046f35214826a0d Mon Sep 17 00:00:00 2001
From: Toby Zerner <toby.zerner@gmail.com>
Date: Sat, 22 Sep 2018 13:48:27 +0930
Subject: [PATCH] Auth token and avatarUrl security improvements (#1514)

* Remove AbstractOAuth2Controller

There is no reason to provide an implementation for a specific oAuth2
library in core; it's not generic enough (eg. auth-twitter can't use it).

This code could be moved into another package which auth extensions
depend on, but it's a negligible amount of relatively simple code that
I don't think it's worth the trouble.

* Introduce login providers

Users can have many login providers (a combination of a provider name
and an identifier for that user, eg. their Facebook ID).

After retrieving user data from a provider (eg. Facebook), you pass the
login provider details into the Auth\ResponseFactory. If an associated
user is found, a response that logs them in will be returned. If not, a
registration token will be created so the user can proceed to sign up.
Once the token is fulfilled, the login provider will be associated with
the user.
---
 framework/core/composer.json                  |   1 -
 .../core/js/src/forum/ForumApplication.js     |   5 +-
 .../js/src/forum/components/SignUpModal.js    |   8 +-
 ...100_change_registration_tokens_columns.php |  33 +++++
 ...22_004200_create_login_providers_table.php |  28 ++++
 .../core/src/Forum/Auth/Registration.php      | 127 ++++++++++++++++++
 .../core/src/Forum/Auth/ResponseFactory.php   |  81 +++++++++++
 .../Forum/AuthenticationResponseFactory.php   | 118 ----------------
 .../Controller/AbstractOAuth2Controller.php   | 107 ---------------
 .../src/Http/Middleware/CollectGarbage.php    |   4 +-
 .../core/src/User/Command/EditUserHandler.php |  43 +-----
 .../src/User/Command/RegisterUserHandler.php  |  98 +++++++-------
 .../User/Event/RegisteringFromProvider.php    |  44 ++++++
 framework/core/src/User/LoginProvider.php     |  60 +++++++++
 .../{AuthToken.php => RegistrationToken.php}  |  55 +++-----
 framework/core/src/User/User.php              |   8 ++
 16 files changed, 462 insertions(+), 358 deletions(-)
 create mode 100644 framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php
 create mode 100644 framework/core/migrations/2018_09_22_004200_create_login_providers_table.php
 create mode 100644 framework/core/src/Forum/Auth/Registration.php
 create mode 100644 framework/core/src/Forum/Auth/ResponseFactory.php
 delete mode 100644 framework/core/src/Forum/AuthenticationResponseFactory.php
 delete mode 100644 framework/core/src/Forum/Controller/AbstractOAuth2Controller.php
 create mode 100644 framework/core/src/User/Event/RegisteringFromProvider.php
 create mode 100644 framework/core/src/User/LoginProvider.php
 rename framework/core/src/User/{AuthToken.php => RegistrationToken.php} (64%)

diff --git a/framework/core/composer.json b/framework/core/composer.json
index ff06ace99..151719c8e 100644
--- a/framework/core/composer.json
+++ b/framework/core/composer.json
@@ -42,7 +42,6 @@
         "illuminate/view": "5.5.*",
         "intervention/image": "^2.3.0",
         "league/flysystem": "^1.0.11",
-        "league/oauth2-client": "~1.0",
         "matthiasmullie/minify": "^1.3",
         "middlewares/base-path": "^1.1",
         "middlewares/base-path-router": "^0.2.1",
diff --git a/framework/core/js/src/forum/ForumApplication.js b/framework/core/js/src/forum/ForumApplication.js
index 2e52abb1d..4a1439ead 100644
--- a/framework/core/js/src/forum/ForumApplication.js
+++ b/framework/core/js/src/forum/ForumApplication.js
@@ -148,17 +148,16 @@ export default class ForumApplication extends Application {
    * 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
+   *     modal. A truthy `loggedIn` prop indicates that the user has logged
    *     in, and thus the page is reloaded.
    * @public
    */
   authenticationComplete(payload) {
-    if (payload.authenticated) {
+    if (payload.loggedIn) {
       window.location.reload();
     } else {
       const modal = new SignUpModal(payload);
       this.modal.show(modal);
-      modal.$('[name=password]').focus();
     }
   }
 }
diff --git a/framework/core/js/src/forum/components/SignUpModal.js b/framework/core/js/src/forum/components/SignUpModal.js
index b651e29aa..94ac1e162 100644
--- a/framework/core/js/src/forum/components/SignUpModal.js
+++ b/framework/core/js/src/forum/components/SignUpModal.js
@@ -42,7 +42,7 @@ export default class SignUpModal extends Modal {
   }
 
   className() {
-    return 'Modal--small SignUpModal' + (this.welcomeUser ? ' SignUpModal--success' : '');
+    return 'Modal--small SignUpModal';
   }
 
   title() {
@@ -61,7 +61,7 @@ export default class SignUpModal extends Modal {
   }
 
   isProvided(field) {
-    return this.props.identificationFields && this.props.identificationFields.indexOf(field) !== -1;
+    return this.props.provided && this.props.provided.indexOf(field) !== -1;
   }
 
   body() {
@@ -179,10 +179,6 @@ export default class SignUpModal extends Modal {
       data.password = this.password();
     }
 
-    if (this.props.avatarUrl) {
-      data.avatarUrl = this.props.avatarUrl;
-    }
-
     return data;
   }
 }
diff --git a/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php b/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php
new file mode 100644
index 000000000..526899c84
--- /dev/null
+++ b/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php
@@ -0,0 +1,33 @@
+<?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.
+ */
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Schema\Builder;
+
+return [
+    'up' => function (Builder $schema) {
+        $schema->table('registration_tokens', function (Blueprint $table) {
+            $table->string('provider');
+            $table->string('identifier');
+            $table->text('user_attributes')->nullable();
+
+            $table->text('payload')->nullable()->change();
+        });
+    },
+
+    'down' => function (Builder $schema) {
+        $schema->table('auth_tokens', function (Blueprint $table) {
+            $table->dropColumn('provider', 'identifier', 'user_attributes');
+
+            $table->string('payload', 150)->change();
+        });
+    }
+];
diff --git a/framework/core/migrations/2018_09_22_004200_create_login_providers_table.php b/framework/core/migrations/2018_09_22_004200_create_login_providers_table.php
new file mode 100644
index 000000000..3d395830e
--- /dev/null
+++ b/framework/core/migrations/2018_09_22_004200_create_login_providers_table.php
@@ -0,0 +1,28 @@
+<?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.
+ */
+
+use Flarum\Database\Migration;
+use Illuminate\Database\Schema\Blueprint;
+
+return Migration::createTable(
+    'login_providers',
+    function (Blueprint $table) {
+        $table->increments('id');
+        $table->unsignedInteger('user_id');
+        $table->string('provider', 100);
+        $table->string('identifier', 100);
+        $table->dateTime('created_at')->nullable();
+        $table->dateTime('last_login_at')->nullable();
+
+        $table->unique(['provider', 'identifier']);
+        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+    }
+);
diff --git a/framework/core/src/Forum/Auth/Registration.php b/framework/core/src/Forum/Auth/Registration.php
new file mode 100644
index 000000000..46ac0d6ad
--- /dev/null
+++ b/framework/core/src/Forum/Auth/Registration.php
@@ -0,0 +1,127 @@
+<?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\Forum\Auth;
+
+class Registration
+{
+    /**
+     * @var array
+     */
+    protected $provided = [];
+
+    /**
+     * @var array
+     */
+    protected $suggested = [];
+
+    /**
+     * @var mixed
+     */
+    protected $payload;
+
+    /**
+     * @return array
+     */
+    public function getProvided(): array
+    {
+        return $this->provided;
+    }
+
+    /**
+     * @return array
+     */
+    public function getSuggested(): array
+    {
+        return $this->suggested;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getPayload()
+    {
+        return $this->payload;
+    }
+
+    /**
+     * @param string $key
+     * @param mixed $value
+     * @return $this
+     */
+    public function provide(string $key, $value): self
+    {
+        $this->provided[$key] = $value;
+
+        return $this;
+    }
+
+    /**
+     * @param string $email
+     * @return $this
+     */
+    public function provideTrustedEmail(string $email): self
+    {
+        return $this->provide('email', $email);
+    }
+
+    /**
+     * @param string $url
+     * @return $this
+     */
+    public function provideAvatar(string $url): self
+    {
+        return $this->provide('avatar_url', $url);
+    }
+
+    /**
+     * @param string $key
+     * @param mixed $value
+     * @return $this
+     */
+    public function suggest(string $key, $value): self
+    {
+        $this->suggested[$key] = $value;
+
+        return $this;
+    }
+
+    /**
+     * @param string $username
+     * @return $this
+     */
+    public function suggestUsername(string $username): self
+    {
+        $username = preg_replace('/[^a-z0-9-_]/i', '', $username);
+
+        return $this->suggest('username', $username);
+    }
+
+    /**
+     * @param string $email
+     * @return $this
+     */
+    public function suggestEmail(string $email): self
+    {
+        return $this->suggest('email', $email);
+    }
+
+    /**
+     * @param mixed $payload
+     * @return $this
+     */
+    public function setPayload($payload): self
+    {
+        $this->payload = $payload;
+
+        return $this;
+    }
+}
diff --git a/framework/core/src/Forum/Auth/ResponseFactory.php b/framework/core/src/Forum/Auth/ResponseFactory.php
new file mode 100644
index 000000000..37eb5e9cc
--- /dev/null
+++ b/framework/core/src/Forum/Auth/ResponseFactory.php
@@ -0,0 +1,81 @@
+<?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\Forum\Auth;
+
+use Flarum\Http\Rememberer;
+use Flarum\User\LoginProvider;
+use Flarum\User\RegistrationToken;
+use Flarum\User\User;
+use Psr\Http\Message\ResponseInterface;
+use Zend\Diactoros\Response\HtmlResponse;
+
+class ResponseFactory
+{
+    /**
+     * @var Rememberer
+     */
+    protected $rememberer;
+
+    /**
+     * @param Rememberer $rememberer
+     */
+    public function __construct(Rememberer $rememberer)
+    {
+        $this->rememberer = $rememberer;
+    }
+
+    public function make(string $provider, string $identifier, callable $configureRegistration): ResponseInterface
+    {
+        if ($user = LoginProvider::logIn($provider, $identifier)) {
+            return $this->makeLoggedInResponse($user);
+        }
+
+        $configureRegistration($registration = new Registration);
+
+        $provided = $registration->getProvided();
+
+        if (! empty($provided['email']) && $user = User::where(array_only($provided, 'email'))->first()) {
+            $user->loginProviders()->create(compact('provider', 'identifier'));
+
+            return $this->makeLoggedInResponse($user);
+        }
+
+        $token = RegistrationToken::generate($provider, $identifier, $provided, $registration->getPayload());
+        $token->save();
+
+        return $this->makeResponse(array_merge(
+            $provided,
+            $registration->getSuggested(),
+            [
+                'token' => $token->id,
+                'provided' => array_keys($provided)
+            ]
+        ));
+    }
+
+    private function makeResponse(array $payload): HtmlResponse
+    {
+        $content = sprintf(
+            '<script>window.close(); window.opener.app.authenticationComplete(%s);</script>',
+            json_encode($payload)
+        );
+
+        return new HtmlResponse($content);
+    }
+
+    private function makeLoggedInResponse(User $user)
+    {
+        $response = $this->makeResponse(['loggedIn' => true]);
+
+        return $this->rememberer->rememberUser($response, $user->id);
+    }
+}
diff --git a/framework/core/src/Forum/AuthenticationResponseFactory.php b/framework/core/src/Forum/AuthenticationResponseFactory.php
deleted file mode 100644
index 6a17c8c2d..000000000
--- a/framework/core/src/Forum/AuthenticationResponseFactory.php
+++ /dev/null
@@ -1,118 +0,0 @@
-<?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\Forum;
-
-use Flarum\Http\Rememberer;
-use Flarum\Http\SessionAuthenticator;
-use Flarum\User\AuthToken;
-use Flarum\User\User;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Zend\Diactoros\Response\HtmlResponse;
-
-class AuthenticationResponseFactory
-{
-    /**
-     * @var SessionAuthenticator
-     */
-    protected $authenticator;
-
-    /**
-     * @var Rememberer
-     */
-    protected $rememberer;
-
-    /**
-     * AuthenticationResponseFactory constructor.
-     * @param SessionAuthenticator $authenticator
-     * @param Rememberer $rememberer
-     */
-    public function __construct(SessionAuthenticator $authenticator, Rememberer $rememberer)
-    {
-        $this->authenticator = $authenticator;
-        $this->rememberer = $rememberer;
-    }
-
-    public function make(Request $request, array $identification, array $suggestions = [])
-    {
-        if (isset($suggestions['username'])) {
-            $suggestions['username'] = $this->sanitizeUsername($suggestions['username']);
-        }
-
-        $user = User::where($identification)->first();
-
-        $payload = $this->getPayload($identification, $suggestions, $user);
-
-        $response = $this->getResponse($payload);
-
-        if ($user) {
-            $session = $request->getAttribute('session');
-            $this->authenticator->logIn($session, $user->id);
-
-            $response = $this->rememberer->rememberUser($response, $user->id);
-        }
-
-        return $response;
-    }
-
-    /**
-     * @param string $username
-     * @return string
-     */
-    private function sanitizeUsername($username)
-    {
-        return preg_replace('/[^a-z0-9-_]/i', '', $username);
-    }
-
-    /**
-     * @param array $payload
-     * @return HtmlResponse
-     */
-    private function getResponse(array $payload)
-    {
-        $content = sprintf(
-            '<script>window.opener.app.authenticationComplete(%s); window.close();</script>',
-            json_encode($payload)
-        );
-
-        return new HtmlResponse($content);
-    }
-
-    /**
-     * @param array $identification
-     * @param array $suggestions
-     * @param User|null $user
-     * @return array
-     */
-    private function getPayload(array $identification, array $suggestions, User $user = null)
-    {
-        // 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) {
-            $payload = ['authenticated' => true];
-        } else {
-            $token = AuthToken::generate($identification);
-            $token->save();
-
-            $payload = array_merge(
-                $identification,
-                $suggestions,
-                ['token' => $token->id],
-                // List of the fields that can't be edited during sign up
-                ['identificationFields' => array_keys($identification)]
-            );
-        }
-
-        return $payload;
-    }
-}
diff --git a/framework/core/src/Forum/Controller/AbstractOAuth2Controller.php b/framework/core/src/Forum/Controller/AbstractOAuth2Controller.php
deleted file mode 100644
index bd6522f64..000000000
--- a/framework/core/src/Forum/Controller/AbstractOAuth2Controller.php
+++ /dev/null
@@ -1,107 +0,0 @@
-<?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\Forum\Controller;
-
-use Flarum\Forum\AuthenticationResponseFactory;
-use League\OAuth2\Client\Provider\ResourceOwnerInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Psr\Http\Server\RequestHandlerInterface;
-use Zend\Diactoros\Response\RedirectResponse;
-
-abstract class AbstractOAuth2Controller implements RequestHandlerInterface
-{
-    /**
-     * @var AuthenticationResponseFactory
-     */
-    protected $authResponse;
-
-    /**
-     * @var \League\OAuth2\Client\Provider\AbstractProvider
-     */
-    protected $provider;
-
-    /**
-     * The access token, once obtained.
-     *
-     * @var string
-     */
-    protected $token;
-
-    /**
-     * @param AuthenticationResponseFactory $authResponse
-     */
-    public function __construct(AuthenticationResponseFactory $authResponse)
-    {
-        $this->authResponse = $authResponse;
-    }
-
-    /**
-     * @param Request $request
-     * @return ResponseInterface
-     */
-    public function handle(Request $request): ResponseInterface
-    {
-        $redirectUri = (string) $request->getAttribute('originalUri', $request->getUri())->withQuery('');
-
-        $this->provider = $this->getProvider($redirectUri);
-
-        $session = $request->getAttribute('session');
-
-        $queryParams = $request->getQueryParams();
-        $code = array_get($queryParams, 'code');
-        $state = array_get($queryParams, 'state');
-
-        if (! $code) {
-            $authUrl = $this->provider->getAuthorizationUrl($this->getAuthorizationUrlOptions());
-            $session->put('oauth2state', $this->provider->getState());
-
-            return new RedirectResponse($authUrl.'&display=popup');
-        } elseif (! $state || $state !== $session->get('oauth2state')) {
-            $session->remove('oauth2state');
-            echo 'Invalid state. Please close the window and try again.';
-            exit;
-        }
-
-        $this->token = $this->provider->getAccessToken('authorization_code', compact('code'));
-
-        $owner = $this->provider->getResourceOwner($this->token);
-
-        $identification = $this->getIdentification($owner);
-        $suggestions = $this->getSuggestions($owner);
-
-        return $this->authResponse->make($request, $identification, $suggestions);
-    }
-
-    /**
-     * @param string $redirectUri
-     * @return \League\OAuth2\Client\Provider\AbstractProvider
-     */
-    abstract protected function getProvider($redirectUri);
-
-    /**
-     * @return array
-     */
-    abstract protected function getAuthorizationUrlOptions();
-
-    /**
-     * @param ResourceOwnerInterface $resourceOwner
-     * @return array
-     */
-    abstract protected function getIdentification(ResourceOwnerInterface $resourceOwner);
-
-    /**
-     * @param ResourceOwnerInterface $resourceOwner
-     * @return array
-     */
-    abstract protected function getSuggestions(ResourceOwnerInterface $resourceOwner);
-}
diff --git a/framework/core/src/Http/Middleware/CollectGarbage.php b/framework/core/src/Http/Middleware/CollectGarbage.php
index 9b0da016c..3160a6aca 100644
--- a/framework/core/src/Http/Middleware/CollectGarbage.php
+++ b/framework/core/src/Http/Middleware/CollectGarbage.php
@@ -13,9 +13,9 @@ namespace Flarum\Http\Middleware;
 
 use Carbon\Carbon;
 use Flarum\Http\AccessToken;
-use Flarum\User\AuthToken;
 use Flarum\User\EmailToken;
 use Flarum\User\PasswordToken;
+use Flarum\User\RegistrationToken;
 use Illuminate\Contracts\Config\Repository as ConfigRepository;
 use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
@@ -64,7 +64,7 @@ class CollectGarbage implements Middleware
 
         EmailToken::where('created_at', '<=', $earliestToKeep)->delete();
         PasswordToken::where('created_at', '<=', $earliestToKeep)->delete();
-        AuthToken::where('created_at', '<=', $earliestToKeep)->delete();
+        RegistrationToken::where('created_at', '<=', $earliestToKeep)->delete();
 
         $this->sessionHandler->gc($this->getSessionLifetimeInSeconds());
     }
diff --git a/framework/core/src/User/Command/EditUserHandler.php b/framework/core/src/User/Command/EditUserHandler.php
index 6d9d2485d..33261ca60 100644
--- a/framework/core/src/User/Command/EditUserHandler.php
+++ b/framework/core/src/User/Command/EditUserHandler.php
@@ -11,19 +11,15 @@
 
 namespace Flarum\User\Command;
 
-use Exception;
 use Flarum\Foundation\DispatchEventsTrait;
 use Flarum\User\AssertPermissionTrait;
-use Flarum\User\AvatarUploader;
 use Flarum\User\Event\GroupsChanged;
 use Flarum\User\Event\Saving;
 use Flarum\User\User;
 use Flarum\User\UserRepository;
 use Flarum\User\UserValidator;
 use Illuminate\Contracts\Events\Dispatcher;
-use Illuminate\Contracts\Validation\Factory;
 use Illuminate\Validation\ValidationException;
-use Intervention\Image\ImageManager;
 
 class EditUserHandler
 {
@@ -40,36 +36,23 @@ class EditUserHandler
      */
     protected $validator;
 
-    /**
-     * @var AvatarUploader
-     */
-    protected $avatarUploader;
-
-    /**
-     * @var Factory
-     */
-    private $validatorFactory;
-
     /**
      * @param Dispatcher $events
      * @param \Flarum\User\UserRepository $users
      * @param UserValidator $validator
-     * @param AvatarUploader $avatarUploader
-     * @param Factory $validatorFactory
      */
-    public function __construct(Dispatcher $events, UserRepository $users, UserValidator $validator, AvatarUploader $avatarUploader, Factory $validatorFactory)
+    public function __construct(Dispatcher $events, UserRepository $users, UserValidator $validator)
     {
         $this->events = $events;
         $this->users = $users;
         $this->validator = $validator;
-        $this->avatarUploader = $avatarUploader;
-        $this->validatorFactory = $validatorFactory;
     }
 
     /**
      * @param EditUser $command
      * @return User
      * @throws \Flarum\User\Exception\PermissionDeniedException
+     * @throws ValidationException
      */
     public function handle(EditUser $command)
     {
@@ -146,28 +129,6 @@ class EditUserHandler
             });
         }
 
-        if ($avatarUrl = array_get($attributes, 'avatarUrl')) {
-            $this->assertPermission($canEdit);
-
-            $validation = $this->validatorFactory->make(compact('avatarUrl'), ['avatarUrl' => 'url']);
-
-            if ($validation->fails()) {
-                throw new ValidationException($validation);
-            }
-
-            try {
-                $image = (new ImageManager)->make($avatarUrl);
-
-                $this->avatarUploader->upload($user, $image);
-            } catch (Exception $e) {
-                //
-            }
-        } elseif (array_key_exists('avatarUrl', $attributes)) {
-            $this->assertPermission($canEdit);
-
-            $this->avatarUploader->remove($user);
-        }
-
         $this->events->dispatch(
             new Saving($user, $actor, $data)
         );
diff --git a/framework/core/src/User/Command/RegisterUserHandler.php b/framework/core/src/User/Command/RegisterUserHandler.php
index 05029bb70..a8899a55d 100644
--- a/framework/core/src/User/Command/RegisterUserHandler.php
+++ b/framework/core/src/User/Command/RegisterUserHandler.php
@@ -11,18 +11,17 @@
 
 namespace Flarum\User\Command;
 
-use Exception;
 use Flarum\Foundation\DispatchEventsTrait;
 use Flarum\Settings\SettingsRepositoryInterface;
 use Flarum\User\AssertPermissionTrait;
-use Flarum\User\AuthToken;
 use Flarum\User\AvatarUploader;
+use Flarum\User\Event\RegisteringFromProvider;
 use Flarum\User\Event\Saving;
 use Flarum\User\Exception\PermissionDeniedException;
+use Flarum\User\RegistrationToken;
 use Flarum\User\User;
 use Flarum\User\UserValidator;
 use Illuminate\Contracts\Events\Dispatcher;
-use Illuminate\Contracts\Validation\Factory;
 use Illuminate\Validation\ValidationException;
 use Intervention\Image\ImageManager;
 
@@ -46,34 +45,26 @@ class RegisterUserHandler
      */
     protected $avatarUploader;
 
-    /**
-     * @var Factory
-     */
-    private $validatorFactory;
-
     /**
      * @param Dispatcher $events
      * @param SettingsRepositoryInterface $settings
      * @param UserValidator $validator
      * @param AvatarUploader $avatarUploader
-     * @param Factory $validatorFactory
      */
-    public function __construct(Dispatcher $events, SettingsRepositoryInterface $settings, UserValidator $validator, AvatarUploader $avatarUploader, Factory $validatorFactory)
+    public function __construct(Dispatcher $events, SettingsRepositoryInterface $settings, UserValidator $validator, AvatarUploader $avatarUploader)
     {
         $this->events = $events;
         $this->settings = $settings;
         $this->validator = $validator;
         $this->avatarUploader = $avatarUploader;
-        $this->validatorFactory = $validatorFactory;
     }
 
     /**
      * @param RegisterUser $command
+     * @return User
      * @throws PermissionDeniedException if signup is closed and the actor is
      *     not an administrator.
-     * @throws \Flarum\User\Exception\InvalidConfirmationTokenException if an
-     *     email confirmation token is provided but is invalid.
-     * @return User
+     * @throws ValidationException
      */
     public function handle(RegisterUser $command)
     {
@@ -84,32 +75,24 @@ class RegisterUserHandler
             $this->assertAdmin($actor);
         }
 
-        $username = array_get($data, 'attributes.username');
-        $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']);
+            $token = RegistrationToken::validOrFail($data['attributes']['token']);
 
             $password = $password ?: str_random(20);
         }
 
-        $user = User::register($username, $email, $password);
+        $user = User::register(
+            array_get($data, 'attributes.username'),
+            array_get($data, 'attributes.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)) {
-            foreach ($token->payload as $k => $v) {
-                $user->$k = $v;
-            }
-
-            if (isset($token->payload['email'])) {
-                $user->activate();
-            }
+            $this->applyToken($user, $token);
         }
 
         if ($actor->isAdmin() && array_get($data, 'attributes.isEmailConfirmed')) {
@@ -122,30 +105,53 @@ class RegisterUserHandler
 
         $this->validator->assertValid(array_merge($user->getAttributes(), compact('password')));
 
-        if ($avatarUrl = array_get($data, 'attributes.avatarUrl')) {
-            $validation = $this->validatorFactory->make(compact('avatarUrl'), ['avatarUrl' => 'url']);
-
-            if ($validation->fails()) {
-                throw new ValidationException($validation);
-            }
-
-            try {
-                $image = (new ImageManager)->make($avatarUrl);
-
-                $this->avatarUploader->upload($user, $image);
-            } catch (Exception $e) {
-                //
-            }
-        }
-
         $user->save();
 
         if (isset($token)) {
-            $token->delete();
+            $this->fulfillToken($user, $token);
         }
 
         $this->dispatchEventsFor($user, $actor);
 
         return $user;
     }
+
+    private function applyToken(User $user, RegistrationToken $token)
+    {
+        foreach ($token->user_attributes as $k => $v) {
+            if ($k === 'avatar_url') {
+                $this->uploadAvatarFromUrl($user, $v);
+                continue;
+            }
+
+            $user->$k = $v;
+
+            if ($k === 'email') {
+                $user->activate();
+            }
+        }
+
+        $this->events->dispatch(
+            new RegisteringFromProvider($user, $token->provider, $token->payload)
+        );
+    }
+
+    private function uploadAvatarFromUrl(User $user, string $url)
+    {
+        $image = (new ImageManager)->make($url);
+
+        $this->avatarUploader->upload($user, $image);
+    }
+
+    private function fulfillToken(User $user, RegistrationToken $token)
+    {
+        $token->delete();
+
+        if ($token->provider && $token->identifier) {
+            $user->loginProviders()->create([
+                'provider' => $token->provider,
+                'identifier' => $token->identifier
+            ]);
+        }
+    }
 }
diff --git a/framework/core/src/User/Event/RegisteringFromProvider.php b/framework/core/src/User/Event/RegisteringFromProvider.php
new file mode 100644
index 000000000..6b96a3bff
--- /dev/null
+++ b/framework/core/src/User/Event/RegisteringFromProvider.php
@@ -0,0 +1,44 @@
+<?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\User\Event;
+
+use Flarum\User\User;
+
+class RegisteringFromProvider
+{
+    /**
+     * @var User
+     */
+    public $user;
+
+    /**
+     * @var string
+     */
+    public $provider;
+
+    /**
+     * @var array
+     */
+    public $payload;
+
+    /**
+     * @param User $user
+     * @param $provider
+     * @param $payload
+     */
+    public function __construct(User $user, string $provider, array $payload)
+    {
+        $this->user = $user;
+        $this->provider = $provider;
+        $this->payload = $payload;
+    }
+}
diff --git a/framework/core/src/User/LoginProvider.php b/framework/core/src/User/LoginProvider.php
new file mode 100644
index 000000000..e0fb1bf8c
--- /dev/null
+++ b/framework/core/src/User/LoginProvider.php
@@ -0,0 +1,60 @@
+<?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\User;
+
+use Flarum\Database\AbstractModel;
+
+/**
+ * @property int $id
+ * @property int $user_id
+ * @property string $provider
+ * @property string $identifier
+ * @property \Illuminate\Support\Carbon $created_at
+ * @property \Illuminate\Support\Carbon $last_login_at
+ * @property-read User $user
+ */
+class LoginProvider extends AbstractModel
+{
+    protected $dates = ['created_at', 'last_login_at'];
+
+    public $timestamps = true;
+
+    const UPDATED_AT = 'last_login_at';
+
+    protected $fillable = ['provider', 'identifier'];
+
+    /**
+     * Get the user that the login provider belongs to.
+     */
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    /**
+     * Get the user associated with the provider so that they can be logged in.
+     *
+     * @param string $provider
+     * @param string $identifier
+     * @return User|null
+     */
+    public static function logIn(string $provider, string $identifier): ?User
+    {
+        if ($provider = static::where(compact('provider', 'identifier'))->first()) {
+            $provider->touch();
+
+            return $provider->user;
+        }
+
+        return null;
+    }
+}
diff --git a/framework/core/src/User/AuthToken.php b/framework/core/src/User/RegistrationToken.php
similarity index 64%
rename from framework/core/src/User/AuthToken.php
rename to framework/core/src/User/RegistrationToken.php
index 877cd7c24..61c6a5144 100644
--- a/framework/core/src/User/AuthToken.php
+++ b/framework/core/src/User/RegistrationToken.php
@@ -17,16 +17,14 @@ use Flarum\User\Exception\InvalidConfirmationTokenException;
 
 /**
  * @property string $token
+ * @property string $provider
+ * @property string $identifier
+ * @property array $user_attributes
+ * @property array $payload
  * @property \Carbon\Carbon $created_at
- * @property string $payload
  */
-class AuthToken extends AbstractModel
+class RegistrationToken extends AbstractModel
 {
-    /**
-     * {@inheritdoc}
-     */
-    protected $table = 'registration_tokens';
-
     /**
      * The attributes that should be mutated to dates.
      *
@@ -34,6 +32,11 @@ class AuthToken extends AbstractModel
      */
     protected $dates = ['created_at'];
 
+    protected $casts = [
+        'user_attributes' => 'array',
+        'payload' => 'array'
+    ];
+
     /**
      * Use a custom primary key for this model.
      *
@@ -47,44 +50,28 @@ class AuthToken extends AbstractModel
     protected $primaryKey = 'token';
 
     /**
-     * Generate an email token for the specified user.
-     *
-     * @param string $payload
+     * Generate an auth token for the specified user.
      *
+     * @param string $provider
+     * @param string $identifier
+     * @param array $attributes
+     * @param array $payload
      * @return static
      */
-    public static function generate($payload)
+    public static function generate(string $provider, string $identifier, array $attributes, array $payload)
     {
         $token = new static;
 
         $token->token = str_random(40);
+        $token->provider = $provider;
+        $token->identifier = $identifier;
+        $token->user_attributes = $attributes;
         $token->payload = $payload;
         $token->created_at = Carbon::now();
 
         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.
      *
@@ -93,11 +80,11 @@ class AuthToken extends AbstractModel
      *
      * @throws InvalidConfirmationTokenException
      *
-     * @return AuthToken
+     * @return RegistrationToken
      */
     public function scopeValidOrFail($query, string $token)
     {
-        /** @var AuthToken $token */
+        /** @var RegistrationToken $token */
         $token = $query->find($token);
 
         if (! $token || $token->created_at->lessThan(Carbon::now()->subDay())) {
diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php
index b4742e212..cd3b7c035 100644
--- a/framework/core/src/User/User.php
+++ b/framework/core/src/User/User.php
@@ -668,6 +668,14 @@ class User extends AbstractModel
         return $this->hasMany('Flarum\Http\AccessToken');
     }
 
+    /**
+     * Get the user's login providers.
+     */
+    public function loginProviders()
+    {
+        return $this->hasMany(LoginProvider::class);
+    }
+
     /**
      * @param string $ability
      * @param array|mixed $arguments