Created default authorization and authentication callbacks

* Created corresponding templates
* Changed how Server configuration works
* Ensured that rauthorization approval requests verify their indieauth parameters
* Wrote first passing test for Server, fixed a variety of small errors along the way
This commit is contained in:
Barnaby Walters 2021-06-07 20:32:02 +02:00
parent 4d3a025296
commit b2c4f8eee5
11 changed files with 382 additions and 74 deletions

View File

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth\Callback;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Authorization Form Interface
*/
interface AuthorizationFormInterface {
/**
* Show Form
*
* This method is called once the IndieAuth Authorization Endpoint has confirmed that:
*
* * The current user is authenticated
* * The client app (client_id) has been fetched and is valid
* * The client app redirect_uri is known to be valid
*
* It should build an authorization form which the currently logged-in user can use
* to choose which scopes (if any) to grant the app.
*
* Information specific to the IndieAuth authorization request can be found in
* `$request->getQueryParams()`. The parameters most likely to be of use to the authorization
* form are:
*
* * `scope`: a space-separated list of scopes which the client app is requesting. May be absent.
* * `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 additionally include any CSRF tokens required to protect the submission.
* Refer to whatever CSRF protection code youre using (e.g. `\Taproot\IndieAuth\Middleware\DoubleSubmitCookieCsrfMiddleware`)
* and make sure to include the required element. This will usually involve getting a
* CSRF token with `$request->getAttribute()` and including it in an `<input type="hidden" …/>`.
*
* The form SHOULD present
*
* @param ServerRequestInterface $request The current request.
* @param array $authenticationResult The array returned from the Authentication Handler. Guaranteed to contain a 'me' key, may also contain additional keys e.g. 'profile'.
* @param string $formAction The URL which your form MUST submit to. Can also be used as the redirect URL for a logout process.
* @param array|null $clientHApp If available, the microformats-2 structure representing the client app.
* @return ResponseInterface A response containing the authorization form.
*/
public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface;
/**
* Transform Authorization Code
*
*
*
* @param array $code The base authorization code data, to be added to.
* @param ServerRequestInterface $request The current request.
* @return array The $code argument with any necessary changes.
*/
public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array;
}

View File

@ -0,0 +1,72 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth\Callback;
use BarnabyWalters\Mf2 as M;
use Psr\Http\Message\ServerRequestInterface;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use function Taproot\IndieAuth\renderTemplate;
class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwareInterface {
public string $csrfKey;
public string $formTemplatePath;
public LoggerInterface $logger;
public function __construct(?string $formTemplatePath=null, ?string $csrfKey=null, ?LoggerInterface $logger=null) {
$this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../templates/default_authorization_page.html.php';
$this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
$this->logger = $logger ?? new NullLogger;
}
public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface {
// Show an authorization page. List all requested scopes, as this default
// function has now way of knowing which scopes are supported by the consumer.
$scopes = [];
foreach(explode(' ', $request->getQueryParams()['scope'] ?? '') as $s) {
$scopes[$s] = null; // Ideally there would be a description of the scope here, we dont have one though.
}
$hApp = [
'name' => M\getProp($clientHApp, 'name'),
'url' => M\getProp($clientHApp, 'url'),
'photo' => M\getProp($clientHApp, 'photo')
];
return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplatePath, [
'scopes' => $scopes,
'user' => $authenticationResult,
'formAction' => $formAction,
'request' => $request,
'clientHApp' => $hApp,
'clientId' => $request->getQueryParams()['client_id'],
'clientRedirectUri' => $request->getQueryParams()['redirect_uri'],
'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />'
]));
}
public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array {
// Add any granted scopes from the form to the code.
$grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope[]'] ?? [];
// This default implementation naievely accepts any scopes it receives from the form.
// You may wish to perform some sort of validation.
$code['scope'] = join(' ', $grantedScopes);
// You may wish to additionally make any other necessary changes to the the code based on
// the form submission, e.g. if the user set a custom token lifetime, or wanted extra data
// stored on the token to affect how it behaves.
return $code;
}
public function setLogger(LoggerInterface $logger) {
$this->logger = $logger;
}
}

