1
0
mirror of https://github.com/flarum/core.git synced 2025-07-22 01:01:28 +02:00

Refactor Access Tokens (#2651)

- Make session token-based instead of user-based
- Clear current session access tokens on logout
- Introduce increment ID so we can show tokens to moderators in the future without exposing secrets
- Switch to type classes to manage the different token types. New implementation fixes #2075
- Drop ability to customize lifetime per-token
- Add developer access keys that don't expire. These must be created from the database for now
- Add title in preparation for the developer token UI
- Add IP and user agent logging
- Delete all non-remember tokens in migration
This commit is contained in:
Clark Winkelmann
2021-03-04 22:50:38 +01:00
committed by GitHub
parent 9c47ccd1fd
commit 965b713a27
28 changed files with 772 additions and 53 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http;
class DeveloperAccessToken extends AccessToken
{
public static $type = 'developer';
protected static $lifetime = 0;
}

View File

@@ -62,4 +62,25 @@ class HttpServiceProvider extends AbstractServiceProvider
return new SlugManager($this->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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http;
class RememberAccessToken extends AccessToken
{
public static $type = 'session_remember';
protected static $lifetime = 5 * 365 * 24 * 60 * 60; // 5 years
/**
* Just a helper method so we can re-use the lifetime value which is protected.
* @return int
*/
public static function rememberCookieLifeTime(): int
{
return self::$lifetime;
}
}

View File

@@ -29,20 +29,36 @@ class Rememberer
$this->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);
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http;
class SessionAccessToken extends AccessToken
{
public static $type = 'session';
protected static $lifetime = 60 * 60; // 1 hour
}

View File

@@ -15,12 +15,22 @@ class SessionAuthenticator
{
/**
* @param Session $session
* @param int $userId
* @param AccessToken|int $token Token or user ID. Use of User ID is deprecated in beta 16, will be removed in beta 17
*/
public function logIn(Session $session, $userId)
public function logIn(Session $session, $token)
{
// Backwards compatibility with $userId as parameter
// Remove in beta 17
if (! ($token instanceof AccessToken)) {
$userId = $token;
trigger_error('Parameter $userId is deprecated in beta 16, will be replaced by $token in beta 17', E_USER_DEPRECATED);
$token = SessionAccessToken::generate($userId);
}
$session->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();
}