Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.00% covered (danger)
41.00%
41 / 100
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebAuthnController
41.00% covered (danger)
41.00%
41 / 100
20.00% covered (danger)
20.00%
1 / 5
153.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
5.88% covered (danger)
5.88%
2 / 34
0.00% covered (danger)
0.00%
0 / 1
47.85
 register
9.09% covered (danger)
9.09%
2 / 22
0.00% covered (danger)
0.00%
0 / 1
43.81
 prepareLogin
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
 login
85.19% covered (success)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
6.12
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;
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: 'api/webauthn/prepare', name: 'api.private.webauthn.prepare', methods: ['POST'])]
63    public function prepare(Request $request): JsonResponse
64    {
65        if (!$this->configuration->get(item: 'security.enableWebAuthnSupport')) {
66            return $this->json(['error' => 'WebAuthn support is disabled.'], Response::HTTP_FORBIDDEN);
67        }
68
69        if (!$this->configuration->get(item: 'security.enableRegistration')) {
70            return $this->json(['error' => 'User registration is disabled.'], Response::HTTP_FORBIDDEN);
71        }
72
73        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
74        if (!$data instanceof \stdClass) {
75            return $this->json(['error' => 'The request body must be a JSON object.'], Response::HTTP_BAD_REQUEST);
76        }
77
78        $csrfToken = Filter::filterVar($data->csrfToken ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
79        if (!Token::getInstance($this->session)->verifyToken('webauthn-prepare', $csrfToken)) {
80            return $this->json(['error' => Translation::get(key: 'err_NotAuth')], Response::HTTP_UNAUTHORIZED);
81        }
82
83        $username = Filter::filterVar($data->username ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
84
85        if (!$this->user->getUserByLogin($username, raiseError: false)) {
86            try {
87                $this->user->createUser($username);
88                $this->user->setStatus(status: 'active');
89                $this->user->setAuthSource(AuthenticationSourceType::AUTH_WEB_AUTHN->value);
90                $this->user->setUserData([
91                    'display_name' => $username,
92                    'email' => $username,
93                ]);
94            } catch (\Exception $e) {
95                return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
96            }
97        }
98
99        $webAuthnUser = new WebAuthnUser();
100        $webAuthnUser
101            ->setName($username)
102            ->setId((string) $this->user->getUserId())
103            ->setWebAuthnKeys(webAuthnKeys: '');
104
105        $this->authWebAuthn->storeUserInSession($webAuthnUser);
106
107        return $this->json([
108            'challenge' => $this->authWebAuthn->prepareChallengeForRegistration(
109                $username,
110                (string) $this->user->getUserId(),
111            ),
112            'csrfToken' => Token::getInstance($this->session)->getTokenString('webauthn-register'),
113        ], Response::HTTP_OK);
114    }
115
116    /**
117     * @throws Exception
118     * @throws \JsonException
119     */
120    #[Route(path: 'api/webauthn/register', name: 'api.private.webauthn.register', methods: ['POST'])]
121    public function register(Request $request): JsonResponse
122    {
123        if (!$this->configuration->get(item: 'security.enableWebAuthnSupport')) {
124            return $this->json(['error' => 'WebAuthn support is disabled.'], Response::HTTP_FORBIDDEN);
125        }
126
127        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
128        if (!$data instanceof \stdClass) {
129            return $this->json(['error' => 'The request body must be a JSON object.'], Response::HTTP_BAD_REQUEST);
130        }
131
132        $csrfToken = Filter::filterVar($data->csrfToken ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
133        if (!Token::getInstance($this->session)->verifyToken('webauthn-register', $csrfToken)) {
134            return $this->json(['error' => Translation::get(key: 'err_NotAuth')], Response::HTTP_UNAUTHORIZED);
135        }
136
137        $register = Filter::filterVar($data->register ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
138
139        $webAuthnUser = $this->authWebAuthn->getUserFromSession();
140        if ($webAuthnUser === null) {
141            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
142        }
143
144        $webAuthnUser->setWebAuthnKeys($this->authWebAuthn->register($register, $webAuthnUser->getWebAuthnKeys()));
145
146        try {
147            $this->user->getUserByLogin($webAuthnUser->getName());
148        } catch (Exception) {
149            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
150        }
151
152        if ($this->user->setWebAuthnKeys($webAuthnUser->getWebAuthnKeys())) {
153            return $this->json([
154                'success' => 'ok',
155                'message' => Translation::get(key: 'msgPasskeyRegistrationSuccess'),
156            ], Response::HTTP_OK);
157        }
158
159        return $this->json(['error' => 'Cannot set WebAuthn keys'], Response::HTTP_BAD_REQUEST);
160    }
161
162    /**
163     * @throws \JsonException
164     * @throws RandomException
165     */
166    #[Route(path: 'api/webauthn/prepare-login', name: 'api.private.webauthn.prepare-login', methods: ['POST'])]
167    public function prepareLogin(Request $request): JsonResponse
168    {
169        if (!$this->configuration->get(item: 'security.enableWebAuthnSupport')) {
170            return $this->json(['error' => 'WebAuthn support is disabled.'], Response::HTTP_FORBIDDEN);
171        }
172
173        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
174        if (!$data instanceof \stdClass) {
175            return $this->json(['error' => 'The request body must be a JSON object.'], Response::HTTP_BAD_REQUEST);
176        }
177
178        $login = Filter::filterVar($data->username ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
179
180        try {
181            $this->user->getUserByLogin($login);
182        } catch (Exception) {
183            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
184        }
185
186        $webAuthnKeys = $this->user->getWebAuthnKeys();
187        $publicKey = $this->authWebAuthn->prepareForLogin($webAuthnKeys);
188
189        // prepareForLogin() stamps the pending challenge onto the keys; it has to be stored so the
190        // login can check the assertion against it and reject replays.
191        $this->user->setWebAuthnKeys($webAuthnKeys);
192
193        return $this->json($publicKey, Response::HTTP_OK);
194    }
195
196    /**
197     * @throws Exception
198     * @throws \JsonException
199     * @throws \Exception
200     */
201    #[Route(path: 'api/webauthn/login', name: 'api.private.webauthn.login', methods: ['POST'])]
202    public function login(Request $request): JsonResponse
203    {
204        if (!$this->configuration->get(item: 'security.enableWebAuthnSupport')) {
205            return $this->json(['error' => 'WebAuthn support is disabled.'], Response::HTTP_FORBIDDEN);
206        }
207
208        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
209        if (!$data instanceof \stdClass) {
210            return $this->json(['error' => 'The request body must be a JSON object.'], Response::HTTP_BAD_REQUEST);
211        }
212
213        $login = Filter::filterVar($data->username ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
214        $loginData = $data->login ?? null;
215
216        $this->user->getUserByLogin($login);
217
218        $webAuthnKeys = $this->user->getWebAuthnKeys();
219
220        if (!$loginData instanceof \stdClass) {
221            return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_BAD_REQUEST);
222        }
223
224        $isAuthenticated = $this->authWebAuthn->authenticate($loginData, $webAuthnKeys);
225
226        // authenticate() blanks the challenge it just consumed. Store that, so the same assertion
227        // cannot be presented a second time.
228        $this->user->setWebAuthnKeys($webAuthnKeys);
229
230        if ($isAuthenticated) {
231            $currentUser = $this->loginCurrentUser ?? new CurrentUser($this->configuration);
232            $currentUser->getUserByLogin($login);
233
234            if ($currentUser->isBlocked()) {
235                return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_UNAUTHORIZED);
236            }
237
238            $currentUser->setLoggedIn(loggedIn: true);
239            $currentUser->setSuccess(success: true);
240            $currentUser->updateSessionId(updateLastLogin: true);
241            $currentUser->saveToSession();
242            return $this->json([
243                'success' => 'ok',
244                'redirect' => $this->configuration->getDefaultUrl(),
245            ], Response::HTTP_OK);
246        }
247
248        return $this->json(['error' => Translation::get(key: 'ad_auth_fail')], Response::HTTP_UNAUTHORIZED);
249    }
250}