Started writing tests
* Tested the more important functions * Tested the Double-Submit CSRF Middleware
This commit is contained in:
parent
b111c619d4
commit
ddcaf4b64d
@ -12,12 +12,12 @@
|
|||||||
"autoload": {
|
"autoload": {
|
||||||
"files": ["src/functions.php"],
|
"files": ["src/functions.php"],
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Taproot\\IndieAuth": "src"
|
"Taproot\\IndieAuth\\": "src"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Taproot\\IndieAuth\\Test": "tests"
|
"Taproot\\IndieAuth\\Test\\": "tests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@ -33,6 +33,7 @@
|
|||||||
"guzzlehttp/psr7": "^1.8"
|
"guzzlehttp/psr7": "^1.8"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"guzzlehttp/guzzle": "^7.3"
|
"guzzlehttp/guzzle": "^7.3",
|
||||||
|
"phpunit/phpunit": "^9.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2093
composer.lock
generated
2093
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ use Psr\Http\Message\ResponseInterface;
|
|||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
class ClosureRequestHandler implements RequestHandlerInterface {
|
class ClosureRequestHandler implements RequestHandlerInterface {
|
||||||
protected callable $callable;
|
protected $callable;
|
||||||
|
|
||||||
protected array $args;
|
protected array $args;
|
||||||
|
|
||||||
|
@ -24,16 +24,16 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa
|
|||||||
|
|
||||||
public int $ttl;
|
public int $ttl;
|
||||||
|
|
||||||
public callable $errorResponse;
|
public $errorResponse;
|
||||||
|
|
||||||
public int $tokenLength;
|
public int $tokenLength;
|
||||||
|
|
||||||
public LoggerInterface $logger;
|
public LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(string $attribute=self::ATTRIBUTE, int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) {
|
public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) {
|
||||||
$this->attribute = $attribute;
|
$this->attribute = $attribute ?? self::ATTRIBUTE;
|
||||||
$this->ttl = $ttl;
|
$this->ttl = $ttl ?? self::TTL;
|
||||||
$this->tokenLength = $tokenLength;
|
$this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH;
|
||||||
|
|
||||||
if (!is_callable($errorResponse)) {
|
if (!is_callable($errorResponse)) {
|
||||||
if (!$errorResponse instanceof ResponseInterface) {
|
if (!$errorResponse instanceof ResponseInterface) {
|
||||||
@ -57,18 +57,16 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
|
||||||
if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS)) {
|
// Generate a new CSRF token, add it to the request attributes, and as a cookie on the response.
|
||||||
// This request is a write method and requires CSRF protection.
|
|
||||||
if (!$this->isValid($request)) {
|
|
||||||
return call_user_func($this->errorResponse, $request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, generate a new CSRF token, add it to the request attributes, and as a cookie on the response.
|
|
||||||
$csrfToken = generateRandomString($this->tokenLength);
|
$csrfToken = generateRandomString($this->tokenLength);
|
||||||
$request = $request->withAttribute($this->attribute, $csrfToken);
|
$request = $request->withAttribute($this->attribute, $csrfToken);
|
||||||
|
|
||||||
|
if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) {
|
||||||
|
// This request is a write method with invalid CSRF parameters.
|
||||||
|
$response = call_user_func($this->errorResponse, $request);
|
||||||
|
} else {
|
||||||
$response = $handler->handle($request);
|
$response = $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
// Add the new CSRF cookie, restricting its scope to match the current request.
|
// Add the new CSRF cookie, restricting its scope to match the current request.
|
||||||
$response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
|
$response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
|
||||||
@ -82,8 +80,8 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function isValid(ServerRequestInterface $request) {
|
protected function isValid(ServerRequestInterface $request) {
|
||||||
if (in_array($this->attribute, $request->getParsedBody())) {
|
if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) {
|
||||||
if (in_array($this->attribute, $request->getCookieParams())) {
|
if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) {
|
||||||
return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
|
return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
19
src/Middleware/ResponseRequestHandler.php
Normal file
19
src/Middleware/ResponseRequestHandler.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Taproot\IndieAuth\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
class ResponseRequestHandler implements RequestHandlerInterface {
|
||||||
|
public ResponseInterface $response;
|
||||||
|
|
||||||
|
public function __construct(ResponseInterface $response) {
|
||||||
|
$this->response = $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ServerRequestInterface $request): ResponseInterface {
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
|
}
|
@ -3,23 +3,18 @@
|
|||||||
namespace Taproot\IndieAuth;
|
namespace Taproot\IndieAuth;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use GuzzleHttp\Exception\ServerException;
|
|
||||||
use IndieAuth\Client as IndieAuthClient;
|
use IndieAuth\Client as IndieAuthClient;
|
||||||
use Mf2;
|
use Mf2;
|
||||||
use BarnabyWalters\Mf2 as M;
|
use BarnabyWalters\Mf2 as M;
|
||||||
use GuzzleHttp\Psr7\Header as HeaderParser;
|
use GuzzleHttp\Psr7\Header as HeaderParser;
|
||||||
use Nyholm\Psr7\Response;
|
use Nyholm\Psr7\Response;
|
||||||
use Nyholm\Psr7\Request;
|
|
||||||
use Nyholm\Psr7\ServerRequest;
|
|
||||||
use Psr\Http\Client\ClientExceptionInterface;
|
use Psr\Http\Client\ClientExceptionInterface;
|
||||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||||
use Psr\Http\Client\NetworkExceptionInterface;
|
use Psr\Http\Client\NetworkExceptionInterface;
|
||||||
use Psr\Http\Client\RequestExceptionInterface;
|
use Psr\Http\Client\RequestExceptionInterface;
|
||||||
use Psr\Http\Message\RequestInterface;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Log\LoggerAwareInterface;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
@ -55,8 +55,13 @@ function buildQueryString(array $parameters) {
|
|||||||
* with either ? or & as appropriate.
|
* with either ? or & as appropriate.
|
||||||
*/
|
*/
|
||||||
function appendQueryParams(string $uri, array $queryParams) {
|
function appendQueryParams(string $uri, array $queryParams) {
|
||||||
|
if (empty($queryParams)) {
|
||||||
|
return $uri;
|
||||||
|
}
|
||||||
|
|
||||||
$queryString = buildQueryString($queryParams);
|
$queryString = buildQueryString($queryParams);
|
||||||
$separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
|
$separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
|
||||||
|
$uri = rtrim($uri, '?&');
|
||||||
return "{$uri}{$separator}{$queryString}";
|
return "{$uri}{$separator}{$queryString}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
123
tests/DoubleSubmitCookieCsrfMiddlewareTest.php
Normal file
123
tests/DoubleSubmitCookieCsrfMiddlewareTest.php
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Taproot\IndieAuth\Test;
|
||||||
|
|
||||||
|
use Dflydev\FigCookies\FigResponseCookies;
|
||||||
|
use Nyholm\Psr7\Response;
|
||||||
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Taproot\IndieAuth\Middleware\ClosureRequestHandler;
|
||||||
|
use Taproot\IndieAuth\Middleware\DoubleSubmitCookieCsrfMiddleware;
|
||||||
|
use Taproot\IndieAuth\Middleware\ResponseRequestHandler;
|
||||||
|
|
||||||
|
class DoubleSubmitCookieCsrfMiddlewareTest extends TestCase {
|
||||||
|
public function testPassesThroughNonWriteRequestsAddingAttribute() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['GET', 'HEAD', 'OPTIONS'] as $method) {
|
||||||
|
$request = new ServerRequest($method, 'https://example.com');
|
||||||
|
$preparedResponse = new Response();
|
||||||
|
$token = null;
|
||||||
|
$returnedResponse = $mw->process($request, new ClosureRequestHandler(function (ServerRequestInterface $request) use ($preparedResponse, $mw, &$token) {
|
||||||
|
$this->assertNotEmpty($request->getAttribute($mw->attribute), "The $mw->attribute on \$request was empty.");
|
||||||
|
$token = $request->getAttribute($mw->attribute);
|
||||||
|
return $preparedResponse;
|
||||||
|
}));
|
||||||
|
$this->assertEquals($preparedResponse->getStatusCode(), $returnedResponse->getStatusCode(), "Prepared response was not passed through for $method request.");
|
||||||
|
$responseCsrfCookieValue = FigResponseCookies::get($returnedResponse, $mw->attribute)->getValue();
|
||||||
|
$this->assertNotNull($responseCsrfCookieValue, "The $mw->attribute cookie on the response should not be null.");
|
||||||
|
$this->assertEquals($token, $responseCsrfCookieValue, "The $mw->attribute cookie attached to the response did not have the same value as the one in the request attribute.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDefaultErrorResponseOnWriteRequestsWithoutToken() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['PUT', 'POST', 'DELETE', 'PATCH'] as $method) {
|
||||||
|
$request = new ServerRequest($method, 'https://example.com');
|
||||||
|
$returnedResponse = $mw->process($request, new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $returnedResponse->getStatusCode(), "Default error response was not returned for CSRF-less $method request.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDefaultErrorResponseOnWriteRequestWithOnlyCookieToken() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['PUT', 'POST', 'DELETE', 'PATCH'] as $method) {
|
||||||
|
$request = (new ServerRequest($method, 'https://example.com'))->withCookieParams([
|
||||||
|
$mw->attribute => 'Invalid unmatched CSRF token!'
|
||||||
|
]);
|
||||||
|
$returnedResponse = $mw->process($request, new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $returnedResponse->getStatusCode(), "Default error response was not returned for $method request with CSRF token only in the $mw->attribute cookie.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDefaultErrorResponseOnWriteRequestWithOnlyBodyToken() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['PUT', 'POST', 'DELETE', 'PATCH'] as $method) {
|
||||||
|
$request = (new ServerRequest($method, 'https://example.com'))->withParsedBody([
|
||||||
|
$mw->attribute => 'Invalid unmatched CSRF token!'
|
||||||
|
]);
|
||||||
|
$returnedResponse = $mw->process($request, new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $returnedResponse->getStatusCode(), "Default error response was not returned for $method request with CSRF token only in the $mw->attribute body parameter.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDefaultErrorResponseOnWriteRequestWithMismatchedTokens() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['PUT', 'POST', 'DELETE', 'PATCH'] as $method) {
|
||||||
|
$request = (new ServerRequest($method, 'https://example.com'))->withParsedBody([
|
||||||
|
$mw->attribute => 'Invalid unmatched CSRF token!'
|
||||||
|
])->withCookieParams([
|
||||||
|
$mw->attribute => 'INVALID UNMATCHED CSRF TOKEN!!!!!'
|
||||||
|
]);
|
||||||
|
$returnedResponse = $mw->process($request, new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $returnedResponse->getStatusCode(), "Default error response was not returned for $method request with CSRF token only in the $mw->attribute body parameter.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPassesResponseThroughOnWriteRequestWithValidToken() {
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware();
|
||||||
|
|
||||||
|
foreach (['PUT', 'POST', 'DELETE', 'PATCH'] as $method) {
|
||||||
|
$request = (new ServerRequest($method, 'https://example.com'))->withParsedBody([
|
||||||
|
$mw->attribute => 'Valid matching CSRF token :D'
|
||||||
|
])->withCookieParams([
|
||||||
|
$mw->attribute => 'Valid matching CSRF token :D'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$returnedResponse = $mw->process($request, new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(200, $returnedResponse->getStatusCode(), "The response was not passed through on a $method request with valid matching CSRF tokens.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptsCustomStringErrorResponse() {
|
||||||
|
$errorResponseBody = 'ERROR!';
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware(null, null, $errorResponseBody);
|
||||||
|
$response = $mw->process(new ServerRequest('POST', 'https://example.com'), new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $response->getStatusCode(), "An error response should have been returned.");
|
||||||
|
$this->assertEquals($errorResponseBody, $response->getBody()->getContents(), "The error response should have the predefined body contents.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptsCustomErrorResponse() {
|
||||||
|
$errorResponseBody = 'ERROR!';
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware(null, null, new Response(400, [], $errorResponseBody));
|
||||||
|
$response = $mw->process(new ServerRequest('POST', 'https://example.com'), new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $response->getStatusCode(), "An error response should have been returned.");
|
||||||
|
$this->assertEquals($errorResponseBody, $response->getBody()->getContents(), "The error response should have the predefined body contents.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptsCustomCallbackErrorResponse() {
|
||||||
|
$errorResponseBody = 'ERROR!';
|
||||||
|
$mw = new DoubleSubmitCookieCsrfMiddleware(null, null, function (ServerRequestInterface $request) use ($errorResponseBody) {
|
||||||
|
$this->assertInstanceOf('\Psr\Http\Message\ServerRequestInterface', $request, "The request should be available within the error response callback.");
|
||||||
|
return new Response(400, [], $errorResponseBody);
|
||||||
|
});
|
||||||
|
$response = $mw->process(new ServerRequest('POST', 'https://example.com'), new ResponseRequestHandler(new Response(200)));
|
||||||
|
$this->assertEquals(400, $response->getStatusCode(), "An error response should have been returned.");
|
||||||
|
$this->assertEquals($errorResponseBody, $response->getBody()->getContents(), "The error response should have the predefined body contents.");
|
||||||
|
}
|
||||||
|
}
|
38
tests/FunctionTest.php
Normal file
38
tests/FunctionTest.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Taproot\IndieAuth\Test;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Taproot\IndieAuth as IA;
|
||||||
|
|
||||||
|
class FunctionTest extends TestCase {
|
||||||
|
public function testGenerateRandomString() {
|
||||||
|
$len = 10;
|
||||||
|
$rand = IA\generateRandomString($len);
|
||||||
|
$this->assertEquals($len, strlen(hex2bin($rand)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildQueryString() {
|
||||||
|
$testCases = [
|
||||||
|
'key=value' => ['key' => 'value'],
|
||||||
|
'k1=v1&k2=v2' => ['k1' => 'v1', 'k2' => 'v2']
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testCases as $expected => $params) {
|
||||||
|
$this->assertEquals($expected, IA\buildQueryString($params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppendQueryParams() {
|
||||||
|
$testCases = [
|
||||||
|
'https://example.com/?k=v' => ['https://example.com/', ['k' => 'v']],
|
||||||
|
'https://example.com/?k=v' => ['https://example.com/?', ['k' => 'v']],
|
||||||
|
'https://example.com/?k=v' => ['https://example.com/?k=v', []],
|
||||||
|
'https://example.com/?k=v&k2=v2' => ['https://example.com/?k=v', ['k2' => 'v2']]
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testCases as $expected => list($uri, $params)) {
|
||||||
|
$this->assertEquals($expected, IA\appendQueryParams($uri, $params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
tests/ServerTest.php
Normal file
10
tests/ServerTest.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Taproot\IndieAuth\Test;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Taproot\IndieAuth\Server;
|
||||||
|
|
||||||
|
class ServerTest extends TestCase {
|
||||||
|
|
||||||
|
}
|
Reference in New Issue
Block a user