From a0fe1b5f804816f5e507d4bef3e739712b58a7ba Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Sat, 12 Jun 2021 20:08:16 +0200 Subject: [PATCH] Required cache-control headers on more responses --- src/Callback/AuthorizationFormInterface.php | 6 +- ...ngleUserPasswordAuthenticationCallback.php | 5 ++ .../DoubleSubmitCookieCsrfMiddleware.php | 16 +++- src/Server.php | 85 +++++++++++++------ src/functions.php | 9 ++ tests/ServerTest.php | 8 +- 6 files changed, 96 insertions(+), 33 deletions(-) diff --git a/src/Callback/AuthorizationFormInterface.php b/src/Callback/AuthorizationFormInterface.php index 7fbba5a..4037a2b 100644 --- a/src/Callback/AuthorizationFormInterface.php +++ b/src/Callback/AuthorizationFormInterface.php @@ -29,8 +29,8 @@ interface AuthorizationFormInterface { * * `client_id`: the URL of the client app. Should be shown to the user. This also makes a good “cancel” link. * * `redirect_uri`: the URI which the user will be redirected to on successful authorization. * - * The form MUST submit a POST request to $formAction, with the `taproot_indieauth_action` - * set to `approve`. + * The form MUST submit a POST request to `$formAction`, with the `taproot_indieauth_action` + * parameter set to `approve`. * * The form MUST additionally include any CSRF tokens required to protect the submission. * Refer to whatever CSRF protection code you’re using (e.g. `\Taproot\IndieAuth\Middleware\DoubleSubmitCookieCsrfMiddleware`) @@ -88,4 +88,4 @@ interface AuthorizationFormInterface { * @return array The $code data after making any necessary changes. */ public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array; -} \ No newline at end of file +} diff --git a/src/Callback/SingleUserPasswordAuthenticationCallback.php b/src/Callback/SingleUserPasswordAuthenticationCallback.php index 4e92155..80889b3 100644 --- a/src/Callback/SingleUserPasswordAuthenticationCallback.php +++ b/src/Callback/SingleUserPasswordAuthenticationCallback.php @@ -8,6 +8,11 @@ use Psr\Http\Message\ServerRequestInterface; use function Taproot\IndieAuth\renderTemplate; +/** + * Single User Password Authentication Callback + * + * + */ class SingleUserPasswordAuthenticationCallback { const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; diff --git a/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php b/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php index 7998776..47fddc5 100644 --- a/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php +++ b/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php @@ -11,7 +11,8 @@ use Dflydev\FigCookies; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use function Taproot\IndieAuth\generateRandomString; + +use function Taproot\IndieAuth\generateRandomPrintableAsciiString; /** * Double-Submit Cookie CSRF Middleware @@ -50,6 +51,17 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa public LoggerInterface $logger; + /** + * Constructor + * + * The `$errorResponse` parameter can be used to customse the error response returned when a + * write request has invalid CSRF parameters. It can take the following forms: + * + * * A `string`, which will be returned as-is with a 400 Status Code and `Content-type: text/plain` header + * * An instance of `ResponseInterface`, which will be returned as-is + * * A callable with the signature `function (ServerRequestInterface $request): ResponseInterface`, + * the return value of which will be returned as-is. + */ 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 ?? self::ATTRIBUTE; $this->ttl = $ttl ?? self::TTL; @@ -78,7 +90,7 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. - $csrfToken = generateRandomString($this->tokenLength); + $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); $request = $request->withAttribute($this->attribute, $csrfToken); if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { diff --git a/src/Server.php b/src/Server.php index a31b27d..2edd544 100644 --- a/src/Server.php +++ b/src/Server.php @@ -2,12 +2,13 @@ namespace Taproot\IndieAuth; +use BarnabyWalters\Mf2 as M; use Exception; +use GuzzleHttp\Psr7\Header as HeaderParser; use IndieAuth\Client as IndieAuthClient; use Mf2; -use BarnabyWalters\Mf2 as M; -use GuzzleHttp\Psr7\Header as HeaderParser; use Nyholm\Psr7\Response; +use PDO; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Client\RequestExceptionInterface; @@ -18,28 +19,31 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Taproot\IndieAuth\Callback\AuthorizationFormInterface; use Taproot\IndieAuth\Callback\DefaultAuthorizationForm; - +use Taproot\IndieAuth\Storage\TokenStorageInterface; /** * IndieAuth Server * - * A PSR-7 compatible implementation of the request-handling logic for IndieAuth authorization endpoints + * A PSR-7-compatible implementation of the request-handling logic for IndieAuth authorization endpoints * and token endpoints. * - * Typical usage looks something like this: + * Typical minimal usage looks something like this: * - * // Somewhere in your app set-up: - * - * use Taproot\IndieAuth; - * - * $server = new IndieAuth\Server([ - * 'secret' => APP_INDIEAUTH_SECRET, - * 'tokenStorage' => '/../data/auth_tokens/', - * 'handleAuthenticationRequestCallback' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback([ - * 'me' => 'https://your-domain.com/' - * ], - * YOUR_HASHED_PASSWORD - * ) + * // Somewhere in your app set-up code: + * $server = new Taproot\IndieAuth\Server([ + * 'secret' => APP_INDIEAUTH_SECRET, // A secret key, >= 64 characters long. + * 'tokenStorage' => '/../data/auth_tokens/', // A path to store token data, or an object implementing TokenStorageInterface. + * 'handleAuthenticationRequestCallback' => function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl) { + * // If the request is authenticated, return an array with a `me` key containing the + * // canonical URL of the currently logged-in user. + * if ($userUrl = getLoggedInUserUrl($request)) { + * return ['me' => $userUrl]; + * } + * + * // Otherwise, redirect the user to a login page, ensuring that they will be redirected + * // back to the IndieAuth flow with query parameters intact once logged in. + * return new Response('302', ['Location' => 'https://example.com/login?next=' . urlencode($authenticationRedirect)]); + * } * ]); * * // In your authorization endpoint route: @@ -47,6 +51,15 @@ use Taproot\IndieAuth\Callback\DefaultAuthorizationForm; * * // In your token endpoint route: * return $server->handleTokenEndpointRequest($request); + * + * // In another route (e.g. a micropub route), to authenticate the request: + * // (assuming $bearerToken is a token parsed from an “Authorization: Bearer XXXXXX” header + * // or access_token property from a request body) + * if ($accessToken = $server->getTokenStorage()->getAccessToken($bearerToken)) { + * // Request is authenticated as $accessToken['me'], and is allowed to + * // act according to the scopes listed in $accessToken['scope']. + * $scopes = explode(' ', $accessToken['scope']); + * } * * Refer to the `__construct` documentation for further configuration options, and to the * documentation for both handling methods for further documentation about them. @@ -120,8 +133,12 @@ class Server { * `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used * as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT * be considered valid data. + * + * If redirecting to an existing authentication flow, this callable can usually be implemented as a + * closure. The callable may also implement its own authentication logic. For an example, see + * `Callback\SingleUserPasswordAuthenticationCallback`. * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used - * to hash and subsequently query parameters which get passed around. + * to hash and subsequently verify request query parameters which get passed around. * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path, * which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization * codes and access tokens, as well as implementation-specific parts of the exchange process which are @@ -156,7 +173,7 @@ class Server { * made by your authentication or authorization pages, if it’s not convenient to put them elsewhere. * Returning `null` will result in a standard `invalid_request` error being returned. * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set - * as the logger for most objects passed in config which implement `LoggerAwareInterface`. + * as the logger for any objects passed in config which implement `LoggerAwareInterface`. * * @param array $config An array of configuration variables * @return self @@ -179,7 +196,7 @@ class Server { $secret = $config['secret'] ?? ''; if (!is_string($secret) || strlen($secret) < 64) { - throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); + throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters."); } $this->secret = $secret; @@ -253,6 +270,10 @@ class Server { trySetLogger($this->authorizationForm, $this->logger); } + public function getTokenStorage(): TokenStorageInterface { + return $this->tokenStorage; + } + /** * Handle Authorization Endpoint Request * @@ -357,7 +378,10 @@ class Server { // TODO: return an error if the token doesn’t contain a me key. // If everything checked out, return {"me": "https://example.com"} response - return new Response(200, ['content-type' => 'application/json'], json_encode(array_filter($token->getData(), function ($k) { + return new Response(200, [ + 'content-type' => 'application/json', + 'cache-control' => 'no-store', + ], json_encode(array_filter($token->getData(), function ($k) { return in_array($k, ['me', 'profile']); }, ARRAY_FILTER_USE_KEY))); } @@ -501,10 +525,13 @@ class Server { } // Return a redirect to the client app. - return new Response(302, ['Location' => appendQueryParams($queryParams['redirect_uri'], [ - 'code' => $authCode->getKey(), - 'state' => $code['state'] - ])]); + return new Response(302, [ + 'Location' => appendQueryParams($queryParams['redirect_uri'], [ + 'code' => $authCode->getKey(), + 'state' => $code['state'] + ]), + 'Cache-control' => 'no-cache' + ]); } // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. @@ -566,7 +593,8 @@ class Server { } // Present the authorization UI. - return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp); + return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp) + ->withAddedHeader('Cache-control', 'no-cache'); } } @@ -670,7 +698,10 @@ class Server { } // If everything checks out, generate an access token and return it. - return new Response(200, ['content-type' => 'application/json'], json_encode(array_merge([ + return new Response(200, [ + 'content-type' => 'application/json', + 'cache-control' => 'no-store' + ], json_encode(array_merge([ 'access_token' => $token->getKey(), 'token_type' => 'Bearer' ], array_filter($token->getData(), function ($k) { diff --git a/src/functions.php b/src/functions.php index feca81c..0ec294d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -25,6 +25,15 @@ function generateRandomString($numBytes) { return bin2hex($bytes); } +function generateRandomPrintableAsciiString(int $length) { + $chars = []; + while (count($chars) < $length) { + // 0x21 to 0x7E is the entire printable ASCII range, not including space (0x20). + $chars[] = chr(random_int(0x21, 0x7E)); + } + return join('', $chars); +} + function generatePKCECodeChallenge($plaintext) { return base64_urlencode(hash('sha256', $plaintext, true)); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 4c78f0c..250db7f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -352,7 +352,8 @@ EOT ]); $res = $s->handleAuthorizationEndpointRequest($req); - + + $this->assertEquals('no-cache', $res->getHeaderLine('cache-control')); $this->assertEquals(200, $res->getStatusCode()); } @@ -381,6 +382,7 @@ EOT $res = $s->handleAuthorizationEndpointRequest($req); + $this->assertEquals('no-cache', $res->getHeaderLine('cache-control')); $this->assertEquals(200, $res->getStatusCode()); } @@ -406,6 +408,7 @@ EOT $res = $s->handleAuthorizationEndpointRequest($req); + $this->assertEquals('no-cache', $res->getHeaderLine('cache-control')); $this->assertEquals(200, $res->getStatusCode()); } @@ -508,6 +511,7 @@ EOT $res = $s->handleAuthorizationEndpointRequest($req); + $this->assertEquals('no-cache', $res->getHeaderLine('cache-control')); $this->assertEquals(302, $res->getStatusCode(), 'The Response from a successful approval request must be a 302 redirect.'); $responseLocation = $res->getHeaderLine('location'); @@ -686,6 +690,7 @@ EOT $res = $s->handleAuthorizationEndpointRequest($req); $this->assertEquals(200, $res->getStatusCode()); + $this->assertEquals('no-store', $res->getHeaderLine('cache-control')); $resJson = json_decode((string) $res->getBody(), true); $this->assertEquals([ 'me' => 'https://me.example.com/', @@ -772,6 +777,7 @@ EOT $res = $s->handleTokenEndpointRequest($req); $this->assertEquals(200, $res->getStatusCode()); + $this->assertEquals('no-store', $res->getHeaderLine('cache-control')); $resJson = json_decode((string) $res->getBody(), true); $this->assertEquals(hash_hmac('sha256', $authCode->getKey(), SERVER_SECRET), $resJson['access_token']); $this->assertEquals('Bearer', $resJson['token_type']);