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:
parent
4d3a025296
commit
b2c4f8eee5
60
src/Callback/AuthorizationFormInterface.php
Normal file
60
src/Callback/AuthorizationFormInterface.php
Normal 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 you’re 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;
|
||||
}
|
72
src/Callback/DefaultAuthorizationForm.php
Normal file
72
src/Callback/DefaultAuthorizationForm.php
Normal 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 don’t 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;
|
||||
}
|
||||
}
|
43
src/Callback/SingleUserPasswordAuthenticationCallback.php
Normal file
43
src/Callback/SingleUserPasswordAuthenticationCallback.php
Normal 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 user’s 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)) . '" />'
|
||||
]));
|
||||
}
|
||||
}
|
@ -13,6 +13,13 @@ use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
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 {
|
||||
const READ_METHODS = ['HEAD', 'GET', 'OPTIONS'];
|
||||
const TTL = 60 * 20;
|
||||
@ -70,11 +77,11 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa
|
||||
|
||||
// Add the new CSRF cookie, restricting its scope to match the current request.
|
||||
$response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
|
||||
->withValue($csrfToken)
|
||||
->withMaxAge($this->ttl)
|
||||
->withSecure($request->getUri()->getScheme() == 'https')
|
||||
->withDomain($request->getUri()->getHost())
|
||||
->withPath($request->getUri()->getPath()));
|
||||
->withValue($csrfToken)
|
||||
->withMaxAge($this->ttl)
|
||||
->withSecure($request->getUri()->getScheme() == 'https')
|
||||
->withDomain($request->getUri()->getHost())
|
||||
->withPath($request->getUri()->getPath()));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
135
src/Server.php
135
src/Server.php
@ -8,6 +8,7 @@ use Mf2;
|
||||
use BarnabyWalters\Mf2 as M;
|
||||
use GuzzleHttp\Psr7\Header as HeaderParser;
|
||||
use Nyholm\Psr7\Response;
|
||||
use PHPUnit\Framework\Constraint\Callback;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||
use Psr\Http\Client\NetworkExceptionInterface;
|
||||
@ -17,8 +18,9 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
use function PHPSTORM_META\type;
|
||||
use Taproot\IndieAuth\Callback\AuthorizationFormInterface;
|
||||
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
|
||||
use Taproot\IndieAuth\Middleware\ResponseRequestHandler;
|
||||
|
||||
/**
|
||||
* Development Reference
|
||||
@ -26,15 +28,12 @@ use function PHPSTORM_META\type;
|
||||
* Specification: https://indieauth.spec.indieweb.org/
|
||||
* Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
|
||||
* 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 {
|
||||
const CUSTOMIZE_AUTHORIZATION_CODE = 'customise_authorization_code';
|
||||
const SHOW_AUTHORIZATION_PAGE = 'show_authorization_page';
|
||||
const HANDLE_NON_INDIEAUTH_REQUEST = 'handle_non_indieauth_request';
|
||||
const HANDLE_AUTHENTICATION_REQUEST = 'handle_authentication_request';
|
||||
const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback';
|
||||
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
|
||||
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
|
||||
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
|
||||
|
||||
public $callbacks;
|
||||
@ -43,77 +42,60 @@ class Server {
|
||||
|
||||
public Storage\TokenStorageInterface $accessTokenStorage;
|
||||
|
||||
public AuthorizationFormInterface $authorizationForm;
|
||||
|
||||
public MiddlewareInterface $csrfMiddleware;
|
||||
|
||||
public LoggerInterface $logger;
|
||||
|
||||
public HttpClientInterface $httpClient;
|
||||
|
||||
public callable $httpGetWithEffectiveUrl;
|
||||
public $httpGetWithEffectiveUrl;
|
||||
|
||||
public string $csrfKey;
|
||||
public $handleAuthenticationRequestCallback;
|
||||
|
||||
public $handleNonIndieAuthRequest;
|
||||
|
||||
protected string $secret;
|
||||
|
||||
public function __construct(array $config) {
|
||||
$config = array_merge_recursive([
|
||||
$config = array_merge([
|
||||
'csrfMiddleware' => null,
|
||||
'csrfKey' => self::DEFAULT_CSRF_KEY,
|
||||
'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 don’t 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,
|
||||
'accessTokenStorage' => null,
|
||||
'httpGetWithEffectiveUrl' => null
|
||||
'httpGetWithEffectiveUrl' => null,
|
||||
'authorizationForm' => new DefaultAuthorizationForm(),
|
||||
], $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.");
|
||||
}
|
||||
$this->logger = $config['logger'] ?? new NullLogger();
|
||||
|
||||
$callbacks = $config['callbacks'];
|
||||
if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $callbacks) and is_callable($callbacks[self::HANDLE_AUTHENTICATION_REQUEST]))) {
|
||||
if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
|
||||
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'];
|
||||
if (!$authorizationCodeStorage instanceof Storage\TokenStorageInterface) {
|
||||
if (is_string($authorizationCodeStorage)) {
|
||||
$authorizationCodeStorage = new Storage\FilesystemJsonStorage($authorizationCodeStorage, 600, true);
|
||||
} 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);
|
||||
@ -130,13 +112,11 @@ class Server {
|
||||
}
|
||||
trySetLogger($accessTokenStorage, $this->logger);
|
||||
$this->accessTokenStorage = $accessTokenStorage;
|
||||
|
||||
$this->csrfKey = $config['csrfKey'];
|
||||
|
||||
$csrfMiddleware = $config['csrfMiddleware'];
|
||||
if (!$csrfMiddleware instanceof MiddlewareInterface) {
|
||||
// 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);
|
||||
$this->csrfMiddleware = $csrfMiddleware;
|
||||
@ -165,6 +145,12 @@ class Server {
|
||||
}
|
||||
trySetLogger($httpGetWithEffectiveUrl, $this->logger);
|
||||
$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 {
|
||||
@ -213,10 +199,15 @@ class Server {
|
||||
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
|
||||
}
|
||||
|
||||
// Build a URL for the authentication flow to redirect to, if it needs to.
|
||||
// TODO: perhaps filter queryParams to only include the indieauth-relevant params?
|
||||
$authenticationRedirect = $request->getUri() . '?' . buildQueryString($queryParams);
|
||||
// Build a URL containing the indieauth authorization request parameters, hashing them
|
||||
// to protect them from being changed.
|
||||
// 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');
|
||||
$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 (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.
|
||||
$code = array_merge($authenticationResult, [
|
||||
'client_id' => $queryParams['client_id'],
|
||||
@ -298,8 +305,8 @@ class Server {
|
||||
]);
|
||||
|
||||
// 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.
|
||||
$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.
|
||||
|
||||
// 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 isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
|
||||
// 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) {
|
||||
return $nonIndieAuthRequestResult;
|
||||
} else {
|
||||
return new Response(400, ['content-type' => 'application/json'], json_encode([
|
||||
'error' => 'invalid_request'
|
||||
]));
|
||||
return new Response(400, ['content-type' => 'text/plain'], 'Invalid request!');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ class FilesystemJsonStorage implements TokenStorageInterface {
|
||||
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$this->ttl = $ttl;
|
||||
|
||||
@mkdir($this->path, 0777, true);
|
||||
|
||||
if ($cleanUpNow) {
|
||||
$this->cleanUp();
|
||||
}
|
||||
@ -22,8 +24,8 @@ class FilesystemJsonStorage implements TokenStorageInterface {
|
||||
|
||||
$deleted = 0;
|
||||
|
||||
// A TTL of 0 means the token should live until deleted, and negative TTLs are invalid.
|
||||
if ($ttl > 0) {
|
||||
// A TTL of 0 means the token should live until deleted. A negative TTLs means “delete everything”.
|
||||
if ($ttl !== 0) {
|
||||
foreach (new DirectoryIterator($this->path) as $fileInfo) {
|
||||
if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json' && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) {
|
||||
unlink($fileInfo->getPathname());
|
||||
|
@ -21,6 +21,21 @@ function generateRandomString($numBytes) {
|
||||
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) {
|
||||
return strtolower($request->getMethod()) == 'post'
|
||||
&& array_key_exists('grant_type', $request->getParsedBody())
|
||||
|
@ -77,6 +77,7 @@
|
||||
<p>The app has requested the following scopes. You may choose which to grant it.</p>
|
||||
|
||||
<ul class="scope-list">
|
||||
<!-- Loop through $scopes, which maps string $scope to ?string $description by default. -->
|
||||
<?php foreach ($scopes as $scope => $description): ?>
|
||||
<li class="scope-list-item">
|
||||
<label>
|
||||
@ -94,14 +95,25 @@
|
||||
<?php endif ?>
|
||||
</div>
|
||||
|
||||
<!-- You’re 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">
|
||||
<p>After approving, you will be redirected to <span class="inline-url"><?= htmlentities($clientRedirectUri) ?></span>.</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>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer>
|
||||
<small>Powered by <a href="https://taprootproject.com">taproot/indieauth</a></small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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>
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Taproot\IndieAuth\Test;
|
||||
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Taproot\IndieAuth as IA;
|
||||
|
||||
@ -35,4 +36,24 @@ class FunctionTest extends TestCase {
|
||||
$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'));
|
||||
}
|
||||
}
|
||||
|
@ -2,9 +2,48 @@
|
||||
|
||||
namespace Taproot\IndieAuth\Test;
|
||||
|
||||
use Nyholm\Psr7\ServerRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
|
||||
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 {
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user