mirror of
https://github.com/flarum/core.git
synced 2025-01-16 21:58:20 +01: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:
parent
8eef7230e9
commit
08ba2599d7
@ -0,0 +1,43 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => 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');
|
||||
});
|
||||
}
|
||||
];
|
35
migrations/2021_03_02_040500_change_access_tokens_add_id.php
Normal file
35
migrations/2021_03_02_040500_change_access_tokens_add_id.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => 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');
|
||||
});
|
||||
}
|
||||
];
|
@ -0,0 +1,21 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
use Flarum\Database\Migration;
|
||||
|
||||
return Migration::addColumns('access_tokens', [
|
||||
'title' => ['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],
|
||||
]);
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
17
src/Http/DeveloperAccessToken.php
Normal file
17
src/Http/DeveloperAccessToken.php
Normal 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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
26
src/Http/RememberAccessToken.php
Normal file
26
src/Http/RememberAccessToken.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
17
src/Http/SessionAccessToken.php
Normal file
17
src/Http/SessionAccessToken.php
Normal 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
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 () {
|
||||
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}");
|
||||
|
143
tests/integration/api/access_tokens/AccessTokenLifecycleTest.php
Normal file
143
tests/integration/api/access_tokens/AccessTokenLifecycleTest.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?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\Tests\integration\api\access_tokens;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Http\AccessToken;
|
||||
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Tests\integration\TestCase;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
|
||||
class AccessTokenLifecycleTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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));
|
||||
}
|
||||
}
|
97
tests/integration/api/access_tokens/RemembererTest.php
Normal file
97
tests/integration/api/access_tokens/RemembererTest.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?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\Tests\integration\api\access_tokens;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Tests\integration\TestCase;
|
||||
|
||||
class RemembererTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user