Added csrf middleware

This commit is contained in:
Igor Scheller 2018-09-03 15:33:13 +01:00 committed by msquare
parent 8236989be0
commit 23c0fae36f
14 changed files with 395 additions and 7 deletions

View File

@ -40,6 +40,7 @@ return [
// The application code // The application code
\Engelsystem\Middleware\ErrorHandler::class, \Engelsystem\Middleware\ErrorHandler::class,
\Engelsystem\Middleware\VerifyCsrfToken::class,
\Engelsystem\Middleware\RouteDispatcher::class, \Engelsystem\Middleware\RouteDispatcher::class,
// Handle request // Handle request

View File

@ -44,6 +44,7 @@ function admin_user()
$html .= '<form action="' $html .= '<form action="'
. page_link_to('admin_user', ['action' => 'save', 'id' => $user_id]) . page_link_to('admin_user', ['action' => 'save', 'id' => $user_id])
. '" method="post">' . "\n"; . '" method="post">' . "\n";
$html .= form_csrf();
$html .= '<table border="0">' . "\n"; $html .= '<table border="0">' . "\n";
$html .= '<input type="hidden" name="Type" value="Normal">' . "\n"; $html .= '<input type="hidden" name="Type" value="Normal">' . "\n";
$html .= '<tr><td>' . "\n"; $html .= '<tr><td>' . "\n";
@ -105,6 +106,7 @@ function admin_user()
$html .= 'Hier kannst Du das Passwort dieses Engels neu setzen:<form action="' $html .= 'Hier kannst Du das Passwort dieses Engels neu setzen:<form action="'
. page_link_to('admin_user', ['action' => 'change_pw', 'id' => $user_id]) . page_link_to('admin_user', ['action' => 'change_pw', 'id' => $user_id])
. '" method="post">' . "\n"; . '" method="post">' . "\n";
$html .= form_csrf();
$html .= '<table>' . "\n"; $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>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"; $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="' $html .= 'Hier kannst Du die Benutzergruppen des Engels festlegen:<form action="'
. page_link_to('admin_user', ['action' => 'save_groups', 'id' => $user_id]) . page_link_to('admin_user', ['action' => 'save_groups', 'id' => $user_id])
. '" method="post">' . "\n"; . '" method="post">' . "\n";
$html .= form_csrf();
$html .= '<table>'; $html .= '<table>';
$groups = DB::select(' $groups = DB::select('

View File

@ -407,7 +407,18 @@ function form_element($label, $input, $for = '')
*/ */
function form($elements, $action = '') 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'));
} }
/** /**

View File

@ -14,3 +14,7 @@ require('./moment-countdown');
$(function () { $(function () {
moment.locale($('html').attr('lang')); moment.locale($('html').attr('lang'));
}); });
$.ajaxSetup({
headers: {'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')}
});

View File

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

View File

@ -7,6 +7,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"> <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') }}"/> <link rel="stylesheet" type="text/css" href="{{ asset('assets/theme' ~ theme ~ '.css') }}"/>
<script type="text/javascript" src="{{ asset('assets/vendor.js') }}"></script> <script type="text/javascript" src="{{ asset('assets/vendor.js') }}"></script>

View File

@ -96,7 +96,7 @@ class Response extends SymfonyResponse implements ResponseInterface
/** /**
* Return an instance with the rendered content. * 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 * an instance with the updated status and headers
* *
* @param string $view * @param string $view
@ -111,6 +111,14 @@ class Response extends SymfonyResponse implements ResponseInterface
throw new \InvalidArgumentException('Renderer not defined'); 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;
} }
} }

View File

@ -5,7 +5,9 @@ namespace Engelsystem\Http;
use Engelsystem\Config\Config; use Engelsystem\Config\Config;
use Engelsystem\Container\ServiceProvider; use Engelsystem\Container\ServiceProvider;
use Engelsystem\Http\SessionHandlers\DatabaseHandler; use Engelsystem\Http\SessionHandlers\DatabaseHandler;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Session\Session; 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\MockArraySessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface; use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface;
@ -21,6 +23,11 @@ class SessionServiceProvider extends ServiceProvider
$session = $this->app->make(Session::class); $session = $this->app->make(Session::class);
$this->app->instance(Session::class, $session); $this->app->instance(Session::class, $session);
$this->app->instance('session', $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 */ /** @var Request $request */
$request = $this->app->get('request'); $request = $this->app->get('request');

