Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.47% covered (warning)
89.47%
102 / 114
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Login
89.47% covered (warning)
89.47%
102 / 114
76.92% covered (warning)
76.92%
10 / 13
31.05
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
 login
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 authenticate
89.29% covered (warning)
89.29%
50 / 56
0.00% covered (danger)
0.00%
0 / 1
5.03
 handoff
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 expired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logout
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 hasPanelPermission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 policy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitizedNext
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 message
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 redirect
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Factory\Factory;
9use Celema\Core\Request;
10use Celema\Core\Response;
11use Cosray\Auth as CmsAuth;
12use Cosray\Config;
13use Cosray\Security\Policy;
14use Cosray\Security\RateLimiter;
15use Cosray\User\PasswordResets;
16use Cosray\Validation;
17use Override;
18use Psr\Log\LoggerInterface as Logger;
19
20final class Login extends Panel
21{
22    /** Seconds a window of counted sign-in failures lasts. */
23    public const int WINDOW = 900;
24
25    /** Failed sign-ins per window from one address for one login. */
26    public const int PER_LOGIN = 5;
27
28    /** Failed sign-ins per window from one address, for any login. */
29    public const int PER_ADDRESS = 30;
30
31    public function __construct(
32        Config $config,
33        Container $container,
34        Request $request,
35        private readonly CmsAuth $auth,
36        private readonly Logger $logger,
37        private readonly PasswordResets $resets,
38        private readonly RateLimiter $limiter,
39    ) {
40        parent::__construct($config, $container, $request);
41    }
42
43    /** @return array<string, mixed>|Response */
44    public function login(Factory $factory): array|Response
45    {
46        if ($this->hasPanelPermission()) {
47            return $this->redirect($factory, $this->panelPath());
48        }
49
50        return $this->context([
51            'next' => $this->sanitizedNext(),
52            'login' => '',
53            'rememberme' => false,
54            'message' => null,
55        ]);
56    }
57
58    /** @return array<string, mixed>|Response */
59    public function authenticate(Factory $factory): array|Response
60    {
61        $data = $this->formData();
62        $shape = new Validation\Login();
63        $result = $shape->validate($data);
64
65        if (!$result->valid()) {
66            return $this->context([
67                'next' => $this->sanitizedNext($data['next'] ?? ''),
68                'login' => (string) ($data['login'] ?? ''),
69                'rememberme' => (bool) ($data['rememberme'] ?? false),
70                'message' => $this->message(__('auth:missing-credentials')),
71            ]);
72        }
73
74        $values = $result->values();
75        $ip = $this->auth->clientIp();
76        $address = "sign-in:{$ip}";
77        $login = $address . ':' . mb_strtolower($values['login']);
78        // Counted before the password is checked, so concurrent guesses cannot slip past the limit.
79        $wait = $this->limiter->attempt([$address => self::PER_ADDRESS, $login => self::PER_LOGIN], self::WINDOW);
80
81        if ($wait > 0) {
82            $this->logger->warning('Login from {ip} refused: too many failed attempts', ['ip' => $ip]);
83
84            return $this->render(
85                $factory,
86                'login',
87                $this->context([
88                    'next' => $this->sanitizedNext($data['next'] ?? ''),
89                    'login' => (string) ($data['login'] ?? ''),
90                    'rememberme' => (bool) ($data['rememberme'] ?? false),
91                    'message' => $this->tooManyAttempts($wait),
92                ]),
93                429,
94            )->header('Retry-After', (string) $wait);
95        }
96
97        $user = $this->auth->authenticate(
98            $values['login'],
99            $values['password'],
100            $values['rememberme'],
101            true,
102        );
103
104        if ($user === false) {
105            return $this->context([
106                'next' => $this->sanitizedNext($data['next'] ?? ''),
107                'login' => (string) ($data['login'] ?? ''),
108                'rememberme' => (bool) ($data['rememberme'] ?? false),
109                'message' => $this->message(__('auth:invalid-credentials')),
110            ]);
111        }
112
113        $this->limiter->release($address);
114        $this->limiter->clear($login);
115
116        if (!$this->policy()->permits($user, 'panel')) {
117            $this->auth->logout();
118            $this->logger->notice('Panel login refused for {login} from {ip}: no panel permission', [
119                'login' => $user->loginName(),
120                'ip' => $this->auth->clientIp(),
121            ]);
122
123            return $this->context([
124                'next' => $this->sanitizedNext($data['next'] ?? ''),
125                'login' => (string) ($data['login'] ?? ''),
126                'rememberme' => false,
127                'message' => $this->message(__('auth:no-panel-access')),
128            ]);
129        }
130
131        return $this->redirect($factory, $this->sanitizedNext($data['next'] ?? ''));
132    }
133
134    /**
135     * Logs in the user a one-time token was issued for, then follows `next`
136     * inside the panel: how an embedding application hands its user over.
137     */
138    public function handoff(
139        Factory $factory,
140        #[\SensitiveParameter]
141        string $token,
142    ): Response {
143        $address = 'sign-in:' . $this->auth->clientIp();
144
145        if ($this->limiter->attempt([$address => self::PER_ADDRESS], self::WINDOW) > 0) {
146            return $this->redirect($factory, $this->panelPath() . '/login');
147        }
148
149        $user = $this->auth->authenticateByOneTimeToken($token, true);
150
151        if ($user === false) {
152            return $this->redirect($factory, $this->panelPath() . '/login');
153        }
154
155        $this->limiter->release($address);
156
157        if (!$this->policy()->permits($user, 'panel')) {
158            $this->auth->logout();
159
160            return $this->redirect($factory, $this->panelPath() . '/login');
161        }
162
163        return $this->redirect($factory, $this->sanitizedNext());
164    }
165
166    /**
167     * Explains a form refused for a missing or outdated CSRF token, as when
168     * the page was opened before the session expired.
169     *
170     * @return array<string, mixed>
171     */
172    public function expired(): array
173    {
174        return $this->context(['next' => $this->sanitizedNext()]);
175    }
176
177    public function logout(Factory $factory): Response
178    {
179        $user = $this->auth->user();
180        $this->auth->logout();
181
182        if ($user !== null) {
183            $this->logger->info('Logout of {login} from {ip}', [
184                'login' => $user->loginName(),
185                'ip' => $this->auth->clientIp(),
186            ]);
187        }
188
189        return $this->redirect($factory, $this->panelPath() . '/login');
190    }
191
192    /**
193     * @param array<string, mixed> $data
194     * @return array<string, mixed>
195     */
196    #[Override]
197    protected function context(array $data = []): array
198    {
199        return parent::context(['passwordReset' => $this->resets->available(), ...$data]);
200    }
201
202    /** @return array<array-key, mixed> */
203    #[Override]
204    protected function formData(): array
205    {
206        $data = parent::formData();
207        $rememberme = $data['rememberme'] ?? false;
208        $data['rememberme'] = in_array($rememberme, [true, 1, '1', 'true', 'on'], true);
209
210        return $data;
211    }
212
213    private function hasPanelPermission(): bool
214    {
215        return $this->policy()->permits($this->auth->user(), 'panel');
216    }
217
218    private function policy(): Policy
219    {
220        return $this->container->instance(Policy::class);
221    }
222
223    private function sanitizedNext(string $next = ''): string
224    {
225        if ($next === '') {
226            $next = $this->request->param('next', '');
227        }
228
229        if (!is_string($next)) {
230            return $this->panelPath();
231        }
232
233        $next = trim($next);
234
235        if ($next === '') {
236            return $this->panelPath();
237        }
238
239        if (!str_starts_with($next, '/')) {
240            return $this->panelPath();
241        }
242
243        if (preg_match('#^https?://#i', $next)) {
244            return $this->panelPath();
245        }
246
247        if (!str_starts_with($next, $this->panelPath())) {
248            return $this->panelPath();
249        }
250
251        return $next;
252    }
253
254    private function message(string $message): ?string
255    {
256        $message = trim($message);
257
258        return $message === '' ? null : $message;
259    }
260
261    private function redirect(Factory $factory, string $target): Response
262    {
263        $response = Response::create($factory);
264
265        if ($this->request->hasHeader('HX-Request')) {
266            return $response
267                ->status(200)
268                ->header('HX-Redirect', $target);
269        }
270
271        return $response->redirect($target, 303);
272    }
273}