View File

@ -0,0 +1,43 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth\Callback;
use Exception;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface;
use function Taproot\IndieAuth\renderTemplate;
class SingleUserPasswordAuthenticationCallback {
const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password';
public string $csrfKey;
public string $formTemplate;
protected array $user;
protected string $hashedPassword;
public function __construct(array $user, string $hashedPassword, ?string $csrfKey=null, ?string $formTemplate=null) {
if (!array_key_exists('me', $user) || !is_string($user['me'])) {
throw new Exception('The $user array MUST contain a “me” key, the value which must be the users canonical URL as a string.');
}
$this->user = $user;
$this->hashedPassword = $hashedPassword;
$this->formTemplate = $formTemplate ?? __DIR__ . '/../templates/single_user_password_authentication_form.html.php';
$this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
}
public function __invoke(ServerRequestInterface $request, string $formAction) {
// If the request is a form submission with a matching password, return the corresponding
// user data.
if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) {
return $this->user;
}
// Otherwise, return a response containing the password form.
return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [
'formAction' => $formAction,
'request' => $request,
'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />'
]));
}
}

View File

@ -13,6 +13,13 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use function Taproot\IndieAuth\generateRandomString; use function Taproot\IndieAuth\generateRandomString;
/**
* Development reference
*
* CSRF protection cheat sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
* Example CSRF protection cookie middleware: https://github.com/zakirullin/csrf-middleware/blob/master/src/CSRF.php
*/
class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwareInterface { class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwareInterface {
const READ_METHODS = ['HEAD', 'GET', 'OPTIONS']; const READ_METHODS = ['HEAD', 'GET', 'OPTIONS'];
const TTL = 60 * 20; const TTL = 60 * 20;

View File

@ -8,6 +8,7 @@ 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 PHPUnit\Framework\Constraint\Callback;
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;
@ -17,8 +18,9 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Taproot\IndieAuth\Callback\AuthorizationFormInterface;
use function PHPSTORM_META\type; use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
use Taproot\IndieAuth\Middleware\ResponseRequestHandler;
/** /**
* Development Reference * Development Reference
@ -26,15 +28,12 @@ use function PHPSTORM_META\type;
* Specification: https://indieauth.spec.indieweb.org/ * Specification: https://indieauth.spec.indieweb.org/
* Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 * Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
* indieweb/indieauth-client: https://github.com/indieweb/indieauth-client-php * indieweb/indieauth-client: https://github.com/indieweb/indieauth-client-php
* CSRF protection cheat sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
* Example CSRF protection cookie middleware: https://github.com/zakirullin/csrf-middleware/blob/master/src/CSRF.php
*/ */
class Server { class Server {
const CUSTOMIZE_AUTHORIZATION_CODE = 'customise_authorization_code'; const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback';
const SHOW_AUTHORIZATION_PAGE = 'show_authorization_page'; const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
const HANDLE_NON_INDIEAUTH_REQUEST = 'handle_non_indieauth_request'; const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
const HANDLE_AUTHENTICATION_REQUEST = 'handle_authentication_request';
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf'; const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
public $callbacks; public $callbacks;
@ -43,77 +42,60 @@ class Server {
public Storage\TokenStorageInterface $accessTokenStorage; public Storage\TokenStorageInterface $accessTokenStorage;
public AuthorizationFormInterface $authorizationForm;
public MiddlewareInterface $csrfMiddleware; public MiddlewareInterface $csrfMiddleware;
public LoggerInterface $logger; public LoggerInterface $logger;
public HttpClientInterface $httpClient; public HttpClientInterface $httpClient;
public callable $httpGetWithEffectiveUrl; public $httpGetWithEffectiveUrl;
public string $csrfKey; public $handleAuthenticationRequestCallback;
public $handleNonIndieAuthRequest;
protected string $secret;
public function __construct(array $config) { public function __construct(array $config) {
$config = array_merge_recursive([ $config = array_merge([
'csrfMiddleware' => null, 'csrfMiddleware' => null,
'csrfKey' => self::DEFAULT_CSRF_KEY,
'logger' => null, 'logger' => null,
'callbacks' => [
self::CUSTOMIZE_AUTHORIZATION_CODE => function (array $code, ServerRequestInterface $request) {
// Configure the access code based on the authorization form parameters submitted in $request;
// TODO: that, based on the default authorization form.
return $code;
},
self::SHOW_AUTHORIZATION_PAGE => function (ServerRequestInterface $request, array $authenticationResult, string $authenticationRedirect, ?array $clientHApp) {
// Default implementation: show an authorization page. List all requested scopes, as this default
// function has now way of knowing which scopes are supported by the consumer.
$scopes = [];
foreach(explode(' ', $request->getQueryParams()['scope'] ?? '') as $s) {
$scopes[$s] = null; // Ideally there would be a description of the scope here, we dont have one though.
}
$templatePath = __DIR__ . '/templates/default_authorization_page.html.php';
$hApp = [
'name' => M\getProp($clientHApp, 'name'),
'url' => M\getProp($clientHApp, 'url'),
'photo' => M\getProp($clientHApp, 'photo')
];
return new Response(200, ['content-type' => 'text/html'], renderTemplate($templatePath, [
'scopes' => $scopes,
'user' => $authenticationResult,
'formAction' => $authenticationRedirect,
'request' => $request,
'clientHApp' => $hApp,
'clientId' => $request->getQueryParams()['client_id'],
'clientRedirectUri' => $request->getQueryParams()['redirect_uri'],
'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />'
]));
},
self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op. self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
],
'authorizationCodeStorage' => null, 'authorizationCodeStorage' => null,
'accessTokenStorage' => null, 'accessTokenStorage' => null,
'httpGetWithEffectiveUrl' => null 'httpGetWithEffectiveUrl' => null,
'authorizationForm' => new DefaultAuthorizationForm(),
], $config); ], $config);
if (!$config['logger'] instanceof LoggerInterface) { $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.");
}
$this->secret = $secret;
if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) {
throw new Exception("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null."); throw new Exception("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null.");
} }
$this->logger = $config['logger'] ?? new NullLogger(); $this->logger = $config['logger'] ?? new NullLogger();
$callbacks = $config['callbacks']; if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $callbacks) and is_callable($callbacks[self::HANDLE_AUTHENTICATION_REQUEST]))) {
throw new Exception('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.'); throw new Exception('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
} }
$this->callbacks = $callbacks; $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST];
if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) {
throw new Exception("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable");
}
$this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST];
$authorizationCodeStorage = $config['authorizationCodeStorage']; $authorizationCodeStorage = $config['authorizationCodeStorage'];
if (!$authorizationCodeStorage instanceof Storage\TokenStorageInterface) { if (!$authorizationCodeStorage instanceof Storage\TokenStorageInterface) {
if (is_string($authorizationCodeStorage)) { if (is_string($authorizationCodeStorage)) {
$authorizationCodeStorage = new Storage\FilesystemJsonStorage($authorizationCodeStorage, 600, true); $authorizationCodeStorage = new Storage\FilesystemJsonStorage($authorizationCodeStorage, 600, true);
} else { } else {
throw new Exception('$authorizationCodeStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.'); throw new Exception("\$config['authorizationCodeStorage'] must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.");
} }
} }
trySetLogger($authorizationCodeStorage, $this->logger); trySetLogger($authorizationCodeStorage, $this->logger);
@ -131,12 +113,10 @@ class Server {
trySetLogger($accessTokenStorage, $this->logger); trySetLogger($accessTokenStorage, $this->logger);
$this->accessTokenStorage = $accessTokenStorage; $this->accessTokenStorage = $accessTokenStorage;
$this->csrfKey = $config['csrfKey'];
$csrfMiddleware = $config['csrfMiddleware']; $csrfMiddleware = $config['csrfMiddleware'];
if (!$csrfMiddleware instanceof MiddlewareInterface) { if (!$csrfMiddleware instanceof MiddlewareInterface) {
// Default to the statless Double-Submit Cookie CSRF Middleware, with default settings. // Default to the statless Double-Submit Cookie CSRF Middleware, with default settings.
$csrfMiddleware = new Middleware\DoubleSubmitCookieCsrfMiddleware($this->csrfKey); $csrfMiddleware = new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY);
} }
trySetLogger($csrfMiddleware, $this->logger); trySetLogger($csrfMiddleware, $this->logger);
$this->csrfMiddleware = $csrfMiddleware; $this->csrfMiddleware = $csrfMiddleware;
@ -165,6 +145,12 @@ class Server {
} }
trySetLogger($httpGetWithEffectiveUrl, $this->logger); trySetLogger($httpGetWithEffectiveUrl, $this->logger);
$this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl; $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl;
if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) {
throw new Exception("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm.");
}
$this->authorizationForm = $config['authorizationForm'];
trySetLogger($this->authorizationForm, $this->logger);
} }
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface { public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
@ -213,10 +199,15 @@ class Server {
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
} }
// Build a URL for the authentication flow to redirect to, if it needs to. // Build a URL containing the indieauth authorization request parameters, hashing them
// TODO: perhaps filter queryParams to only include the indieauth-relevant params? // to protect them from being changed.
$authenticationRedirect = $request->getUri() . '?' . buildQueryString($queryParams); // Make a hash of the protected indieauth-specific parameters.
$hash = hashAuthorizationRequestParameters($request, $this->secret);
$queryParams[self::HASH_QUERY_STRING_KEY] = $hash;
$authenticationRedirect = $request->getUri()->withQuery(buildQueryString($queryParams));
// User-facing requests always start by calling the authentication request callback.
$this->logger->info('Calling handle_authentication_request callback'); $this->logger->info('Calling handle_authentication_request callback');
$authenticationResult = call_user_func($this->callbacks[self::HANDLE_AUTHENTICATION_REQUEST], $request, $authenticationRedirect); $authenticationResult = call_user_func($this->callbacks[self::HANDLE_AUTHENTICATION_REQUEST], $request, $authenticationRedirect);
@ -286,6 +277,22 @@ class Server {
// If this is a POST request sent from the authorization (i.e. scope-choosing) form: // If this is a POST request sent from the authorization (i.e. scope-choosing) form:
if (isAuthorizationApprovalRequest($request)) { if (isAuthorizationApprovalRequest($request)) {
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
// authorization request parameters from being changed, e.g. by a malicious script which
// found its way onto the authorization form.
$expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
if (is_null($expectedHash)) {
$this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
return new Response(400, ['content-type' => 'text/plain'], 'The ' . self::HASH_QUERY_STRING_KEY . ' parameter was missing!');
}
if (!hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
$this->logger->warning("The hash provided in the URL was invalid!", [
'expected' => $expectedHash,
'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
]);
return new Response(400, ['content-type' => 'text/plain'], 'Invalid hash!');
}
// Assemble the data for the authorization code, store it somewhere persistent. // Assemble the data for the authorization code, store it somewhere persistent.
$code = array_merge($authenticationResult, [ $code = array_merge($authenticationResult, [
'client_id' => $queryParams['client_id'], 'client_id' => $queryParams['client_id'],
@ -298,8 +305,8 @@ class Server {
]); ]);
// Pass it to the auth code customisation callback, if any. // Pass it to the auth code customisation callback, if any.
$code = call_user_func($this->callbacks[self::CUSTOMIZE_AUTHORIZATION_CODE], $code, $request);
$code = call_user_func($this->callbacks[self::HANDLE_AUTHORIZATION_FORM], $request, $code);
// Store the authorization code. // Store the authorization code.
$this->authorizationCodeStorage->put($code['code'], $code); $this->authorizationCodeStorage->put($code['code'], $code);
@ -313,19 +320,17 @@ class Server {
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
// Present the authorization UI. // Present the authorization UI.
return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_PAGE], $request, $authenticationResult, $authenticationRedirect, $clientHApp); return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_FORM], $request, $authenticationResult, $authenticationRedirect, $clientHApp);
} }
} }
// If the request isnt an IndieAuth Authorization or Code-redeeming request, its either an invalid // If the request isnt an IndieAuth Authorization or Code-redeeming request, its either an invalid
// request or something to do with a custom auth handler (e.g. sending a one-time code in an email.) // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
$nonIndieAuthRequestResult = call_user_func($this->callbacks[self::HANDLE_NON_INDIEAUTH_REQUEST], $request); $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
if ($nonIndieAuthRequestResult instanceof ResponseInterface) { if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
return $nonIndieAuthRequestResult; return $nonIndieAuthRequestResult;
} else { } else {
return new Response(400, ['content-type' => 'application/json'], json_encode([ return new Response(400, ['content-type' => 'text/plain'], 'Invalid request!');
'error' => 'invalid_request'
]));
} }
})); }));
} }