View File

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

View File

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

View File

@ -4,9 +4,10 @@ namespace Engelsystem\Renderer;
use Engelsystem\Config\Config as EngelsystemConfig; use Engelsystem\Config\Config as EngelsystemConfig;
use Engelsystem\Container\ServiceProvider; use Engelsystem\Container\ServiceProvider;
use Engelsystem\Renderer\Twig\Extensions\Authentication;
use Engelsystem\Renderer\Twig\Extensions\Assets; use Engelsystem\Renderer\Twig\Extensions\Assets;
use Engelsystem\Renderer\Twig\Extensions\Authentication;
use Engelsystem\Renderer\Twig\Extensions\Config; use Engelsystem\Renderer\Twig\Extensions\Config;
use Engelsystem\Renderer\Twig\Extensions\Csrf;
use Engelsystem\Renderer\Twig\Extensions\Globals; use Engelsystem\Renderer\Twig\Extensions\Globals;
use Engelsystem\Renderer\Twig\Extensions\Legacy; use Engelsystem\Renderer\Twig\Extensions\Legacy;
use Engelsystem\Renderer\Twig\Extensions\Session; use Engelsystem\Renderer\Twig\Extensions\Session;
@ -23,6 +24,7 @@ class TwigServiceProvider extends ServiceProvider
'assets' => Assets::class, 'assets' => Assets::class,
'authentication' => Authentication::class, 'authentication' => Authentication::class,
'config' => Config::class, 'config' => Config::class,
'csrf' => Csrf::class,
'globals' => Globals::class, 'globals' => Globals::class,
'session' => Session::class, 'session' => Session::class,
'legacy' => Legacy::class, 'legacy' => Legacy::class,

View File

@ -9,6 +9,7 @@ use Engelsystem\Http\SessionServiceProvider;
use Engelsystem\Test\Unit\ServiceProviderTest; use Engelsystem\Test\Unit\ServiceProviderTest;
use PHPUnit_Framework_MockObject_MockObject as MockObject; use PHPUnit_Framework_MockObject_MockObject as MockObject;
use Symfony\Component\HttpFoundation\Session\Session; 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\MockArraySessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface as StorageInterface; use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface as StorageInterface;
@ -104,8 +105,16 @@ class SessionServiceProviderTest extends ServiceProviderTest
['driver' => 'pdo', 'name' => 'foobar'] ['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($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()); $this->setExpects($session, 'start', null, null, $this->atLeastOnce());
$serviceProvider->register(); $serviceProvider->register();
@ -142,10 +151,16 @@ class SessionServiceProviderTest extends ServiceProviderTest
[Session::class, $session], [Session::class, $session],
['session', $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($app, 'get', ['request'], $request);
$this->setExpects($request, 'setSession', [$session]); $this->setExpects($request, 'setSession', [$session]);
$this->setExpects($session, 'has', ['_token'], true);
$this->setExpects($session, 'start'); $this->setExpects($session, 'start');
$serviceProvider = new SessionServiceProvider($app); $serviceProvider = new SessionServiceProvider($app);
@ -160,7 +175,7 @@ class SessionServiceProviderTest extends ServiceProviderTest
$sessionStorage = $this->getMockForAbstractClass(StorageInterface::class); $sessionStorage = $this->getMockForAbstractClass(StorageInterface::class);
return $this->getMockBuilder(Session::class) return $this->getMockBuilder(Session::class)
->setConstructorArgs([$sessionStorage]) ->setConstructorArgs([$sessionStorage])
->setMethods(['start']) ->setMethods(['start', 'has', 'set'])
->getMock(); ->getMock();
} }

View File

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

View File

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