Required cache-control headers on more responses

This commit is contained in:
Barnaby Walters 2021-06-12 20:08:16 +02:00
parent f66473cc53
commit a0fe1b5f80
6 changed files with 96 additions and 33 deletions

View File

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

View File

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

View File

@ -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)) {

View File

@ -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 its 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 doesnt 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) {

View File

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

View File

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