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