Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.77% |
30 / 31 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
| PasswordResets | |
96.77% |
30 / 31 |
|
85.71% |
6 / 7 |
15 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| available | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| request | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| issue | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| user | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| complete | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| url | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\User; |
| 6 | |
| 7 | use Celema\Quma\Database; |
| 8 | use Cosray\Actor; |
| 9 | use Cosray\Config; |
| 10 | use Cosray\Queue; |
| 11 | use Cosray\User; |
| 12 | use Cosray\Users; |
| 13 | use 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 | */ |
| 20 | final 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 | } |