Added csrf middleware
parent
8236989be0
commit
23c0fae36f
@ -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 %}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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