Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
29 / 29
SingleUserPasswordAuthenticationCallback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
2 / 2
9
100.00% covered (success)
100.00%
29 / 29
 __construct
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
13 / 13
 __invoke
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
16 / 16
1<?php declare(strict_types=1);
2
3namespace Taproot\IndieAuth\Callback;
4
5use BadMethodCallException;
6use Dflydev\FigCookies;
7use Nyholm\Psr7\Response;
8use Psr\Http\Message\ServerRequestInterface;
9
10use 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 */
46class 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}