View File

@ -12,6 +12,8 @@ class FilesystemJsonStorage implements TokenStorageInterface {
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->ttl = $ttl; $this->ttl = $ttl;
@mkdir($this->path, 0777, true);
if ($cleanUpNow) { if ($cleanUpNow) {
$this->cleanUp(); $this->cleanUp();
} }
@ -22,8 +24,8 @@ class FilesystemJsonStorage implements TokenStorageInterface {
$deleted = 0; $deleted = 0;
// A TTL of 0 means the token should live until deleted, and negative TTLs are invalid. // A TTL of 0 means the token should live until deleted. A negative TTLs means “delete everything”.
if ($ttl > 0) { if ($ttl !== 0) {
foreach (new DirectoryIterator($this->path) as $fileInfo) { foreach (new DirectoryIterator($this->path) as $fileInfo) {
if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json' && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) { if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json' && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) {
unlink($fileInfo->getPathname()); unlink($fileInfo->getPathname());

View File

@ -21,6 +21,21 @@ function generateRandomString($numBytes) {
return bin2hex($bytes); return bin2hex($bytes);
} }
function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null): ?string {
$hashedParameters = $hashedParameters ?? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'];
$algo = $algo ?? 'sha256';
$queryParams = $request->getQueryParams();
$data = '';
foreach ($hashedParameters as $key) {
if (!array_key_exists($key, $queryParams)) {
return null;
}
$data .= $queryParams[$key];
}
return hash_hmac($algo, $data, $secret);
}
function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request) { function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request) {
return strtolower($request->getMethod()) == 'post' return strtolower($request->getMethod()) == 'post'
&& array_key_exists('grant_type', $request->getParsedBody()) && array_key_exists('grant_type', $request->getParsedBody())

View File

@ -77,6 +77,7 @@
<p>The app has requested the following scopes. You may choose which to grant it.</p> <p>The app has requested the following scopes. You may choose which to grant it.</p>
<ul class="scope-list"> <ul class="scope-list">
<!-- Loop through $scopes, which maps string $scope to ?string $description by default. -->
<?php foreach ($scopes as $scope => $description): ?> <?php foreach ($scopes as $scope => $description): ?>
<li class="scope-list-item"> <li class="scope-list-item">
<label> <label>
@ -94,14 +95,25 @@
<?php endif ?> <?php endif ?>
</div> </div>
<!-- Youre welcome to add addition UI for the user to customise the properties of the granted
access token (e.g. lifetime), just make sure you adapt the transformAuthorizationCode
function to handle them. -->
<div class="submit-section"> <div class="submit-section">
<p>After approving, you will be redirected to <span class="inline-url"><?= htmlentities($clientRedirectUri) ?></span>.</p> <p>After approving, you will be redirected to <span class="inline-url"><?= htmlentities($clientRedirectUri) ?></span>.</p>
<p> <p>
<a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to app)</a> <!-- Forms should give the user a chance to cancel the authorization. This usually involves linking them back to the app they came from. -->
<a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to <?= $clientHApp['name'] ?? 'app' ?>)</a>
<!-- Your form MUST be submitted with taproot_indieauth_action=approve for the approval submission to work. -->
<button type="submit" name="taproot_indieauth_action" value="approve">Authorize</button> <button type="submit" name="taproot_indieauth_action" value="approve">Authorize</button>
</p> </p>
</div> </div>
</form> </form>
<footer>
<small>Powered by <a href="https://taprootproject.com">taproot/indieauth</a></small>
</footer>
</body> </body>
</html> </html>

View File

@ -0,0 +1,32 @@
<?php
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var string $csrfFormElement A pre-rendered CSRF form element which must be output inside the authorization form. */
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>IndieAuth Log In</title>
<style>
</style>
</head>
<body>
<form method="post" action="<?= $formAction ?>">
<?= $csrfFormElement ?>
<h1>Log In</h1>
<p><input type="password" name="<?= \Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback::PASSWORD_FORM_PARAMETER ?>" /></p>
<p><button type="submit">Log In</button></p>
</form>
<footer>
<small>Powered by <a href="https://taprootproject.com">taproot/indieauth</a></small>
</footer>
</body>
</html>

View File

@ -2,6 +2,7 @@
namespace Taproot\IndieAuth\Test; namespace Taproot\IndieAuth\Test;
use GuzzleHttp\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Taproot\IndieAuth as IA; use Taproot\IndieAuth as IA;
@ -35,4 +36,24 @@ class FunctionTest extends TestCase {
$this->assertEquals($expected, IA\appendQueryParams($uri, $params)); $this->assertEquals($expected, IA\appendQueryParams($uri, $params));
} }
} }
public function testHashAuthorizationRequestParametersReturnsNullWhenParameterIsMissing() {
$req = (new ServerRequest('GET', 'https://example.com'))->withQueryParams([]);
$hash = IA\hashAuthorizationRequestParameters($req, 'super secret');
$this->assertNull($hash);
}
public function testHashAuthorizationRequestParametersIgnoresExtraParameters() {
$params = [
'client_id' => '1',
'redirect_uri' => '1',
'code_challenge' => '1',
'code_challenge_method' => '1'
];
$req1 = (new ServerRequest('GET', 'https://example.com'))->withQueryParams($params);
$req2 = (new ServerRequest('GET', 'https://example.com'))->withQueryParams(array_merge($params, [
'an_additional_parameter' => 'an additional value!'
]));
$this->assertEquals(IA\hashAuthorizationRequestParameters($req1, 'super secret'), IA\hashAuthorizationRequestParameters($req2, 'super secret'));
}
} }

