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