diff --git a/framework/core/migrations/2021_03_02_040000_change_access_tokens_add_type.php b/framework/core/migrations/2021_03_02_040000_change_access_tokens_add_type.php new file mode 100644 index 000000000..f80af1f23 --- /dev/null +++ b/framework/core/migrations/2021_03_02_040000_change_access_tokens_add_type.php @@ -0,0 +1,43 @@ + function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + $table->string('type', 100)->index(); + }); + + // Since all active sessions will stop working on update due to switching from user_id to access_token + // We can do things simple here by terminating all tokens that have the previously default lifetime + $schema->getConnection()->table('access_tokens') + ->where('lifetime_seconds', 3600) + ->delete(); + + // We will then assume that all remaining tokens are remember tokens + // This will include tokens that previously had a custom lifetime + $schema->getConnection()->table('access_tokens') + ->update([ + 'type' => 'session_remember', + ]); + + $schema->table('access_tokens', function (Blueprint $table) { + $table->dropColumn('lifetime_seconds'); + }); + }, + + 'down' => function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + $table->dropColumn('type'); + $table->integer('lifetime_seconds'); + }); + } +]; diff --git a/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php b/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php new file mode 100644 index 000000000..06d1ec248 --- /dev/null +++ b/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php @@ -0,0 +1,35 @@ + function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + // Replace primary key with unique index so we can create a new primary + $table->dropPrimary('token'); + $table->unique('token'); + }); + + // This needs to be done in a second statement because of the order Laravel runs operations in + $schema->table('access_tokens', function (Blueprint $table) { + // Introduce new increment-based ID + $table->increments('id')->first(); + }); + }, + + 'down' => function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + $table->dropColumn('id'); + $table->dropIndex('token'); + $table->primary('token'); + }); + } +]; diff --git a/framework/core/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php b/framework/core/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php new file mode 100644 index 000000000..885237efb --- /dev/null +++ b/framework/core/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php @@ -0,0 +1,21 @@ + ['string', 'length' => 150, 'nullable' => true], + // Accommodates both IPv4 and IPv6 as strings + 'last_ip_address' => ['string', 'length' => 45, 'nullable' => true], + // Technically, there's no limit to a user agent length + // Most are around 150 in length, and the general recommendation seems to be below 200 + // We're going to use the longest string possible to be safe + // There will still be exceptions, we'll just truncate them + 'last_user_agent' => ['string', 'length' => 255, 'nullable' => true], +]); diff --git a/framework/core/src/Api/Controller/CreateTokenController.php b/framework/core/src/Api/Controller/CreateTokenController.php index 0f3c5710c..8490cb90d 100644 --- a/framework/core/src/Api/Controller/CreateTokenController.php +++ b/framework/core/src/Api/Controller/CreateTokenController.php @@ -9,7 +9,8 @@ namespace Flarum\Api\Controller; -use Flarum\Http\AccessToken; +use Flarum\Http\RememberAccessToken; +use Flarum\Http\SessionAccessToken; use Flarum\User\Exception\NotAuthenticatedException; use Flarum\User\UserRepository; use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher; @@ -66,8 +67,20 @@ class CreateTokenController implements RequestHandlerInterface throw new NotAuthenticatedException; } - $token = AccessToken::generate($user->id, $lifetime); - $token->save(); + // Use of lifetime attribute is deprecated in beta 16, removed in beta 17 + // For backward compatibility with custom integrations, longer lifetimes will be interpreted as remember tokens + if ($lifetime > 3600 || Arr::get($body, 'remember')) { + if ($lifetime > 3600) { + trigger_error('Use of parameter lifetime is deprecated in beta 16, will be removed in beta 17. Use remember parameter to start a remember session', E_USER_DEPRECATED); + } + + $token = RememberAccessToken::generate($user->id); + } else { + $token = SessionAccessToken::generate($user->id); + } + + // We do a first update here to log the IP/agent of the token creator, even if the token is never used afterwards + $token->touch($request); return new JsonResponse([ 'token' => $token->token, diff --git a/framework/core/src/Forum/Auth/ResponseFactory.php b/framework/core/src/Forum/Auth/ResponseFactory.php index af2dc9245..ba1839440 100644 --- a/framework/core/src/Forum/Auth/ResponseFactory.php +++ b/framework/core/src/Forum/Auth/ResponseFactory.php @@ -9,6 +9,7 @@ namespace Flarum\Forum\Auth; +use Flarum\Http\RememberAccessToken; use Flarum\Http\Rememberer; use Flarum\User\LoginProvider; use Flarum\User\RegistrationToken; @@ -75,6 +76,8 @@ class ResponseFactory { $response = $this->makeResponse(['loggedIn' => true]); - return $this->rememberer->rememberUser($response, $user->id); + $token = RememberAccessToken::generate($user->id); + + return $this->rememberer->remember($response, $token); } } diff --git a/framework/core/src/Forum/Controller/ConfirmEmailController.php b/framework/core/src/Forum/Controller/ConfirmEmailController.php index f8bfe9083..f57572ae2 100644 --- a/framework/core/src/Forum/Controller/ConfirmEmailController.php +++ b/framework/core/src/Forum/Controller/ConfirmEmailController.php @@ -9,6 +9,7 @@ namespace Flarum\Forum\Controller; +use Flarum\Http\SessionAccessToken; use Flarum\Http\SessionAuthenticator; use Flarum\Http\UrlGenerator; use Flarum\User\Command\ConfirmEmail; @@ -61,7 +62,8 @@ class ConfirmEmailController implements RequestHandlerInterface ); $session = $request->getAttribute('session'); - $this->authenticator->logIn($session, $user->id); + $token = SessionAccessToken::generate($user->id); + $this->authenticator->logIn($session, $token); return new RedirectResponse($this->url->to('forum')->base()); } diff --git a/framework/core/src/Forum/Controller/LogInController.php b/framework/core/src/Forum/Controller/LogInController.php index 587e01693..ba0378981 100644 --- a/framework/core/src/Forum/Controller/LogInController.php +++ b/framework/core/src/Forum/Controller/LogInController.php @@ -12,6 +12,7 @@ namespace Flarum\Forum\Controller; use Flarum\Api\Client; use Flarum\Api\Controller\CreateTokenController; use Flarum\Http\AccessToken; +use Flarum\Http\RememberAccessToken; use Flarum\Http\Rememberer; use Flarum\Http\SessionAuthenticator; use Flarum\User\Event\LoggedIn; @@ -71,21 +72,21 @@ class LogInController implements RequestHandlerInterface { $actor = $request->getAttribute('actor'); $body = $request->getParsedBody(); - $params = Arr::only($body, ['identification', 'password']); + $params = Arr::only($body, ['identification', 'password', 'remember']); $response = $this->apiClient->send(CreateTokenController::class, $actor, [], $params); if ($response->getStatusCode() === 200) { $data = json_decode($response->getBody()); - $session = $request->getAttribute('session'); - $this->authenticator->logIn($session, $data->userId); + $token = AccessToken::findValid($data->token); - $token = AccessToken::find($data->token); + $session = $request->getAttribute('session'); + $this->authenticator->logIn($session, $token); $this->events->dispatch(new LoggedIn($this->users->findOrFail($data->userId), $token)); - if (Arr::get($body, 'remember')) { + if ($token instanceof RememberAccessToken) { $response = $this->rememberer->remember($response, $token); } } diff --git a/framework/core/src/Forum/Controller/RegisterController.php b/framework/core/src/Forum/Controller/RegisterController.php index db0ef3bc7..3e6684341 100644 --- a/framework/core/src/Forum/Controller/RegisterController.php +++ b/framework/core/src/Forum/Controller/RegisterController.php @@ -11,6 +11,7 @@ namespace Flarum\Forum\Controller; use Flarum\Api\Client; use Flarum\Api\Controller\CreateUserController; +use Flarum\Http\RememberAccessToken; use Flarum\Http\Rememberer; use Flarum\Http\SessionAuthenticator; use Psr\Http\Message\ResponseInterface; @@ -62,10 +63,12 @@ class RegisterController implements RequestHandlerInterface if (isset($body->data)) { $userId = $body->data->id; - $session = $request->getAttribute('session'); - $this->authenticator->logIn($session, $userId); + $token = RememberAccessToken::generate($userId); - $response = $this->rememberer->rememberUser($response, $userId); + $session = $request->getAttribute('session'); + $this->authenticator->logIn($session, $token); + + $response = $this->rememberer->remember($response, $token); } return $response; diff --git a/framework/core/src/Forum/Controller/SavePasswordController.php b/framework/core/src/Forum/Controller/SavePasswordController.php index 4e6f5ea1e..2148ff34b 100644 --- a/framework/core/src/Forum/Controller/SavePasswordController.php +++ b/framework/core/src/Forum/Controller/SavePasswordController.php @@ -10,6 +10,7 @@ namespace Flarum\Forum\Controller; use Flarum\Foundation\DispatchEventsTrait; +use Flarum\Http\SessionAccessToken; use Flarum\Http\SessionAuthenticator; use Flarum\Http\UrlGenerator; use Flarum\User\PasswordToken; @@ -99,7 +100,8 @@ class SavePasswordController implements RequestHandlerInterface $token->delete(); $session = $request->getAttribute('session'); - $this->authenticator->logIn($session, $token->user->id); + $accessToken = SessionAccessToken::generate($token->user->id); + $this->authenticator->logIn($session, $accessToken); return new RedirectResponse($this->url->to('forum')->base()); } diff --git a/framework/core/src/Http/AccessToken.php b/framework/core/src/Http/AccessToken.php index 0c0e97cdf..c641c6577 100644 --- a/framework/core/src/Http/AccessToken.php +++ b/framework/core/src/Http/AccessToken.php @@ -12,53 +12,112 @@ namespace Flarum\Http; use Carbon\Carbon; use Flarum\Database\AbstractModel; use Flarum\User\User; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Psr\Http\Message\ServerRequestInterface; /** + * @property int $id * @property string $token * @property int $user_id * @property Carbon $created_at * @property Carbon|null $last_activity_at - * @property int $lifetime_seconds + * @property string $type + * @property string $title + * @property string $last_ip_address + * @property string $last_user_agent * @property \Flarum\User\User|null $user */ class AccessToken extends AbstractModel { + protected $table = 'access_tokens'; + + protected $dates = [ + 'created_at', + 'last_activity_at', + ]; + /** - * Use a custom primary key for this model. + * A map of access token types, as specified in the `type` column, to their classes. * - * @var bool + * @var array */ - public $incrementing = false; + protected static $models = []; - protected $primaryKey = 'token'; + /** + * The type of token this is, to be stored in the access tokens table. + * + * Should be overwritten by subclasses with the value that is + * to be stored in the database, which will then be used for + * mapping the hydrated model instance to the proper subtype. + * + * @var string + */ + public static $type = ''; - protected $dates = ['last_activity_at']; + /** + * How long this access token should be valid from the time of last activity. + * This value will be used in the validity and expiration checks. + * @var int Lifetime in seconds. Zero means it will never expire. + */ + protected static $lifetime = 0; /** * Generate an access token for the specified user. * * @param int $userId - * @param int $lifetime + * @param int $lifetime Does nothing. Deprecated in beta 16, removed in beta 17 * @return static */ - public static function generate($userId, $lifetime = 3600) + public static function generate($userId, $lifetime = null) { - $token = new static; + if (! is_null($lifetime)) { + trigger_error('Parameter $lifetime is deprecated in beta 16, will be removed in beta 17', E_USER_DEPRECATED); + } + + if (static::class === self::class) { + trigger_error('Use of AccessToken::generate() is deprecated in beta 16. Use SessionAccessToken::generate() or RememberAccessToken::generate()', E_USER_DEPRECATED); + + $token = new SessionAccessToken; + $token->type = 'session'; + } else { + $token = new static; + $token->type = static::$type; + } $token->token = Str::random(40); $token->user_id = $userId; $token->created_at = Carbon::now(); $token->last_activity_at = Carbon::now(); - $token->lifetime_seconds = $lifetime; + $token->save(); return $token; } - public function touch() + /** + * Update the time of last usage of a token. + * If a request object is provided, the IP address and User Agent will also be logged. + * @param ServerRequestInterface|null $request + * @return bool + */ + public function touch(ServerRequestInterface $request = null) { $this->last_activity_at = Carbon::now(); + if ($request) { + $this->last_ip_address = $request->getAttribute('ipAddress'); + // We truncate user agent so it fits in the database column + // The length is hard-coded as the column length + // It seems like MySQL or Laravel already truncates values, but we'll play safe and do it ourselves + $this->last_user_agent = substr(Arr::get($request->getServerParams(), 'HTTP_USER_AGENT'), 0, 255); + } else { + // If no request is provided, we set the values back to null + // That way the values always match with the date logged in last_activity + $this->last_ip_address = null; + $this->last_user_agent = null; + } + return $this->save(); } @@ -71,4 +130,133 @@ class AccessToken extends AbstractModel { return $this->belongsTo(User::class); } + + /** + * Filters which tokens are valid at the given date for this particular token type. + * Uses the static::$lifetime value by default, can be overridden by children classes. + * @param Builder $query + * @param Carbon $date + */ + protected static function scopeValid(Builder $query, Carbon $date) + { + if (static::$lifetime > 0) { + $query->where('last_activity_at', '>', $date->clone()->subSeconds(static::$lifetime)); + } + } + + /** + * Filters which tokens are expired at the given date and ready for garbage collection. + * Uses the static::$lifetime value by default, can be overridden by children classes. + * @param Builder $query + * @param Carbon $date + */ + protected static function scopeExpired(Builder $query, Carbon $date) + { + if (static::$lifetime > 0) { + $query->where('last_activity_at', '<', $date->clone()->subSeconds(static::$lifetime)); + } else { + $query->whereRaw('FALSE'); + } + } + + /** + * Shortcut to find a valid token. + * @param string $token Token as sent by the user. We allow non-string values like null so we can directly feed any value from a request. + * @return AccessToken|null + */ + public static function findValid($token): ?AccessToken + { + return static::query()->whereValid()->where('token', $token)->first(); + } + + /** + * This query scope is intended to be used on the base AccessToken object to query for valid tokens of any type. + * @param Builder $query + * @param Carbon|null $date + */ + public function scopeWhereValid(Builder $query, Carbon $date = null) + { + if (is_null($date)) { + $date = Carbon::now(); + } + + $query->where(function (Builder $query) use ($date) { + foreach ($this->getModels() as $model) { + $query->orWhere(function (Builder $query) use ($model, $date) { + $query->where('type', $model::$type); + $model::scopeValid($query, $date); + }); + } + }); + } + + /** + * This query scope is intended to be used on the base AccessToken object to query for expired tokens of any type. + * @param Builder $query + * @param Carbon|null $date + */ + public function scopeWhereExpired(Builder $query, Carbon $date = null) + { + if (is_null($date)) { + $date = Carbon::now(); + } + + $query->where(function (Builder $query) use ($date) { + foreach ($this->getModels() as $model) { + $query->orWhere(function (Builder $query) use ($model, $date) { + $query->where('type', $model::$type); + $model::scopeExpired($query, $date); + }); + } + }); + } + + /** + * Create a new model instance according to the access token type. + * + * @param array $attributes + * @param string|null $connection + * @return static|object + */ + public function newFromBuilder($attributes = [], $connection = null) + { + $attributes = (array) $attributes; + + if (! empty($attributes['type']) + && isset(static::$models[$attributes['type']]) + && class_exists($class = static::$models[$attributes['type']]) + ) { + /** @var AccessToken $instance */ + $instance = new $class; + $instance->exists = true; + $instance->setRawAttributes($attributes, true); + $instance->setConnection($connection ?: $this->connection); + + return $instance; + } + + return parent::newFromBuilder($attributes, $connection); + } + + /** + * Get the type-to-model map. + * + * @return array + */ + public static function getModels() + { + return static::$models; + } + + /** + * Set the model for the given access token type. + * + * @param string $type The access token type. + * @param string $model The class name of the model for that type. + * @return void + */ + public static function setModel(string $type, string $model) + { + static::$models[$type] = $model; + } } diff --git a/framework/core/src/Http/DeveloperAccessToken.php b/framework/core/src/Http/DeveloperAccessToken.php new file mode 100644 index 000000000..751a836a8 --- /dev/null +++ b/framework/core/src/Http/DeveloperAccessToken.php @@ -0,0 +1,17 @@ +app->make('flarum.http.selectedSlugDrivers')); }); } + + /** + * {@inheritdoc} + */ + public function boot() + { + $this->setAccessTokenTypes(); + } + + protected function setAccessTokenTypes() + { + $models = [ + DeveloperAccessToken::class, + RememberAccessToken::class, + SessionAccessToken::class + ]; + + foreach ($models as $model) { + AccessToken::setModel($model::$type, $model); + } + } } diff --git a/framework/core/src/Http/Middleware/AuthenticateWithHeader.php b/framework/core/src/Http/Middleware/AuthenticateWithHeader.php index 90e8bee40..225ea92b8 100644 --- a/framework/core/src/Http/Middleware/AuthenticateWithHeader.php +++ b/framework/core/src/Http/Middleware/AuthenticateWithHeader.php @@ -39,8 +39,8 @@ class AuthenticateWithHeader implements Middleware $request = $request->withAttribute('apiKey', $key); $request = $request->withAttribute('bypassThrottling', true); - } elseif ($token = AccessToken::find($id)) { - $token->touch(); + } elseif ($token = AccessToken::findValid($id)) { + $token->touch($request); $actor = $token->user; } diff --git a/framework/core/src/Http/Middleware/AuthenticateWithSession.php b/framework/core/src/Http/Middleware/AuthenticateWithSession.php index ce30f9997..98b7a9b04 100644 --- a/framework/core/src/Http/Middleware/AuthenticateWithSession.php +++ b/framework/core/src/Http/Middleware/AuthenticateWithSession.php @@ -9,8 +9,8 @@ namespace Flarum\Http\Middleware; +use Flarum\Http\AccessToken; use Flarum\User\Guest; -use Flarum\User\User; use Illuminate\Contracts\Session\Session; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -23,7 +23,7 @@ class AuthenticateWithSession implements Middleware { $session = $request->getAttribute('session'); - $actor = $this->getActor($session); + $actor = $this->getActor($session, $request); $actor->setSession($session); @@ -32,14 +32,25 @@ class AuthenticateWithSession implements Middleware return $handler->handle($request); } - private function getActor(Session $session) + private function getActor(Session $session, Request $request) { - $actor = User::find($session->get('user_id')) ?: new Guest; + if ($session->has('access_token')) { + $token = AccessToken::findValid($session->get('access_token')); - if ($actor->exists) { - $actor->updateLastSeen()->save(); + if ($token) { + $actor = $token->user; + $actor->updateLastSeen()->save(); + + $token->touch($request); + + return $actor; + } + + // If this session used to have a token which is no longer valid we properly refresh the session + $session->invalidate(); + $session->regenerateToken(); } - return $actor; + return new Guest; } } diff --git a/framework/core/src/Http/Middleware/CollectGarbage.php b/framework/core/src/Http/Middleware/CollectGarbage.php index c84f61f37..c20330891 100644 --- a/framework/core/src/Http/Middleware/CollectGarbage.php +++ b/framework/core/src/Http/Middleware/CollectGarbage.php @@ -56,7 +56,7 @@ class CollectGarbage implements Middleware $time = Carbon::now()->timestamp; - AccessToken::whereRaw('last_activity_at <= ? - lifetime_seconds', [$time])->delete(); + AccessToken::whereExpired()->delete(); $earliestToKeep = date('Y-m-d H:i:s', $time - 24 * 60 * 60); diff --git a/framework/core/src/Http/Middleware/RememberFromCookie.php b/framework/core/src/Http/Middleware/RememberFromCookie.php index e9293f097..07ac1b227 100644 --- a/framework/core/src/Http/Middleware/RememberFromCookie.php +++ b/framework/core/src/Http/Middleware/RememberFromCookie.php @@ -11,6 +11,7 @@ namespace Flarum\Http\Middleware; use Flarum\Http\AccessToken; use Flarum\Http\CookieFactory; +use Flarum\Http\RememberAccessToken; use Illuminate\Support\Arr; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -37,14 +38,14 @@ class RememberFromCookie implements Middleware $id = Arr::get($request->getCookieParams(), $this->cookie->getName('remember')); if ($id) { - $token = AccessToken::find($id); + $token = AccessToken::findValid($id); - if ($token) { + if ($token && $token instanceof RememberAccessToken) { $token->touch(); /** @var \Illuminate\Contracts\Session\Session $session */ $session = $request->getAttribute('session'); - $session->put('user_id', $token->user_id); + $session->put('access_token', $token->token); } } diff --git a/framework/core/src/Http/RememberAccessToken.php b/framework/core/src/Http/RememberAccessToken.php new file mode 100644 index 000000000..9a091fb04 --- /dev/null +++ b/framework/core/src/Http/RememberAccessToken.php @@ -0,0 +1,26 @@ +cookie = $cookie; } + /** + * Sets the remember cookie on a response. + * @param ResponseInterface $response + * @param RememberAccessToken $token The remember token to set on the response. Use of non-remember token is deprecated in beta 16, removed eta 17. + * @return ResponseInterface + */ public function remember(ResponseInterface $response, AccessToken $token) { - $token->lifetime_seconds = 5 * 365 * 24 * 60 * 60; // 5 years - $token->save(); + if (! ($token instanceof RememberAccessToken)) { + trigger_error('Parameter $token of type AccessToken is deprecated in beta 16, must be instance of RememberAccessToken in beta 17', E_USER_DEPRECATED); + + $token->type = 'session_remember'; + $token->save(); + } return FigResponseCookies::set( $response, - $this->cookie->make(self::COOKIE_NAME, $token->token, $token->lifetime_seconds) + $this->cookie->make(self::COOKIE_NAME, $token->token, RememberAccessToken::rememberCookieLifeTime()) ); } + /** + * @param ResponseInterface $response + * @param $userId + * @return ResponseInterface + * @deprecated beta 16, removed beta 17. Use remember() with a token + */ public function rememberUser(ResponseInterface $response, $userId) { - $token = AccessToken::generate($userId); + $token = RememberAccessToken::generate($userId); return $this->remember($response, $token); } diff --git a/framework/core/src/Http/SessionAccessToken.php b/framework/core/src/Http/SessionAccessToken.php new file mode 100644 index 000000000..4cf162efb --- /dev/null +++ b/framework/core/src/Http/SessionAccessToken.php @@ -0,0 +1,17 @@ +regenerate(true); - $session->put('user_id', $userId); + $session->put('access_token', $token->token); } /** @@ -28,6 +38,12 @@ class SessionAuthenticator */ public function logOut(Session $session) { + $token = AccessToken::findValid($session->get('access_token')); + + if ($token) { + $token->delete(); + } + $session->invalidate(); $session->regenerateToken(); } diff --git a/framework/core/src/Install/Controller/InstallController.php b/framework/core/src/Install/Controller/InstallController.php index f56e0e75d..a226afb35 100644 --- a/framework/core/src/Install/Controller/InstallController.php +++ b/framework/core/src/Install/Controller/InstallController.php @@ -9,6 +9,8 @@ namespace Flarum\Install\Controller; +use Flarum\Http\RememberAccessToken; +use Flarum\Http\Rememberer; use Flarum\Http\SessionAuthenticator; use Flarum\Install\AdminUser; use Flarum\Install\BaseUrl; @@ -35,15 +37,22 @@ class InstallController implements RequestHandlerInterface */ protected $authenticator; + /** + * @var Rememberer + */ + protected $rememberer; + /** * InstallController constructor. * @param Installation $installation * @param SessionAuthenticator $authenticator + * @param Rememberer $rememberer */ - public function __construct(Installation $installation, SessionAuthenticator $authenticator) + public function __construct(Installation $installation, SessionAuthenticator $authenticator, Rememberer $rememberer) { $this->installation = $installation; $this->authenticator = $authenticator; + $this->rememberer = $rememberer; } /** @@ -55,11 +64,15 @@ class InstallController implements RequestHandlerInterface $input = $request->getParsedBody(); $baseUrl = BaseUrl::fromUri($request->getUri()); + // An access token we will use to auto-login the admin at the end of installation + $accessToken = Str::random(40); + try { $pipeline = $this->installation ->baseUrl($baseUrl) ->databaseConfig($this->makeDatabaseConfig($input)) ->adminUser($this->makeAdminUser($input)) + ->accessToken($accessToken) ->settings([ 'forum_title' => Arr::get($input, 'forumTitle'), 'mail_from' => $baseUrl->toEmail('noreply'), @@ -77,9 +90,13 @@ class InstallController implements RequestHandlerInterface } $session = $request->getAttribute('session'); - $this->authenticator->logIn($session, 1); + // Because the Eloquent models cannot be used yet, we create a temporary in-memory object + // that won't interact with the database but can be passed to the authenticator and rememberer + $token = new RememberAccessToken(); + $token->token = $accessToken; + $this->authenticator->logIn($session, $token); - return new Response\EmptyResponse; + return $this->rememberer->remember(new Response\EmptyResponse, $token); } private function makeDatabaseConfig(array $input): DatabaseConfig diff --git a/framework/core/src/Install/Installation.php b/framework/core/src/Install/Installation.php index 8ddd58250..0ee3e5248 100644 --- a/framework/core/src/Install/Installation.php +++ b/framework/core/src/Install/Installation.php @@ -29,6 +29,8 @@ class Installation /** @var AdminUser */ private $adminUser; + private $accessToken; + // A few instance variables to persist objects between steps. // Could also be local variables in build(), but this way // access in closures is easier. :) @@ -83,6 +85,13 @@ class Installation return $this; } + public function accessToken(string $token) + { + $this->accessToken = $token; + + return $this; + } + public function prerequisites(): Prerequisite\PrerequisiteInterface { return new Prerequisite\Composite( @@ -135,7 +144,7 @@ class Installation }); $pipeline->pipe(function () { - return new Steps\CreateAdminUser($this->db, $this->adminUser); + return new Steps\CreateAdminUser($this->db, $this->adminUser, $this->accessToken); }); $pipeline->pipe(function () { diff --git a/framework/core/src/Install/Steps/CreateAdminUser.php b/framework/core/src/Install/Steps/CreateAdminUser.php index aede9a74a..f80d30a8c 100644 --- a/framework/core/src/Install/Steps/CreateAdminUser.php +++ b/framework/core/src/Install/Steps/CreateAdminUser.php @@ -9,6 +9,7 @@ namespace Flarum\Install\Steps; +use Carbon\Carbon; use Flarum\Group\Group; use Flarum\Install\AdminUser; use Flarum\Install\Step; @@ -26,10 +27,16 @@ class CreateAdminUser implements Step */ private $admin; - public function __construct(ConnectionInterface $database, AdminUser $admin) + /** + * @var string|null + */ + private $accessToken; + + public function __construct(ConnectionInterface $database, AdminUser $admin, string $accessToken = null) { $this->database = $database; $this->admin = $admin; + $this->accessToken = $accessToken; } public function getMessage() @@ -47,5 +54,15 @@ class CreateAdminUser implements Step 'user_id' => $uid, 'group_id' => Group::ADMINISTRATOR_ID, ]); + + if ($this->accessToken) { + $this->database->table('access_tokens')->insert([ + 'type' => 'session_remember', + 'token' => $this->accessToken, + 'user_id' => $uid, + 'created_at' => Carbon::now(), + 'last_activity_at' => Carbon::now(), + ]); + } } } diff --git a/framework/core/tests/integration/BuildsHttpRequests.php b/framework/core/tests/integration/BuildsHttpRequests.php index 410d67b56..26fd34743 100644 --- a/framework/core/tests/integration/BuildsHttpRequests.php +++ b/framework/core/tests/integration/BuildsHttpRequests.php @@ -45,7 +45,7 @@ trait BuildsHttpRequests 'user_id' => $userId, 'created_at' => Carbon::now()->toDateTimeString(), 'last_activity_at' => Carbon::now()->toDateTimeString(), - 'lifetime_seconds' => 3600 + 'type' => 'session' ]); return $req->withAddedHeader('Authorization', "Token {$token}"); diff --git a/framework/core/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php b/framework/core/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php new file mode 100644 index 000000000..3782566cc --- /dev/null +++ b/framework/core/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php @@ -0,0 +1,143 @@ +prepareDatabase([ + 'access_tokens' => [ + ['token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'], + ['token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'], + ], + ]); + } + + /** + * @test + */ + public function tokens_expire() + { + $this->populateDatabase(); + + // 30 minutes after last activity + $this->assertEquals([], AccessToken::whereExpired(Carbon::parse('2021-01-01 02:30:00'))->pluck('token')->all()); + + // 1h30 after last activity + $this->assertEquals(['a'], AccessToken::whereExpired(Carbon::parse('2021-01-01 03:30:00'))->pluck('token')->all()); + + // 6 years after last activity + $this->assertEquals(['a', 'b'], AccessToken::whereExpired(Carbon::parse('2027-01-01 01:00:00'))->pluck('token')->sort()->values()->all()); + } + + /** + * @test + */ + public function tokens_valid() + { + $this->populateDatabase(); + + // 30 minutes after last activity + $this->assertEquals(['a', 'b', 'c'], AccessToken::whereValid(Carbon::parse('2021-01-01 02:30:00'))->pluck('token')->sort()->values()->all()); + + // 1h30 after last activity + $this->assertEquals(['b', 'c'], AccessToken::whereValid(Carbon::parse('2021-01-01 03:30:00'))->pluck('token')->sort()->values()->all()); + + // 6 years after last activity + $this->assertEquals(['c'], AccessToken::whereValid(Carbon::parse('2027-01-01 01:00:00'))->pluck('token')->all()); + } + + /** + * @test + */ + public function touch_updates_lifetime() + { + $this->populateDatabase(); + + // 45 minutes after last activity + Carbon::setTestNow('2021-01-01 02:45:00'); + $token = AccessToken::findValid('a'); + $this->assertNotNull($token); + $token->touch(); + Carbon::setTestNow(); + + // 1h30 after original last activity, 45 minutes after touch + $this->assertTrue(AccessToken::whereValid(Carbon::parse('2021-01-01 03:30:00'))->whereToken('a')->exists()); + } + + /** + * @test + */ + public function touch_without_request() + { + $this->populateDatabase(); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $token->touch(); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $this->assertNull($token->last_ip_address); + $this->assertNull($token->last_user_agent); + } + + /** + * @test + */ + public function touch_with_request() + { + $this->populateDatabase(); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $token->touch((new ServerRequest([ + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36', + ]))->withAttribute('ipAddress', '8.8.8.8')); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $this->assertEquals('8.8.8.8', $token->last_ip_address); + $this->assertEquals('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36', $token->last_user_agent); + } + + /** + * @test + */ + public function long_user_agent_id_truncated() + { + $this->populateDatabase(); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $token->touch(new ServerRequest([ + 'HTTP_USER_AGENT' => str_repeat('a', 500), + ])); + + /** @var AccessToken $token */ + $token = AccessToken::whereToken('a')->firstOrFail(); + $this->assertEquals(255, strlen($token->last_user_agent)); + } +} diff --git a/framework/core/tests/integration/api/access_tokens/RemembererTest.php b/framework/core/tests/integration/api/access_tokens/RemembererTest.php new file mode 100644 index 000000000..56056a170 --- /dev/null +++ b/framework/core/tests/integration/api/access_tokens/RemembererTest.php @@ -0,0 +1,97 @@ +prepareDatabase([ + 'access_tokens' => [ + ['token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'], + ['token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'], + ], + ]); + } + + /** + * @test + */ + public function non_remember_tokens_cannot_be_used() + { + $this->populateDatabase(); + + Carbon::setTestNow('2021-01-01 02:30:00'); + + $response = $this->send( + $this->request('GET', '/api')->withCookieParams([ + 'flarum_remember' => 'a', + ]) + ); + + Carbon::setTestNow(); + + $data = json_decode($response->getBody(), true); + $this->assertFalse($data['data']['attributes']['canViewUserList']); + } + + /** + * @test + */ + public function expired_tokens_cannot_be_used() + { + $this->populateDatabase(); + + Carbon::setTestNow('2027-01-01 02:30:00'); + + $response = $this->send( + $this->request('GET', '/api')->withCookieParams([ + 'flarum_remember' => 'b', + ]) + ); + + Carbon::setTestNow(); + + $data = json_decode($response->getBody(), true); + $this->assertFalse($data['data']['attributes']['canViewUserList']); + } + + /** + * @test + */ + public function valid_tokens_can_be_used() + { + $this->populateDatabase(); + + Carbon::setTestNow('2021-01-01 02:30:00'); + + $response = $this->send( + $this->request('GET', '/api')->withCookieParams([ + 'flarum_remember' => 'b', + ]) + ); + + Carbon::setTestNow(); + + $data = json_decode($response->getBody(), true); + $this->assertTrue($data['data']['attributes']['canViewUserList']); + } +} diff --git a/framework/core/tests/integration/api/authentication/WithTokenTest.php b/framework/core/tests/integration/api/authentication/WithTokenTest.php index 79d7b0014..2b014ae69 100644 --- a/framework/core/tests/integration/api/authentication/WithTokenTest.php +++ b/framework/core/tests/integration/api/authentication/WithTokenTest.php @@ -60,7 +60,7 @@ class WithTokenTest extends TestCase // ...and an access token belonging to this user. $token = $data['token']; - $this->assertEquals(2, AccessToken::findOrFail($token)->user_id); + $this->assertEquals(2, AccessToken::whereToken($token)->firstOrFail()->user_id); } /** diff --git a/framework/core/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php b/framework/core/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php index 152bcf4a6..6e00da18f 100644 --- a/framework/core/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php +++ b/framework/core/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php @@ -193,7 +193,7 @@ class RequireCsrfTokenTest extends TestCase public function access_token_does_not_need_csrf_token() { $this->database()->table('access_tokens')->insert( - ['token' => 'myaccesstoken', 'user_id' => 1] + ['token' => 'myaccesstoken', 'user_id' => 1, 'type' => 'developer'] ); $response = $this->send(