Initial commit
Sketched out first draft of how the library should work, stubbed a lot of the smaller utility classes required, and outlined the main handler functions for the IA Server.
This commit is contained in:
commit
8ab57bee25
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
vendor
|
24
composer.json
Normal file
24
composer.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "taproot/indieauth",
|
||||
"description": "PHP PSR-7-compliant IndieAuth Server and Client implementation.",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barnaby Walters",
|
||||
"email": "barnaby@waterpigs.co.uk"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/log": "^1.1",
|
||||
"nyholm/psr7": "^1.4",
|
||||
"indieauth/client": "^1.1",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"dflydev/fig-cookies": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.3"
|
||||
}
|
||||
}
|
1067
composer.lock
generated
Normal file
1067
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
src/Taproot/IndieAuth/ClosureRequestHandler.php
Normal file
22
src/Taproot/IndieAuth/ClosureRequestHandler.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class ClosureRequestHandler implements RequestHandlerInterface {
|
||||
protected callable $callable;
|
||||
|
||||
protected array $args;
|
||||
|
||||
public function __construct(callable $callable) {
|
||||
$this->callable = $callable;
|
||||
$this->args = array_slice(func_get_args(), 1);
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface {
|
||||
return call_user_func_array($this->callable, array_merge([$request], $this->args));
|
||||
}
|
||||
}
|
92
src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php
Normal file
92
src/Taproot/IndieAuth/DoubleSubmitCookieCsrfMiddleware.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
use Nyholm\Psr7\Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Dflydev\FigCookies;
|
||||
|
||||
// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
|
||||
function generateRandomString($numBytes) {
|
||||
if (function_exists('random_bytes')) {
|
||||
$bytes = random_bytes($numBytes);
|
||||
} elseif (function_exists('openssl_random_pseudo_bytes')){
|
||||
$bytes = openssl_random_pseudo_bytes($numBytes);
|
||||
} else {
|
||||
$bytes = '';
|
||||
for($i=0, $bytes=''; $i < $numBytes; $i++) {
|
||||
$bytes .= chr(mt_rand(0, 255));
|
||||
}
|
||||
}
|
||||
return bin2hex($bytes);
|
||||
}
|
||||
|
||||
class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface {
|
||||
const READ_METHODS = ['HEAD', 'GET', 'OPTIONS'];
|
||||
const TTL = 60 * 20;
|
||||
const ATTRIBUTE = 'csrf';
|
||||
const DEFAULT_ERROR_RESPONSE_STRING = 'Invalid or missing CSRF token!';
|
||||
const CSRF_TOKEN_LENGTH = 128;
|
||||
|
||||
public string $attribute;
|
||||
|
||||
public int $ttl;
|
||||
|
||||
public callable $errorResponse;
|
||||
|
||||
public int $tokenLength;
|
||||
|
||||
public function __construct(string $attribute=self::ATTRIBUTE, int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH) {
|
||||
$this->attribute = $attribute;
|
||||
$this->ttl = $ttl;
|
||||
$this->tokenLength = $tokenLength;
|
||||
|
||||
if (!is_callable($errorResponse)) {
|
||||
if (!$errorResponse instanceof ResponseInterface) {
|
||||
if (!is_string($errorResponse)) {
|
||||
$errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING;
|
||||
}
|
||||
$errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse);
|
||||
}
|
||||
$errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; };
|
||||
}
|
||||
$this->errorResponse = $errorResponse;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
|
||||
if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS)) {
|
||||
// This request is a write method and requires CSRF protection.
|
||||
if (!$this->isValid($request)) {
|
||||
return call_user_func($this->errorResponse, $request);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, generate a new CSRF token, add it to the request attributes, and as a cookie on the response.
|
||||
$csrfToken = generateRandomString($this->tokenLength);
|
||||
$request = $request->withAttribute($this->attribute, $csrfToken);
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
// 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()));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function isValid(ServerRequestInterface $request) {
|
||||
if (in_array($this->attribute, $request->getParsedBody())) {
|
||||
if (in_array($this->attribute, $request->getCookieParams())) {
|
||||
return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
68
src/Taproot/IndieAuth/FilesystemJsonStorage.php
Normal file
68
src/Taproot/IndieAuth/FilesystemJsonStorage.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
use DirectoryIterator;
|
||||
|
||||
class FilesystemJsonStorage implements TokenStorageInterface {
|
||||
protected $path;
|
||||
protected $ttl;
|
||||
|
||||
public function __construct(string $path, $ttl=0, $cleanUpNow=false) {
|
||||
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$this->ttl = $ttl;
|
||||
|
||||
if ($cleanUpNow) {
|
||||
$this->cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanUp($ttl=null): int {
|
||||
$ttl = $ttl ?? $this->ttl;
|
||||
|
||||
$deleted = 0;
|
||||
|
||||
// A TTL of 0 means the token should live until deleted, and negative TTLs are invalid.
|
||||
if ($ttl >= 0) {
|
||||
foreach (new DirectoryIterator($this->path) as $fileInfo) {
|
||||
if ($fileInfo->isFile() && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) {
|
||||
unlink($fileInfo->getPathname());
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
public function get(string $key): ?array {
|
||||
$path = $this->getPath($key);
|
||||
if (file_exists($path)) {
|
||||
$result = json_decode(file_get_contents($path), true);
|
||||
|
||||
if (is_array($result)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function put(string $key, array $data): bool {
|
||||
// Ensure that the containing folder exists.
|
||||
mkdir($this->path, 0777, true);
|
||||
|
||||
return file_put_contents($this->getPath($key), json_encode($data)) !== false;
|
||||
}
|
||||
|
||||
public function delete(string $key): bool {
|
||||
if (file_exists($this->getPath($key))) {
|
||||
return unlink($this->getPath($key));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getPath(string $key): string {
|
||||
return $this->path . "$key.json";
|
||||
}
|
||||
}
|
14
src/Taproot/IndieAuth/NoOpMiddleware.php
Normal file
14
src/Taproot/IndieAuth/NoOpMiddleware.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class NoOpMiddleware implements MiddlewareInterface {
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
206
src/Taproot/IndieAuth/Server.php
Normal file
206
src/Taproot/IndieAuth/Server.php
Normal file
@ -0,0 +1,206 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
use Exception;
|
||||
use Nyholm\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use IndieAuth\Client as IndieAuthClient;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
|
||||
/**
|
||||
* Development Reference
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
// TODO: maybe move these to a functions file so they’re usable by consumers even when the class isn’t loaded.
|
||||
// Alternatively, make them static methods so they can be autoloaded.
|
||||
function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request) {
|
||||
return strtolower($request->getMethod()) == 'post'
|
||||
&& array_key_exists('grant_type', $request->getParsedBody())
|
||||
&& $request->getParsedBody()['grant_type'] == 'authorization_code';
|
||||
}
|
||||
|
||||
function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, $permittedMethods=['get']) {
|
||||
return in_array(strtolower($request->getMethod()), array_map('strtolower', $permittedMethods))
|
||||
&& array_key_exists('response_type', $request->getQueryParams())
|
||||
&& $request->getQueryParams()['response_type'] == 'code';
|
||||
}
|
||||
|
||||
function isAuthorizationApprovalRequest(ServerRequestInterface $request) {
|
||||
return strtolower($request->getMethod()) == 'post'
|
||||
&& array_key_exists('taproot_indieauth_action', $request->getParsedBody())
|
||||
&& $request->getParsedBody()['taproot_indieauth_action'] == 'approve';
|
||||
}
|
||||
|
||||
function buildQueryString(array $parameters) {
|
||||
$qs = [];
|
||||
foreach ($parameters as $k => $v) {
|
||||
$qs[] = urlencode($k) . '=' . urlencode($v);
|
||||
}
|
||||
return join('&', $qs);
|
||||
}
|
||||
|
||||
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 DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
|
||||
|
||||
public $callbacks;
|
||||
|
||||
public TokenStorageInterface $authorizationCodeStorage;
|
||||
|
||||
public TokenStorageInterface $accessTokenStorage;
|
||||
|
||||
public MiddlewareInterface $csrfMiddleware;
|
||||
|
||||
public LoggerInterface $logger;
|
||||
|
||||
public string $csrfKey;
|
||||
|
||||
public function __construct(array $callbacks, $authorizationCodeStorage, $accessTokenStorage, $csrfMiddleware=null, string $csrfKey=self::DEFAULT_CSRF_KEY, LoggerInterface $logger=null) {
|
||||
$callbacks = array_merge([
|
||||
self::CUSTOMIZE_AUTHORIZATION_CODE => function (array $code) { return $code; }, // Default to no-op.
|
||||
self::SHOW_AUTHORIZATION_PAGE => function (ServerRequestInterface $request, array $authenticationResult, string $authenticationRedirect) { }, // TODO: Put the default implementation here.
|
||||
self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
|
||||
], $callbacks);
|
||||
|
||||
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.');
|
||||
}
|
||||
$this->callbacks = $callbacks;
|
||||
|
||||
if (!$authorizationCodeStorage instanceof TokenStorageInterface) {
|
||||
if (is_string($authorizationCodeStorage)) {
|
||||
$authorizationCodeStorage = new FilesystemJsonStorage($authorizationCodeStorage, 600, true);
|
||||
} else {
|
||||
throw new Exception('$authorizationCodeStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.');
|
||||
}
|
||||
}
|
||||
$this->authorizationCodeStorage = $authorizationCodeStorage;
|
||||
|
||||
if (!$accessTokenStorage instanceof TokenStorageInterface) {
|
||||
if (is_string($accessTokenStorage)) {
|
||||
// Create a default access token storage with a TTL of 7 days.
|
||||
$accessTokenStorage = new FilesystemJsonStorage($accessTokenStorage, 60 * 60 * 24 * 7, true);
|
||||
} else {
|
||||
throw new Exception('$accessTokenStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.');
|
||||
}
|
||||
}
|
||||
$this->accessTokenStorage = $accessTokenStorage;
|
||||
|
||||
$this->csrfKey = $csrfKey;
|
||||
|
||||
if (!$csrfMiddleware instanceof MiddlewareInterface) {
|
||||
// Default to the statless Double-Submit Cookie CSRF Middleware, with default settings.
|
||||
$csrfMiddleware = new DoubleSubmitCookieCsrfMiddleware($this->csrfKey);
|
||||
}
|
||||
$this->csrfMiddleware = $csrfMiddleware;
|
||||
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
}
|
||||
|
||||
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||||
// If it’s a profile information request:
|
||||
if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
|
||||
// Verify that the authorization code is valid and has not yet been used.
|
||||
$this->authorizationCodeStorage->get($request->getParsedBody()['code']);
|
||||
|
||||
// Verify that it was issued for the same client_id and redirect_uri
|
||||
|
||||
// Check that the supplied code_verifier hashes to the stored code_challenge
|
||||
|
||||
// If everything checked out, return {"me": "https://example.com"} response
|
||||
// (a response containing any additional information must contain a valid scope value, and
|
||||
// be handled by the token_endpoint).
|
||||
// TODO: according to the spec, it is technically permitted for the authorization endpoint
|
||||
// to additional provide profile information. Leave it up to the library consumer to decide
|
||||
// whether to add it or not.
|
||||
}
|
||||
|
||||
// Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
|
||||
// gymnastics here to selectively-CSRF-protect requests which do need it.
|
||||
return $this->csrfMiddleware->process($request, new ClosureRequestHandler(function (ServerRequestInterface $request) {
|
||||
// If this is an authorization or approval request (allowing POST requests as well to accommodate
|
||||
// approval requests and custom auth form submission.
|
||||
if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
|
||||
// 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($request->getQueryParams());
|
||||
|
||||
$authenticationResult = call_user_func($this->callbacks[self::HANDLE_AUTHENTICATION_REQUEST], $request, $authenticationRedirect);
|
||||
|
||||
// If the authentication handler returned a Response, return that as-is.
|
||||
if ($authenticationResult instanceof Response) {
|
||||
return $authenticationResult;
|
||||
} elseif (is_array($authenticationResult)) {
|
||||
// Check the resulting array for errors.
|
||||
|
||||
// The user is logged in.
|
||||
|
||||
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
|
||||
if (isAuthorizationApprovalRequest($request)) {
|
||||
// Assemble the data for the authorization code, store it somewhere persistent.
|
||||
$code = [
|
||||
|
||||
];
|
||||
|
||||
// Pass it to the auth code customisation callback, if any.
|
||||
$code = call_user_func($this->callbacks[self::CUSTOMIZE_AUTHORIZATION_CODE], $code, $request);
|
||||
|
||||
// Store the authorization code.
|
||||
$this->authorizationCodeStorage->put($code['code'], $code);
|
||||
|
||||
// Return a redirect to the client app.
|
||||
}
|
||||
|
||||
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
|
||||
|
||||
// Fetch the client_id URL to find information about the client to present to the user.
|
||||
|
||||
// If the authority of the redirect_uri does not match the client_id or one of their redirect URLs, return an error.
|
||||
|
||||
// Present the authorization UI.
|
||||
return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_PAGE], $request, $authenticationResult, $authenticationRedirect);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
|
||||
return $nonIndieAuthRequestResult;
|
||||
} else {
|
||||
return new Response(400, ['content-type' => 'application/json'], json_encode([
|
||||
'error' => 'invalid_request'
|
||||
]));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||||
// This is a request to redeem an authorization_code for an access_token.
|
||||
|
||||
// Verify that the authorization code is valid and has not yet been used.
|
||||
|
||||
// Verify that it was issued for the same client_id and redirect_uri
|
||||
|
||||
// Check that the supplied code_verifier hashes to the stored code_challenge
|
||||
|
||||
// If the auth code was issued with no scope, return an error.
|
||||
|
||||
// If everything checks out, generate an access token and return it.
|
||||
}
|
||||
}
|
15
src/Taproot/IndieAuth/TokenStorageInterface.php
Normal file
15
src/Taproot/IndieAuth/TokenStorageInterface.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Taproot\IndieAuth;
|
||||
|
||||
// TODO: document.
|
||||
|
||||
interface TokenStorageInterface {
|
||||
public function cleanUp($ttl=null): int;
|
||||
|
||||
public function get(string $key): ?array;
|
||||
|
||||
public function put(string $key, array $data): bool;
|
||||
|
||||
public function delete(string $key): bool;
|
||||
}
|
Reference in New Issue
Block a user