View File

@ -2,9 +2,48 @@
namespace Taproot\IndieAuth\Test; namespace Taproot\IndieAuth\Test;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
use Taproot\IndieAuth\Server; use Taproot\IndieAuth\Server;
use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111';
const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
const TMP_DIR = __DIR__ . '/tmp';
class ServerTest extends TestCase { class ServerTest extends TestCase {
protected function getDefaultServer() {
return new Server([
'secret' => SERVER_SECRET,
'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH,
'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH,
Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT))
]);
}
protected function setUp(): void {
// Clean up tmp folder.
new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true);
new FilesystemJsonStorage(ACCESS_TOKEN_STORAGE_PATH, -1, true);
@rmdir(AUTH_CODE_STORAGE_PATH);
@rmdir(ACCESS_TOKEN_STORAGE_PATH);
}
protected function tearDown(): void {
// Clean up tmp folder.
new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true);
new FilesystemJsonStorage(ACCESS_TOKEN_STORAGE_PATH, -1, true);
@rmdir(AUTH_CODE_STORAGE_PATH);
@rmdir(ACCESS_TOKEN_STORAGE_PATH);
}
public function testAuthorizationRequestMissingParametersReturnsError() {
$s = $this->getDefaultServer();
$req = (new ServerRequest('GET', 'https://example.com/'));
$res = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(400, $res->getStatusCode());
}
} }