Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
30 / 31
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordResets
96.77% covered (success)
96.77%
30 / 31
85.71% covered (warning)
85.71%
6 / 7
15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 request
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 issue
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 user
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 complete
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\User;
6
7use Celema\Quma\Database;
8use Cosray\Actor;
9use Cosray\Config;
10use Cosray\Queue;
11use Cosray\User;
12use Cosray\Users;
13use Cosray\Util\Transaction;
14
15/**
16 * Links that let panel users who forgot their password choose a new one.
17 * A link holds a random token, of which only the sha256 is stored; it
18 * works once and for an hour, and a newer link replaces it.
19 */
20final class PasswordResets
21{
22    /** Seconds a link works. */
23    public const int LIFETIME = 3600;
24
25    /** Seconds before a user gets another link. */
26    public const int INTERVAL = 300;
27
28    public function __construct(
29        private readonly Database $db,
30        private readonly Users $users,
31        private readonly Config $config,
32        private readonly Queue $queue,
33    ) {}
34
35    /** Whether links can be sent: they need a mail transport and `app.url`. */
36    public function available(): bool
37    {
38        return $this->config->mail->transports !== [] && $this->config->app->url !== null;
39    }
40
41    /**
42     * Queues sending a link to the user with this login, if there is one;
43     * the mail is in the user's panel language, else in the given one.
44     */
45    public function request(string $login, string $ip, ?string $locale): void
46    {
47        $this->queue->push(
48            PasswordResetMail::class,
49            ['login' => $login, 'ip' => $ip, 'locale' => $locale],
50            queue: 'mail',
51        );
52    }
53
54    /**
55     * A new link's token, or null while the user's last link is younger than
56     * the interval. The upsert itself checks the interval, so concurrent
57     * requests cannot both replace a link.
58     */
59    public function issue(User $user): ?string
60    {
61        $this->db->passwordResets->expired()->run();
62        $token = bin2hex(random_bytes(32));
63        $issued = $this->db
64            ->passwordResets
65            ->create([
66                'token' => hash('sha256', $token),
67                'usr' => $user->id,
68                'lifetime' => self::LIFETIME,
69                'interval' => self::INTERVAL,
70            ])
71            ->first();
72
73        return $issued === null ? null : $token;
74    }
75
76    /** The active user a working link was issued for. */
77    public function user(#[\SensitiveParameter] string $token): ?User
78    {
79        /** @var ?array{usr: int} $row */
80        $row = $this->db->passwordResets->user(['token' => hash('sha256', $token)])->first();
81        $user = $row === null ? null : $this->users->byId($row['usr']);
82
83        return $user !== null && $user->active ? $user : null;
84    }
85
86    /**
87     * Uses up the link and sets the password of its user, which also ends
88     * their sessions, all or nothing. Returns the user, or null if the link
89     * does not work; of concurrent requests with one link, only the first
90     * gets the user.
91     */
92    public function complete(#[\SensitiveParameter] string $token, #[\SensitiveParameter] string $password): ?User
93    {
94        return Transaction::run($this->db, function () use ($token, $password): ?User {
95            /** @var ?array{usr: int} $row */
96            $row = $this->db->passwordResets->consume(['token' => hash('sha256', $token)])->first();
97            $user = $row === null ? null : $this->users->byId($row['usr']);
98
99            if ($user === null || !$user->active) {
100                return null;
101            }
102
103            $this->users->setPassword($user, $password, Actor::system());
104
105            return $user;
106        });
107    }
108
109    /** The absolute URL of a link. */
110    public function url(#[\SensitiveParameter] string $token): string
111    {
112        return $this->config->app->url . rtrim($this->config->panel->path, '/') . '/password/' . $token;
113    }
114}