Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.30% |
36 / 37 |
|
50.00% |
1 / 2 |
CRAP | |
0.00% |
0 / 1 |
| SingleUserPasswordAuthenticationCallback | |
97.30% |
36 / 37 |
|
50.00% |
1 / 2 |
12 | |
0.00% |
0 / 1 |
| __construct | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
7.01 | |||
| __invoke | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php declare(strict_types=1); |
| 2 | |
| 3 | namespace Taproot\IndieAuth\Callback; |
| 4 | |
| 5 | use BadMethodCallException; |
| 6 | use Dflydev\FigCookies; |
| 7 | use GuzzleHttp\Psr7\Response; |
| 8 | use Psr\Http\Message\ServerRequestInterface; |
| 9 | |
| 10 | use function Taproot\IndieAuth\renderTemplate; |
| 11 | |
| 12 | /** |
| 13 | * Single User Password Authentication Callback |
| 14 | * |
| 15 | * A simple example authentication callback which performs authentication itself rather |
| 16 | * than redirecting to an existing authentication flow. |
| 17 | * |
| 18 | * In some cases, it may make sense for your IndieAuth server to be able to authenticate |
| 19 | * users itself, rather than redirecting them to an existing authentication flow. This |
| 20 | * implementation provides a simple single-user password authentication method intended |
| 21 | * for bootstrapping and testing purposes. |
| 22 | * |
| 23 | * The sign-in form can be customised by making your own template and passing the path to |
| 24 | * the constructor. |
| 25 | * |
| 26 | * Minimal usage: |
| 27 | * |
| 28 | * ```php |
| 29 | * // One-off during app configuration: |
| 30 | * YOUR_HASHED_PASSWORD = password_hash('my super strong password', PASSWORD_DEFAULT); |
| 31 | * |
| 32 | * // In your app: |
| 33 | * use Taproot\IndieAuth; |
| 34 | * $server = new IndieAuth\Server([ |
| 35 | * … |
| 36 | * 'authenticationHandler' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback( |
| 37 | * YOUR_SECRET, |
| 38 | * ['me' => 'https://me.example.com/'], |
| 39 | * YOUR_HASHED_PASSWORD |
| 40 | * ) |
| 41 | * … |
| 42 | * ]); |
| 43 | * ``` |
| 44 | * |
| 45 | * See documentation for `__construct()` for information about customising behaviour. |
| 46 | */ |
| 47 | class SingleUserPasswordAuthenticationCallback { |
| 48 | const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; |
| 49 | const LOGIN_HASH_COOKIE = 'taproot_indieauth_server_supauth_hash'; |
| 50 | const DEFAULT_COOKIE_TTL = 60 * 5; |
| 51 | |
| 52 | /** @var string $csrfKey */ |
| 53 | public $csrfKey; |
| 54 | |
| 55 | /** @var callable $formTemplateCallable */ |
| 56 | protected $formTemplateCallable; |
| 57 | |
| 58 | /** @var array $user */ |
| 59 | protected $user; |
| 60 | |
| 61 | /** @var string $hashedPassword */ |
| 62 | protected $hashedPassword; |
| 63 | |
| 64 | /** @var string $secret */ |
| 65 | protected $secret; |
| 66 | |
| 67 | /** @var int $ttl */ |
| 68 | protected $ttl; |
| 69 | |
| 70 | /** |
| 71 | * Constructor |
| 72 | * |
| 73 | * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. |
| 74 | * @param array $user An array representing the user, which will be returned on a successful authentication. MUST include a 'me' key, may also contain a 'profile' key, or other keys at your discretion. |
| 75 | * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` |
| 76 | * @param string|callable|null $formTemplate The path to a template used to render the sign-in form, or a template callable with the signature `function (array $context): string`. Uses default if null. |
| 77 | * @param string|null $csrfKey The key under which to fetch a CSRF token from `$request` attributes, and as the CSRF token name in submitted form data. Defaults to the Server default, only change if you’re using a custom CSRF middleware. |
| 78 | * @param int|null $ttl The lifetime of the authentication cookie, in seconds. Defaults to five minutes. |
| 79 | */ |
| 80 | public function __construct(string $secret, array $user, string $hashedPassword, $formTemplate=null, ?string $csrfKey=null, ?int $ttl=null) { |
| 81 | if (strlen($secret) < 64) { |
| 82 | throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); |
| 83 | } |
| 84 | $this->secret = $secret; |
| 85 | |
| 86 | $this->ttl = $ttl ?? self::DEFAULT_COOKIE_TTL; |
| 87 | |
| 88 | if (!isset($user['me'])) { |
| 89 | throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); |
| 90 | } |
| 91 | |
| 92 | $hashAlgo = password_get_info($hashedPassword)['algo']; |
| 93 | // Invalid algorithms are null in PHP 7.4, 0 in PHP 7.3. |
| 94 | if (is_null($hashAlgo) or 0 === $hashAlgo) { |
| 95 | throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); |
| 96 | } |
| 97 | $this->user = $user; |
| 98 | $this->hashedPassword = $hashedPassword; |
| 99 | |
| 100 | $formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; |
| 101 | if (is_string($formTemplate)) { |
| 102 | $formTemplate = function (array $context) use ($formTemplate): string { |
| 103 | return renderTemplate($formTemplate, $context); |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | if (!is_callable($formTemplate)) { |
| 108 | throw new BadMethodCallException("\$formTemplate must be a string (path), a callable, or null."); |
| 109 | } |
| 110 | |
| 111 | $this->formTemplateCallable = $formTemplate; |
| 112 | $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; |
| 113 | } |
| 114 | |
| 115 | public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { |
| 116 | // If the request is logged in, return authentication data. |
| 117 | $cookies = $request->getCookieParams(); |
| 118 | if ( |
| 119 | isset($cookies[self::LOGIN_HASH_COOKIE]) |
| 120 | && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) |
| 121 | ) { |
| 122 | return $this->user; |
| 123 | } |
| 124 | |
| 125 | // If the request is a form submission with a matching password, return a redirect to the indieauth |
| 126 | // flow, setting a cookie. |
| 127 | if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { |
| 128 | $response = new Response(302, ['Location' => $formAction]); |
| 129 | |
| 130 | // Set the user data hash cookie. |
| 131 | $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) |
| 132 | ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) |
| 133 | ->withMaxAge($this->ttl) |
| 134 | ->withSecure($request->getUri()->getScheme() == 'https') |
| 135 | ->withDomain($request->getUri()->getHost()) |
| 136 | ); |
| 137 | |
| 138 | return $response; |
| 139 | } |
| 140 | |
| 141 | // Otherwise, return a response containing the password form. |
| 142 | return (new Response(200, ['content-type' => 'text/html'], call_user_func($this->formTemplateCallable, [ |
| 143 | 'formAction' => $formAction, |
| 144 | 'request' => $request, |
| 145 | 'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' |
| 146 | ])))->withAddedHeader('Cache-control', 'no-store') |
| 147 | ->withAddedHeader('Pragma', 'no-cache') |
| 148 | ->withAddedHeader('X-Frame-Options', 'DENY') |
| 149 | ->withAddedHeader('Content-Security-Policy', "frame-ancestors 'none'"); |
| 150 | } |
| 151 | } |