Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.30% covered (success)
97.30%
36 / 37
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
SingleUserPasswordAuthenticationCallback
97.30% covered (success)
97.30%
36 / 37
50.00% covered (danger)
50.00%
1 / 2
12
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 __invoke
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
1<?php declare(strict_types=1);
2
3namespace Taproot\IndieAuth\Callback;
4
5use BadMethodCallException;
6use Dflydev\FigCookies;
7use GuzzleHttp\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    /** @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}