Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.00% covered (success)
98.00%
49 / 50
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordReset
98.00% covered (success)
98.00%
49 / 50
85.71% covered (warning)
85.71%
6 / 7
21
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
 form
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 choose
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 change
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
 guard
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 page
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Factory\Factory;
10use Celema\Core\Request;
11use Celema\Core\Response;
12use Cosray\Auth;
13use Cosray\Config;
14use Cosray\Security\RateLimiter;
15use Cosray\User\PasswordPolicy;
16use Cosray\User\PasswordResets;
17use Psr\Log\LoggerInterface as Logger;
18
19/**
20 * Lets panel users who forgot their password choose a new one through a
21 * link sent by mail. A request only queues the lookup and the mail, so its
22 * answer and its work are the same for every login and the form does not
23 * tell which logins exist. Without a mail transport and `app.url` the pages
24 * do not exist.
25 */
26final class PasswordReset extends Panel
27{
28    private const int MAX_LOGIN_LENGTH = 254;
29
30    /** Requests per window from one address. */
31    public const int PER_ADDRESS = 5;
32
33    /** Seconds a window of counted requests lasts. */
34    public const int WINDOW = 900;
35
36    public function __construct(
37        Config $config,
38        Container $container,
39        Request $request,
40        private readonly PasswordResets $resets,
41        private readonly PasswordPolicy $passwords,
42        private readonly Auth $auth,
43        private readonly Logger $logger,
44        private readonly RateLimiter $limiter,
45    ) {
46        parent::__construct($config, $container, $request);
47    }
48
49    /** @return array<string, mixed> */
50    public function form(): array
51    {
52        $this->guard();
53
54        return $this->page('request');
55    }
56
57    /** @return array<string, mixed>|Response */
58    public function send(Factory $factory): array|Response
59    {
60        $this->guard();
61        $login = $this->formData()['login'] ?? '';
62        $login = is_string($login) ? trim($login) : '';
63
64        if ($login === '' || mb_strlen($login) > self::MAX_LOGIN_LENGTH) {
65            return $this->page('request', ['login' => $login, 'message' => __('auth:reset-missing-login')]);
66        }
67
68        $ip = $this->auth->clientIp();
69        $wait = $this->limiter->attempt(["password-reset:{$ip}" => self::PER_ADDRESS], self::WINDOW);
70
71        if ($wait > 0) {
72            $this->logger->warning('Password reset request from {ip} refused: too many requests', ['ip' => $ip]);
73            $page = $this->page('request', ['login' => $login, 'message' => $this->tooManyAttempts($wait)]);
74
75            return $this->render($factory, 'password', $page, 429)->header('Retry-After', (string) $wait);
76        }
77
78        $locale = $this->request->get('panelLocale', null);
79        $this->resets->request($login, $ip, is_string($locale) ? $locale : null);
80
81        return $this->page('sent');
82    }
83
84    /** @return array<string, mixed> */
85    public function choose(#[\SensitiveParameter] string $token): array
86    {
87        $this->guard();
88
89        return $this->page($this->resets->user($token) === null ? 'invalid' : 'choose', ['token' => $token]);
90    }
91
92    /** @return array<string, mixed> */
93    public function change(#[\SensitiveParameter] string $token): array
94    {
95        $this->guard();
96        $user = $this->resets->user($token);
97
98        if ($user === null) {
99            return $this->page('invalid', ['token' => $token]);
100        }
101
102        $form = $this->formData();
103        $password = is_string($form['password'] ?? null) ? $form['password'] : '';
104        $problem = $this->passwords->check($password, $user->email, $user->username, $user->name);
105        $message = match (true) {
106            $problem !== null => $problem->message(),
107            !hash_equals($password, (string) ($form['password_repeat'] ?? '')) => __('user:error-password-repeat'),
108            default => null,
109        };
110
111        if ($message !== null) {
112            return $this->page('choose', ['token' => $token, 'message' => $message]);
113        }
114
115        $user = $this->resets->complete($token, $password);
116
117        if ($user === null) {
118            return $this->page('invalid', ['token' => $token]);
119        }
120
121        $this->logger->notice('Password of {login} reset from {ip}', [
122            'login' => $user->loginName(),
123            'ip' => $this->auth->clientIp(),
124        ]);
125
126        return $this->page('done');
127    }
128
129    private function guard(): void
130    {
131        if (!$this->resets->available()) {
132            throw new HttpNotFound($this->request);
133        }
134    }
135
136    /**
137     * @param 'request'|'sent'|'choose'|'invalid'|'done' $step
138     * @param array<string, mixed> $data
139     * @return array<string, mixed>
140     */
141    private function page(string $step, array $data = []): array
142    {
143        return $this->context([
144            'step' => $step,
145            'login' => '',
146            'token' => '',
147            'message' => null,
148            ...$data,
149        ]);
150    }
151}