1
0
mirror of https://github.com/flarum/core.git synced 2025-07-26 11:10:41 +02:00

Overhaul sessions, tokens, and authentication

- Use cookies + CSRF token for API authentication in the default client. This mitigates potential XSS attacks by making the token unavailable to JavaScript. The Authorization header is still supported, but not used by default.
- Make sensitive/destructive actions (editing a user, permanently deleting anything, visiting the admin CP) require the user to re-enter their password if they haven't entered it in the last 30 minutes.
- Refactor and clean up the authentication middleware.
- Add an `onhide` hook to the Modal component. (+1 squashed commit)
This commit is contained in:
Toby Zerner
2015-11-05 16:17:00 +10:30
parent 22331306c6
commit 32e9c0587c
69 changed files with 1076 additions and 509 deletions

View File

@@ -339,9 +339,11 @@ class ClientView implements Renderable
*/
protected function getSession()
{
$session = $this->request->getAttribute('session');
return [
'userId' => $this->actor->id,
'token' => array_get($this->request->getCookieParams(), 'flarum_remember'),
'csrfToken' => $session->csrf_token
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Exception;
use Exception;
class MethodNotAllowedException extends Exception
{
public function __construct($message = null, $code = 405, Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,18 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Exception;
use Exception;
class TokenMismatchException extends Exception
{
}

View File

@@ -10,82 +10,43 @@
namespace Flarum\Http\Middleware;
use Flarum\Api\AccessToken;
use Flarum\Core\Guest;
use Flarum\Locale\LocaleManager;
use Flarum\Http\Exception\TokenMismatchException;
use Flarum\Http\Session;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class AuthenticateWithCookie implements MiddlewareInterface
{
/**
* @var LocaleManager
*/
protected $locales;
/**
* @param LocaleManager $locales
*/
public function __construct(LocaleManager $locales)
{
$this->locales = $locales;
}
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$request = $this->logIn($request);
$id = array_get($request->getCookieParams(), 'flarum_session');
if ($id) {
$session = Session::find($id);
$request = $request->withAttribute('session', $session);
if (! $this->isReading($request) && ! $this->tokensMatch($request)) {
throw new TokenMismatchException;
}
}
return $out ? $out($request, $response) : $response;
}
/**
* Set the application's actor instance according to the request token.
*
* @param Request $request
* @return Request
*/
protected function logIn(Request $request)
private function isReading(Request $request)
{
$actor = new Guest;
if ($token = $this->getToken($request)) {
if (! $token->isValid()) {
// TODO: https://github.com/flarum/core/issues/253
} elseif ($token->user) {
$actor = $token->user;
$actor->updateLastSeen()->save();
}
}
if ($actor->exists) {
$locale = $actor->getPreference('locale');
} else {
$locale = array_get($request->getCookieParams(), 'locale');
}
if ($locale && $this->locales->hasLocale($locale)) {
$this->locales->setLocale($locale);
}
return $request->withAttribute('actor', $actor);
return in_array($request->getMethod(), ['HEAD', 'GET', 'OPTIONS']);
}
/**
* Get the access token referred to by the request cookie.
*
* @param Request $request
* @return AccessToken|null
*/
protected function getToken(Request $request)
private function tokensMatch(Request $request)
{
$token = array_get($request->getCookieParams(), 'flarum_remember');
$input = $request->getHeaderLine('X-CSRF-Token') ?: array_get($request->getParsedBody(), 'token');
if ($token) {
return AccessToken::find($token);
}
return $request->getAttribute('session')->csrf_token === $input;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Middleware;
use Flarum\Api\ApiKey;
use Flarum\Core\User;
use Flarum\Http\Session;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class AuthenticateWithHeader implements MiddlewareInterface
{
/**
* @var string
*/
protected $prefix = 'Token ';
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$headerLine = $request->getHeaderLine('authorization');
$parts = explode(';', $headerLine);
if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) {
$id = substr($parts[0], strlen($this->prefix));
if (isset($parts[1]) && ApiKey::valid($id)) {
if ($actor = $this->getUser($parts[1])) {
$request = $request->withAttribute('actor', $actor);
}
} else {
$session = Session::find($id);
$request = $request->withAttribute('session', $session);
}
}
return $out ? $out($request, $response) : $response;
}
private function getUser($string)
{
$parts = explode('=', trim($string));
if (isset($parts[0]) && $parts[0] === 'userId') {
return User::find($parts[1]);
}
}
}

View File

@@ -13,8 +13,9 @@ namespace Flarum\Http\Middleware;
use FastRoute\Dispatcher;
use FastRoute\RouteParser;
use Flarum\Http\RouteCollection;
use Flarum\Http\Exception\MethodNotAllowedException;
use Flarum\Http\Exception\RouteNotFoundException;
use Flarum\Http\RouteCollection;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -47,6 +48,7 @@ class DispatchRoute
* @param Response $response
* @param callable $out
* @return Response
* @throws MethodNotAllowedException
* @throws RouteNotFoundException
*/
public function __invoke(Request $request, Response $response, callable $out = null)
@@ -58,8 +60,11 @@ class DispatchRoute
switch ($routeInfo[0]) {
case Dispatcher::NOT_FOUND:
case Dispatcher::METHOD_NOT_ALLOWED:
throw new RouteNotFoundException;
case Dispatcher::METHOD_NOT_ALLOWED:
throw new MethodNotAllowedException;
case Dispatcher::FOUND:
$handler = $routeInfo[1];
$parameters = $routeInfo[2];

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Middleware;
use Flarum\Locale\LocaleManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class SetLocale implements MiddlewareInterface
{
/**
* @var LocaleManager
*/
protected $locales;
/**
* @param LocaleManager $locales
*/
public function __construct(LocaleManager $locales)
{
$this->locales = $locales;
}
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$actor = $request->getAttribute('actor');
if ($actor->exists) {
$locale = $actor->getPreference('locale');
} else {
$locale = array_get($request->getCookieParams(), 'locale');
}
if ($locale && $this->locales->hasLocale($locale)) {
$this->locales->setLocale($locale);
}
return $out ? $out($request, $response) : $response;
}
}

View File

@@ -0,0 +1,81 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Middleware;
use Dflydev\FigCookies\FigResponseCookies;
use Dflydev\FigCookies\SetCookie;
use Dflydev\FigCookies\SetCookies;
use Flarum\Http\Session;
use Flarum\Core\Guest;
use Flarum\Http\WriteSessionCookieTrait;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class StartSession implements MiddlewareInterface
{
use WriteSessionCookieTrait;
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$this->collectGarbage();
$session = $this->getSession($request);
$actor = $this->getActor($session);
$request = $request
->withAttribute('session', $session)
->withAttribute('actor', $actor);
$response = $out ? $out($request, $response) : $response;
return $this->addSessionCookieToResponse($response, $session, 'flarum_session');
}
private function getSession(Request $request)
{
$session = $request->getAttribute('session');
if (! $session) {
$session = Session::generate();
}
$session->extend()->save();
return $session;
}
private function getActor(Session $session)
{
$actor = $session->user ?: new Guest;
if ($actor->exists) {
$actor->updateLastSeen()->save();
}
return $actor;
}
private function collectGarbage()
{
if ($this->hitsLottery()) {
Session::whereRaw('last_activity <= ? - duration * 60', [time()])->delete();
}
}
private function hitsLottery()
{
return mt_rand(1, 100) <= 1;
}
}

View File

@@ -0,0 +1,140 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http;
use DateTime;
use Flarum\Core\User;
use Flarum\Database\AbstractModel;
use Illuminate\Support\Str;
/**
* @property string $id
* @property int $user_id
* @property int $last_activity
* @property int $duration
* @property \Carbon\Carbon $sudo_expiry_time
* @property string $csrf_token
* @property \Flarum\Core\User|null $user
*/
class Session extends AbstractModel
{
/**
* {@inheritdoc}
*/
protected $table = 'sessions';
/**
* Use a custom primary key for this model.
*
* @var bool
*/
public $incrementing = false;
/**
* {@inheritdoc}
*/
protected $dates = ['sudo_expiry_time'];
/**
* Generate a session.
*
* @param User|null $user
* @param int $duration How long before the session will expire, in minutes.
* @return static
*/
public static function generate(User $user = null, $duration = 60)
{
$session = new static;
$session->assign($user)
->regenerateId()
->renew()
->setDuration($duration);
return $session->extend();
}
/**
* Assign the session to a user.
*
* @param User|null $user
* @return $this
*/
public function assign(User $user = null)
{
$this->user_id = $user ? $user->id : null;
return $this;
}
/**
* Regenerate the session ID.
*
* @return $this
*/
public function regenerateId()
{
$this->id = sha1(uniqid('', true).Str::random(25).microtime(true));
$this->csrf_token = Str::random(40);
return $this;
}
/**
* @return $this
*/
public function extend()
{
$this->last_activity = time();
return $this;
}
/**
* @return $this
*/
public function renew()
{
$this->extend();
$this->sudo_expiry_time = time() + 30 * 60;
return $this;
}
/**
* @param int $duration How long before the session will expire, in minutes.
* @return $this
*/
public function setDuration($duration)
{
$this->duration = $duration;
return $this;
}
/**
* @return bool
*/
public function isSudo()
{
return $this->sudo_expiry_time > new DateTime;
}
/**
* Define the relationship with the owner of this access token.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http;
use Dflydev\FigCookies\FigResponseCookies;
use Dflydev\FigCookies\SetCookie;
use Psr\Http\Message\ResponseInterface as Response;
trait WriteSessionCookieTrait
{
protected function addSessionCookieToResponse(Response $response, Session $session, $cookieName)
{
return FigResponseCookies::set(
$response,
SetCookie::create($cookieName, $session->exists ? $session->id : null)
->withMaxAge($session->exists ? $session->duration * 60 : -2628000)
->withPath('/')
->withHttpOnly(true)
);
}
}