Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
100.00% |
1 / 1 |
|
100.00% |
2 / 2 |
CRAP | |
100.00% |
29 / 29 |
SingleUserPasswordAuthenticationCallback | |
100.00% |
1 / 1 |
|
100.00% |
2 / 2 |
9 | |
100.00% |
29 / 29 |
__construct | |
100.00% |
1 / 1 |
4 | |
100.00% |
13 / 13 |
|||
__invoke | |
100.00% |
1 / 1 |
5 | |
100.00% |
16 / 16 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Taproot\IndieAuth\Callback; |
4 | |
5 | use BadMethodCallException; |
6 | use Dflydev\FigCookies; |
7 | use Nyholm\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 | * ['me' => 'https://me.example.com/'], |
38 | * YOUR_HASHED_PASSWORD |
39 | * ) |
40 | * … |
41 | * ]); |
42 | * ``` |
43 | * |
44 | * See documentation for `__construct()` for information about customising behaviour. |
45 | */ |
46 | class SingleUserPasswordAuthenticationCallback { |
47 | const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; |
48 | const LOGIN_HASH_COOKIE = 'taproot_indieauth_server_supauth_hash'; |
49 | const DEFAULT_COOKIE_TTL = 60 * 5; |
50 | |
51 | public string $csrfKey; |
52 | public string $formTemplate; |
53 | protected array $user; |
54 | protected string $hashedPassword; |
55 | protected string $secret; |
56 | protected int $ttl; |
57 | |
58 | /** |
59 | * Constructor |
60 | * |
61 | * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. |
62 | * @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. |
63 | * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` |
64 | * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. |
65 | * @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. |
66 | * @param int|null $ttl The lifetime of the authentication cookie. Defaults to five minutes. |
67 | */ |
68 | public function __construct(string $secret, array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null, ?int $ttl=self::DEFAULT_COOKIE_TTL) { |
69 | if (strlen($secret) < 64) { |
70 | throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); |
71 | } |
72 | $this->secret = $secret; |
73 | |
74 | $this->ttl = $ttl; |
75 | |
76 | if (!isset($user['me'])) { |
77 | throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); |
78 | } |
79 | |
80 | if (is_null(password_get_info($hashedPassword)['algo'])) { |
81 | throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); |
82 | } |
83 | $this->user = $user; |
84 | $this->hashedPassword = $hashedPassword; |
85 | $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; |
86 | $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; |
87 | } |
88 | |
89 | public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { |
90 | // If the request is logged in, return authentication data. |
91 | $cookies = $request->getCookieParams(); |
92 | if ( |
93 | isset($cookies[self::LOGIN_HASH_COOKIE]) |
94 | && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) |
95 | ) { |
96 | return $this->user; |
97 | } |
98 | |
99 | // If the request is a form submission with a matching password, return a redirect to the indieauth |
100 | // flow, setting a cookie. |
101 | if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { |
102 | $response = new Response(302, ['Location' => $formAction]); |
103 | |
104 | // Set the user data hash cookie. |
105 | $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) |
106 | ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) |
107 | ->withMaxAge($this->ttl) |
108 | ->withSecure($request->getUri()->getScheme() == 'https') |
109 | ->withDomain($request->getUri()->getHost()) |
110 | ); |
111 | |
112 | return $response; |
113 | } |
114 | |
115 | // Otherwise, return a response containing the password form. |
116 | return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ |
117 | 'formAction' => $formAction, |
118 | 'request' => $request, |
119 | 'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' |
120 | ])); |
121 | } |
122 | } |