Added csrf middleware
This commit is contained in:
parent
8236989be0
commit
23c0fae36f
|
@ -40,6 +40,7 @@ return [
|
|||
|
||||
// The application code
|
||||
\Engelsystem\Middleware\ErrorHandler::class,
|
||||
\Engelsystem\Middleware\VerifyCsrfToken::class,
|
||||
\Engelsystem\Middleware\RouteDispatcher::class,
|
||||
|
||||
// Handle request
|
||||
|
|
|
@ -44,6 +44,7 @@ function admin_user()
|
|||
$html .= '<form action="'
|
||||
. page_link_to('admin_user', ['action' => 'save', 'id' => $user_id])
|
||||
. '" method="post">' . "\n";
|
||||
$html .= form_csrf();
|
||||
$html .= '<table border="0">' . "\n";
|
||||
$html .= '<input type="hidden" name="Type" value="Normal">' . "\n";
|
||||
$html .= '<tr><td>' . "\n";
|
||||
|
@ -105,6 +106,7 @@ function admin_user()
|
|||
$html .= 'Hier kannst Du das Passwort dieses Engels neu setzen:<form action="'
|
||||
. page_link_to('admin_user', ['action' => 'change_pw', 'id' => $user_id])
|
||||
. '" method="post">' . "\n";
|
||||
$html .= form_csrf();
|
||||
$html .= '<table>' . "\n";
|
||||
$html .= ' <tr><td>Passwort</td><td>' . '<input type="password" size="40" name="new_pw" value="" class="form-control"></td></tr>' . "\n";
|
||||
$html .= ' <tr><td>Wiederholung</td><td>' . '<input type="password" size="40" name="new_pw2" value="" class="form-control"></td></tr>' . "\n";
|
||||
|
@ -135,6 +137,7 @@ function admin_user()
|
|||
$html .= 'Hier kannst Du die Benutzergruppen des Engels festlegen:<form action="'
|
||||
. page_link_to('admin_user', ['action' => 'save_groups', 'id' => $user_id])
|
||||
. '" method="post">' . "\n";
|
||||
$html .= form_csrf();
|
||||
$html .= '<table>';
|
||||
|
||||
$groups = DB::select('
|
||||
|
|
|
@ -407,7 +407,18 @@ function form_element($label, $input, $for = '')
|
|||
*/
|
||||
function form($elements, $action = '')
|
||||
{
|
||||
return '<form action="' . $action . '" enctype="multipart/form-data" method="post">' . join($elements) . '</form>';
|
||||
return '<form action="' . $action . '" enctype="multipart/form-data" method="post">'
|
||||
. form_csrf()
|
||||
. join($elements)
|
||||
. '</form>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
function form_csrf()
|
||||
{
|
||||
return form_hidden('_token', session()->get('_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,3 +14,7 @@ require('./moment-countdown');
|
|||
$(function () {
|
||||
moment.locale($('html').attr('lang'));
|
||||
});
|
||||
|
||||
$.ajaxSetup({
|
||||
headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "errors/default.twig" %}
|
||||
|
||||
{% block title %}{{ __("Authentication expired") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-warning">{{ __("The provided CSRF token is invalid or has expired") }}</div>
|
||||
{% endblock %}
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{{ asset('assets/theme' ~ theme ~ '.css') }}"/>
|
||||
<script type="text/javascript" src="{{ asset('assets/vendor.js') }}"></script>
|
||||
|
|
|
@ -96,7 +96,7 @@ class Response extends SymfonyResponse implements ResponseInterface
|
|||
/**
|
||||
* Return an instance with the rendered content.
|
||||
*
|
||||
* THis method retains the immutability of the message and returns
|
||||
* This method retains the immutability of the message and returns
|
||||
* an instance with the updated status and headers
|
||||
*
|
||||
* @param string $view
|
||||
|
@ -111,6 +111,14 @@ class Response extends SymfonyResponse implements ResponseInterface
|
|||
throw new \InvalidArgumentException('Renderer not defined');
|
||||
}
|
||||
|
||||
return $this->create($this->view->render($view, $data), $status, $headers);
|
||||
$new = clone $this;
|
||||
$new->setContent($this->view->render($view, $data));
|
||||
$new->setStatusCode($status, ($status == $this->getStatusCode() ? $this->statusText : null));
|
||||
|
||||
foreach ($headers as $key => $values) {
|
||||
$new = $new->withAddedHeader($key, $values);
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@ namespace Engelsystem\Http;
|
|||
use Engelsystem\Config\Config;
|
||||
use Engelsystem\Container\ServiceProvider;
|
||||
use Engelsystem\Http\SessionHandlers\DatabaseHandler;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface;
|
||||
|
@ -21,6 +23,11 @@ class SessionServiceProvider extends ServiceProvider
|
|||
$session = $this->app->make(Session::class);
|
||||
$this->app->instance(Session::class, $session);
|
||||
$this->app->instance('session', $session);
|
||||
$this->app->bind(SessionInterface::class, Session::class);
|
||||
|
||||
if (!$session->has('_token')) {
|
||||
$session->set('_token', Str::random(42));
|
||||
}
|
||||
|
||||
/** @var Request $request */
|
||||
$request = $this->app->get('request');
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace Engelsystem\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
|
||||
class VerifyCsrfToken implements MiddlewareInterface
|
||||
{
|
||||
/** @var SessionInterface */
|
||||
protected $session;
|
||||
|
||||
/**
|
||||
* @param SessionInterface $session
|
||||
*/
|
||||
public function __construct(SessionInterface $session)
|
||||
{
|
||||
$this->session = $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify csrf tokens
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param RequestHandlerInterface $handler
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
if (
|
||||
$this->isReading($request)
|
||||
|| $this->tokensMatch($request)
|
||||
) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
return $this->notAuthorizedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @return bool
|
||||
*/
|
||||
protected function isReading(ServerRequestInterface $request): bool
|
||||
{
|
||||
return in_array(
|
||||
$request->getMethod(),
|
||||
['GET', 'HEAD', 'OPTIONS']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @return bool
|
||||
*/
|
||||
protected function tokensMatch(ServerRequestInterface $request): bool
|
||||
{
|
||||
$token = null;
|
||||
$body = $request->getParsedBody();
|
||||
$header = $request->getHeader('X-CSRF-TOKEN');
|
||||
|
||||
if (is_array($body) && isset($body['_token'])) {
|
||||
$token = $body['_token'];
|
||||
}
|
||||
|
||||
if (!empty($header)) {
|
||||
$header = array_shift($header);
|
||||
}
|
||||
|
||||
$token = $token ?: $header;
|
||||
$sessionToken = $this->session->get('_token');
|
||||
|
||||
return is_string($token)
|
||||
&& is_string($sessionToken)
|
||||
&& hash_equals($sessionToken, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected function notAuthorizedResponse(): ResponseInterface
|
||||
{
|
||||
// The 419 code is used as "Page Expired" to differentiate from a 401 (not authorized)
|
||||
return response()->withStatus(419, 'Authentication Token Mismatch');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace Engelsystem\Renderer\Twig\Extensions;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Twig_Extension as TwigExtension;
|
||||
use Twig_Function as TwigFunction;
|
||||
|
||||
class Csrf extends TwigExtension
|
||||
{
|
||||
/** @var SessionInterface */
|
||||
protected $session;
|
||||
|
||||
/**
|
||||
* @param SessionInterface $session
|
||||
*/
|
||||
public function __construct(SessionInterface $session)
|
||||
{
|
||||
$this->session = $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TwigFunction[]
|
||||
*/
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('csrf', [$this, 'getCsrfField'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('csrf_token', [$this, 'getCsrfToken']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCsrfField()
|
||||
{
|
||||
return sprintf('<input type="hidden" name="_token" value="%s">', $this->getCsrfToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCsrfToken()
|
||||
{
|
||||
return $this->session->get('_token');
|
||||
}
|
||||
}
|
|
@ -4,9 +4,10 @@ namespace Engelsystem\Renderer;
|
|||
|
||||
use Engelsystem\Config\Config as EngelsystemConfig;
|
||||
use Engelsystem\Container\ServiceProvider;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Authentication;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Assets;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Authentication;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Config;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Csrf;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Globals;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Legacy;
|
||||
use Engelsystem\Renderer\Twig\Extensions\Session;
|
||||
|
@ -23,6 +24,7 @@ class TwigServiceProvider extends ServiceProvider
|
|||
'assets' => Assets::class,
|
||||
'authentication' => Authentication::class,
|
||||
'config' => Config::class,
|
||||
'csrf' => Csrf::class,
|
||||
'globals' => Globals::class,
|
||||
'session' => Session::class,
|
||||
'legacy' => Legacy::class,
|
||||
|
|
|
@ -9,6 +9,7 @@ use Engelsystem\Http\SessionServiceProvider;
|
|||
use Engelsystem\Test\Unit\ServiceProviderTest;
|
||||
use PHPUnit_Framework_MockObject_MockObject as MockObject;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface as StorageInterface;
|
||||
|
@ -104,8 +105,16 @@ class SessionServiceProviderTest extends ServiceProviderTest
|
|||
['driver' => 'pdo', 'name' => 'foobar']
|
||||
);
|
||||
|
||||
$this->setExpects($app, 'bind', [StorageInterface::class, 'session.storage'], null, $this->atLeastOnce());
|
||||
$app->expects($this->atLeastOnce())
|
||||
->method('bind')
|
||||
->withConsecutive(
|
||||
[StorageInterface::class, 'session.storage'],
|
||||
[SessionInterface::class, Session::class]
|
||||
);
|
||||
|
||||
$this->setExpects($request, 'setSession', [$session], null, $this->atLeastOnce());
|
||||
$this->setExpects($session, 'has', ['_token'], false, $this->atLeastOnce());
|
||||
$this->setExpects($session, 'set', ['_token'], null, $this->atLeastOnce());
|
||||
$this->setExpects($session, 'start', null, null, $this->atLeastOnce());
|
||||
|
||||
$serviceProvider->register();
|
||||
|
@ -142,10 +151,16 @@ class SessionServiceProviderTest extends ServiceProviderTest
|
|||
[Session::class, $session],
|
||||
['session', $session]
|
||||
);
|
||||
$app->expects($this->atLeastOnce())
|
||||
->method('bind')
|
||||
->withConsecutive(
|
||||
[StorageInterface::class, 'session.storage'],
|
||||
[SessionInterface::class, Session::class]
|
||||
);
|
||||
|
||||
$this->setExpects($app, 'bind', [StorageInterface::class, 'session.storage']);
|
||||
$this->setExpects($app, 'get', ['request'], $request);
|
||||
$this->setExpects($request, 'setSession', [$session]);
|
||||
$this->setExpects($session, 'has', ['_token'], true);
|
||||
$this->setExpects($session, 'start');
|
||||
|
||||
$serviceProvider = new SessionServiceProvider($app);
|
||||
|
@ -160,7 +175,7 @@ class SessionServiceProviderTest extends ServiceProviderTest
|
|||
$sessionStorage = $this->getMockForAbstractClass(StorageInterface::class);
|
||||
return $this->getMockBuilder(Session::class)
|
||||
->setConstructorArgs([$sessionStorage])
|
||||
->setMethods(['start'])
|
||||
->setMethods(['start', 'has', 'set'])
|
||||
->getMock();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace Engelsystem\Test\Unit\Middleware;
|
||||
|
||||
use Engelsystem\Middleware\VerifyCsrfToken;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
|
||||
class VerifyCsrfTokenTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\VerifyCsrfToken::process
|
||||
* @covers \Engelsystem\Middleware\VerifyCsrfToken::isReading
|
||||
*/
|
||||
public function testProcess()
|
||||
{
|
||||
/** @var ServerRequestInterface|MockObject $request */
|
||||
$request = $this->getMockForAbstractClass(ServerRequestInterface::class);
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
/** @var ResponseInterface|MockObject $response */
|
||||
$response = $this->getMockForAbstractClass(ResponseInterface::class);
|
||||
|
||||
$handler->expects($this->exactly(2))
|
||||
->method('handle')
|
||||
->with($request)
|
||||
->willReturn($response);
|
||||
|
||||
/** @var VerifyCsrfToken|MockObject $middleware */
|
||||
$middleware = $this->getMockBuilder(VerifyCsrfToken::class)
|
||||
->disableOriginalConstructor()
|
||||
->setMethods(['notAuthorizedResponse', 'tokensMatch'])
|
||||
->getMock();
|
||||
|
||||
$middleware->expects($this->exactly(1))
|
||||
->method('notAuthorizedResponse')
|
||||
->willReturn($response);
|
||||
|
||||
$middleware->expects($this->exactly(2))
|
||||
->method('tokensMatch')
|
||||
->willReturnOnConsecutiveCalls(true, false);
|
||||
|
||||
// Results in true, false, false
|
||||
$request->expects($this->exactly(3))
|
||||
->method('getMethod')
|
||||
->willReturnOnConsecutiveCalls('GET', 'POST', 'DELETE');
|
||||
|
||||
$middleware->process($request, $handler);
|
||||
$middleware->process($request, $handler);
|
||||
$middleware->process($request, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\VerifyCsrfToken::__construct
|
||||
* @covers \Engelsystem\Middleware\VerifyCsrfToken::tokensMatch
|
||||
*/
|
||||
public function testTokensMatch()
|
||||
{
|
||||
/** @var ServerRequestInterface|MockObject $request */
|
||||
$request = $this->getMockForAbstractClass(ServerRequestInterface::class);
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
/** @var ResponseInterface|MockObject $response */
|
||||
$response = $this->getMockForAbstractClass(ResponseInterface::class);
|
||||
/** @var ResponseInterface|MockObject $noAuthResponse */
|
||||
$noAuthResponse = $this->getMockForAbstractClass(ResponseInterface::class);
|
||||
/** @var SessionInterface|MockObject $session */
|
||||
$session = $this->getMockForAbstractClass(SessionInterface::class);
|
||||
|
||||
/** @var VerifyCsrfToken|MockObject $middleware */
|
||||
$middleware = $this->getMockBuilder(VerifyCsrfToken::class)
|
||||
->setConstructorArgs([$session])
|
||||
->setMethods(['isReading', 'notAuthorizedResponse'])
|
||||
->getMock();
|
||||
|
||||
$middleware->expects($this->atLeastOnce())
|
||||
->method('isReading')
|
||||
->willReturn(false);
|
||||
$middleware->expects($this->exactly(1))
|
||||
->method('notAuthorizedResponse')
|
||||
->willReturn($noAuthResponse);
|
||||
|
||||
$handler->expects($this->exactly(3))
|
||||
->method('handle')
|
||||
->willReturn($response);
|
||||
|
||||
$request->expects($this->exactly(4))
|
||||
->method('getParsedBody')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
null,
|
||||
null,
|
||||
['_token' => 'PostFooToken'],
|
||||
['_token' => 'PostBarToken']
|
||||
);
|
||||
$request->expects($this->exactly(4))
|
||||
->method('getHeader')
|
||||
->with('X-CSRF-TOKEN')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
[],
|
||||
['HeaderFooToken'],
|
||||
[],
|
||||
['HeaderBarToken']
|
||||
);
|
||||
|
||||
$session->expects($this->exactly(4))
|
||||
->method('get')
|
||||
->with('_token')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
'NotAvailableToken',
|
||||
'HeaderFooToken',
|
||||
'PostFooToken',
|
||||
'PostBarToken'
|
||||
);
|
||||
|
||||
// Not tokens
|
||||
$this->assertEquals($noAuthResponse, $middleware->process($request, $handler));
|
||||
// Header token
|
||||
$this->assertEquals($response, $middleware->process($request, $handler));
|
||||
// POST token
|
||||
$this->assertEquals($response, $middleware->process($request, $handler));
|
||||
// Header and POST tokens
|
||||
$this->assertEquals($response, $middleware->process($request, $handler));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Engelsystem\Test\Unit\Renderer\Twig\Extensions;
|
||||
|
||||
use Engelsystem\Renderer\Twig\Extensions\Csrf;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
|
||||
class CsrfTest extends ExtensionTest
|
||||
{
|
||||
/**
|
||||
* @covers \Engelsystem\Renderer\Twig\Extensions\Csrf::getFunctions
|
||||
*/
|
||||
public function testGetGlobals()
|
||||
{
|
||||
/** @var SessionInterface|MockObject $session */
|
||||
$session = $this->createMock(SessionInterface::class);
|
||||
|
||||
$extension = new Csrf($session);
|
||||
$functions = $extension->getFunctions();
|
||||
|
||||
$this->assertExtensionExists('csrf', [$extension, 'getCsrfField'], $functions, ['is_safe' => ['html']]);
|
||||
$this->assertExtensionExists('csrf_token', [$extension, 'getCsrfToken'], $functions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Renderer\Twig\Extensions\Csrf::getCsrfField
|
||||
*/
|
||||
public function testGetCsrfField()
|
||||
{
|
||||
/** @var Csrf|MockObject $extension */
|
||||
$extension = $this->getMockBuilder(Csrf::class)
|
||||
->disableOriginalConstructor()
|
||||
->setMethods(['getCsrfToken'])
|
||||
->getMock();
|
||||
|
||||
$extension->expects($this->once())
|
||||
->method('getCsrfToken')
|
||||
->willReturn('SomeRandomCsrfToken');
|
||||
|
||||
$this->assertEquals(
|
||||
'<input type="hidden" name="_token" value="SomeRandomCsrfToken">',
|
||||
$extension->getCsrfField()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Renderer\Twig\Extensions\Csrf::__construct
|
||||
* @covers \Engelsystem\Renderer\Twig\Extensions\Csrf::getCsrfToken
|
||||
*/
|
||||
public function testGetCsrfToken()
|
||||
{
|
||||
/** @var SessionInterface|MockObject $session */
|
||||
$session = $this->createMock(SessionInterface::class);
|
||||
$session->expects($this->once())
|
||||
->method('get')
|
||||
->with('_token')
|
||||
->willReturn('SomeOtherCsrfToken');
|
||||
|
||||
$extension = new Csrf($session);
|
||||
$this->assertEquals('SomeOtherCsrfToken', $extension->getCsrfToken());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue