Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
98 / 105
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebAuthnController
93.33% covered (success)
93.33%
98 / 105
40.00% covered (danger)
40.00%
2 / 5
35.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 prepare
92.50% covered (success)
92.50%
37 / 40
0.00% covered (danger)
0.00%
0 / 1
14.08
 register
84.21% covered (success)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
7.19
 prepareLogin
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 login
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
8
1<?php
2
3/**
4 * The WebAuthn Controller
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2024-09-11
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use phpMyFAQ\Auth\AuthWebAuthn;
23use phpMyFAQ\Auth\WebAuthn\WebAuthnUser;
24use phpMyFAQ\Controller\AbstractController;
25use phpMyFAQ\Core\Exception;
26use phpMyFAQ\Enums\AuthenticationSourceType;
27use phpMyFAQ\Filter;
28use phpMyFAQ\Session\Token;
29use phpMyFAQ\Translation;
30use phpMyFAQ\User;
31use phpMyFAQ\User\CurrentUser;
32use Random\RandomException;
33use Symfony\Component\HttpFoundation\JsonResponse;
34use Symfony\Component\HttpFoundation\Request;
35use Symfony\Component\HttpFoundation\Response;
36use Symfony\Component\Routing\Attribute\Route;
37
38final class WebAuthnController extends AbstractController
39{
40    private readonly AuthWebAuthn $authWebAuthn;
41
42    private readonly User $user;
43
44    private readonly ?CurrentUser $loginCurrentUser;
45
46    public function __construct(
47        ?AuthWebAuthn $authWebAuthn = null,
48        ?User $user = null,
49        ?CurrentUser $loginCurrentUser = null,
50    ) {
51        parent::__construct();
52
53        $this->authWebAuthn = $authWebAuthn ?? new AuthWebAuthn($this->configuration);
54        $this->user = $user ?? new User($this->configuration);
55        $this->loginCurrentUser = $loginCurrentUser;
56    }
57
58    /**
59     * @throws RandomException|\JsonException
60     * @throws \Exception
61     */
62    #[Route(path: 'webauthn/prepare', name: 'api.private.webauthn.prepare', methods: ['POST'])]
63    public function prepare(Request $request): JsonResponse
64    {
65        if (!$this->configuration->get('security.enableWebAuthnSupport')) {
66            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
67        }
68
69        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
70
71        if (!is_object($data) || !property_exists($data, 'username')) {
72            throw new Exception('Missing username');
73        }
74
75        $username = Filter::filterVar($data->username, FILTER_SANITIZE_SPECIAL_CHARS, '');
76
77        $userExists = (bool) $this->user->getUserByLogin($username, raiseError: false);
78
79        if (!$userExists && !$this->configuration->get('security.enableRegistration')) {
80            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
81        }
82
83        // Verify the CSRF token on every path, regardless of whether the user exists.
84        $csrfToken = Filter::filterVar($data->{'pmf-csrf-token'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
85
86        if (!Token::getInstance($this->session)->verifyToken('webauthn', $csrfToken)) {
87            return $this->json(['error' => Translation::get(key: 'msgSessionExpired')], Response::HTTP_UNAUTHORIZED);
88        }
89
90        // The account already exists: only its authenticated owner may (re-)register a passkey.
91        // This prevents an unauthenticated attacker from overwriting an existing user's passkeys.
92        if ($userExists && !$this->currentUser->isLoggedIn()) {
93            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_UNAUTHORIZED);
94        }
95
96        if ($userExists && $this->currentUser->getUserId() !== $this->user->getUserId()) {
97            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
98        }
99
100        if (!$userExists) {
101            if (!$this->captchaCodeIsValid($request)) {
102                return $this->json(['error' => Translation::get(key: 'msgCaptcha')], Response::HTTP_BAD_REQUEST);
103            }
104
105            try {
106                $this->user->createUser($username);
107                $this->user->setStatus(status: 'active');
108                $this->user->setAuthSource(AuthenticationSourceType::AUTH_WEB_AUTHN->value);
109                $this->user->setUserData([
110                    'display_name' => $username,
111                    'email' => $username,
112                ]);
113            } catch (\Exception $e) {
114                return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
115            }
116        }
117
118        $webAuthnUser = new WebAuthnUser();
119        $webAuthnUser
120            ->setName($username)
121            ->setId((string) $this->user->getUserId())
122            ->setWebAuthnKeys(webAuthnKeys: '');
123
124        $this->authWebAuthn->storeUserInSession($webAuthnUser);
125
126        return $this->json([
127            'challenge' => $this->authWebAuthn->prepareChallengeForRegistration(
128                $username,
129                (string) $this->user->getUserId(),
130            ),
131        ], Response::HTTP_OK);
132    }
133
134    /**
135     * @throws Exception
136     * @throws \JsonException
137     */
138    #[Route(path: 'webauthn/register', name: 'api.private.webauthn.register', methods: ['POST'])]
139    public function register(Request $request): JsonResponse
140    {
141        if (!$this->configuration->get('security.enableWebAuthnSupport')) {
142            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
143        }
144
145        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
146
147        if (!is_object($data) || !property_exists($data, 'register')) {
148            throw new Exception('Missing register data');
149        }
150
151        $register = Filter::filterVar($data->register, FILTER_SANITIZE_SPECIAL_CHARS, '');
152
153        $webAuthnUser = $this->authWebAuthn->getUserFromSession();
154
155        if (!$webAuthnUser) {
156            throw new Exception('User not found in session');
157        }
158
159        $webAuthnUser->setWebAuthnKeys($this->authWebAuthn->register($register, $webAuthnUser->getWebAuthnKeys()));
160
161        try {
162            $this->user->getUserByLogin($webAuthnUser->getName());
163        } catch (Exception) {
164            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
165        }
166
167        if ($this->user->setWebAuthnKeys($webAuthnUser->getWebAuthnKeys())) {
168            return $this->json([
169                'success' => 'ok',
170                'message' => Translation::get(key: 'msgPasskeyRegistrationSuccess'),
171            ], Response::HTTP_OK);
172        }
173
174        return $this->json(['error' => 'Cannot set WebAuthn keys'], Response::HTTP_BAD_REQUEST);
175    }
176
177    /**
178     * @throws \JsonException
179     * @throws RandomException
180     */
181    #[Route(path: 'webauthn/prepare-login', name: 'api.private.webauthn.prepare-login', methods: ['POST'])]
182    public function prepareLogin(Request $request): JsonResponse
183    {
184        if (!$this->configuration->get('security.enableWebAuthnSupport')) {
185            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
186        }
187
188        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
189
190        if (!is_object($data) || !property_exists($data, 'username')) {
191            throw new Exception('Missing username');
192        }
193
194        $login = Filter::filterVar($data->username, FILTER_SANITIZE_SPECIAL_CHARS, '');
195
196        try {
197            $this->user->getUserByLogin($login);
198        } catch (Exception) {
199            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
200        }
201
202        $webAuthnKeys = $this->user->getWebAuthnKeys();
203        $publicKey = $this->authWebAuthn->prepareForLogin($webAuthnKeys);
204
205        // prepareForLogin() stamps the pending challenge onto the keys; it has to be stored so the
206        // login can check the assertion against it and reject replays.
207        $this->user->setWebAuthnKeys($webAuthnKeys);
208
209        return $this->json($publicKey, Response::HTTP_OK);
210    }
211
212    /**
213     * @throws Exception
214     * @throws \JsonException
215     * @throws \Exception
216     */
217    #[Route(path: 'webauthn/login', name: 'api.private.webauthn.login', methods: ['POST'])]
218    public function login(Request $request): JsonResponse
219    {
220        if (!$this->configuration->get('security.enableWebAuthnSupport')) {
221            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
222        }
223
224        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
225
226        if (!is_object($data) || !property_exists($data, 'username')) {
227            throw new Exception('Missing username');
228        }
229
230        if (!property_exists($data, 'login')) {
231            throw new Exception('Missing login data');
232        }
233
234        $login = Filter::filterVar($data->username, FILTER_SANITIZE_SPECIAL_CHARS, '');
235        $loginData = $data->login;
236        if (!$loginData instanceof \stdClass) {
237            throw new Exception('Missing login data');
238        }
239
240        $this->user->getUserByLogin($login);
241
242        $webAuthnKeys = $this->user->getWebAuthnKeys();
243        $isAuthenticated = $this->authWebAuthn->authenticate($loginData, $webAuthnKeys);
244
245        // authenticate() blanks the challenge it just consumed. Store that, so the same assertion
246        // cannot be presented a second time.
247        $this->user->setWebAuthnKeys($webAuthnKeys);
248
249        if ($isAuthenticated) {
250            $currentUser = $this->loginCurrentUser ?? new CurrentUser($this->configuration);
251            $currentUser->getUserByLogin($login);
252
253            if ($currentUser->isBlocked()) {
254                return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_UNAUTHORIZED);
255            }
256
257            $currentUser->setLoggedIn(loggedIn: true);
258            $currentUser->setSuccess(success: true);
259            $currentUser->updateSessionId(updateLastLogin: true);
260            $currentUser->saveToSession();
261            return $this->json([
262                'success' => 'ok',
263                'redirect' => $this->configuration->getDefaultUrl(),
264            ], Response::HTTP_OK);
265        }
266
267        return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_UNAUTHORIZED);
268    }
269}