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 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;
|
||||||
|
131
src/Server.php
131
src/Server.php
@ -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 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,
|
'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 isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
|
// 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.)
|
// 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'
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
|
@ -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())
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
<!-- 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">
|
<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>
|
||||||
|
@ -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;
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user