• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 26937880755

04 Jun 2026 07:38AM UTC coverage: 75.983% (+4.4%) from 71.569%
26937880755

push

github

web-flow
Merge pull request #56 from daycry/development

feat

613 of 719 new or added lines in 42 files covered. (85.26%)

3 existing lines in 3 files now uncovered.

5179 of 6816 relevant lines covered (75.98%)

69.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.16
/src/Libraries/WebAuthn/WebAuthnManager.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Libraries\WebAuthn;
15

16
use Cose\Algorithms;
17
use Daycry\Auth\Entities\User;
18
use Daycry\Auth\Entities\WebAuthnCredential;
19
use Daycry\Auth\Exceptions\WebAuthnDuplicateCredentialException;
20
use Daycry\Auth\Exceptions\WebAuthnException;
21
use Daycry\Auth\Models\UserModel;
22
use Daycry\Auth\Models\WebAuthnCredentialRepository;
23
use Symfony\Component\Serializer\SerializerInterface;
24
use Throwable;
25
use Webauthn\AuthenticatorAssertionResponse;
26
use Webauthn\AuthenticatorAssertionResponseValidator;
27
use Webauthn\AuthenticatorAttestationResponse;
28
use Webauthn\AuthenticatorAttestationResponseValidator;
29
use Webauthn\AuthenticatorSelectionCriteria;
30
use Webauthn\PublicKeyCredential;
31
use Webauthn\PublicKeyCredentialCreationOptions;
32
use Webauthn\PublicKeyCredentialParameters;
33
use Webauthn\PublicKeyCredentialRequestOptions;
34
use Webauthn\PublicKeyCredentialRpEntity;
35
use Webauthn\PublicKeyCredentialUserEntity;
36

37
/**
38
 * Orchestrates the WebAuthn ceremonies (registration here; login/2FA added
39
 * later) using web-auth/webauthn-lib v5. Mirrors Libraries/Oauth/OauthManager.
40
 */
41
class WebAuthnManager
42
{
43
    public function __construct(
14✔
44
        private readonly WebAuthnCredentialRepository $repository,
45
        private readonly ChallengeManager $challenges,
46
        private readonly SerializerInterface $serializer,
47
        private readonly AuthenticatorAttestationResponseValidator $attestationValidator,
48
        private readonly AuthenticatorAssertionResponseValidator $assertionValidator,
49
    ) {
50
    }
14✔
51

52
    private function rpId(): string
13✔
53
    {
54
        $id = setting('AuthSecurity.webauthnRelyingPartyId');
13✔
55

56
        return is_string($id) && $id !== '' ? $id : (parse_url(base_url(), PHP_URL_HOST) ?: 'localhost');
13✔
57
    }
58

59
    /**
60
     * @return array<string, mixed> creation options ready for JSON
61
     */
62
    public function startRegistration(User $user, ?string $label = null): array
12✔
63
    {
64
        $max = (int) (setting('AuthSecurity.webauthnMaxCredentialsPerUser') ?? 10);
12✔
65
        if ($this->repository->countActiveForUser($user->id) >= $max) {
12✔
NEW
66
            throw new WebAuthnException(lang('Auth.webauthnMaxCredentials'));
×
67
        }
68

69
        $rp         = PublicKeyCredentialRpEntity::create((string) setting('AuthSecurity.webauthnRelyingPartyName'), $this->rpId());
12✔
70
        $userEntity = PublicKeyCredentialUserEntity::create(
12✔
71
            (string) ($user->username ?? $user->email ?? (string) $user->id),
12✔
72
            (string) $user->uuid,
12✔
73
            (string) ($user->username ?? $user->email ?? (string) $user->id),
12✔
74
        );
12✔
75

76
        $attachment = setting('AuthSecurity.webauthnAuthenticatorAttachment');
12✔
77
        $selection  = AuthenticatorSelectionCriteria::create(
12✔
78
            authenticatorAttachment: is_string($attachment) ? $attachment : null,
12✔
79
            userVerification: (string) setting('AuthSecurity.webauthnUserVerification'),
12✔
80
            residentKey: (string) setting('AuthSecurity.webauthnResidentKey'),
12✔
81
        );
12✔
82

83
        $options = PublicKeyCredentialCreationOptions::create(
12✔
84
            $rp,
12✔
85
            $userEntity,
12✔
86
            random_bytes(32),
12✔
87
            [
12✔
88
                PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256),
12✔
89
                PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS256),
12✔
90
            ],
12✔
91
            $selection,
12✔
92
            (string) setting('AuthSecurity.webauthnAttestationConveyance'),
12✔
93
            $this->repository->descriptorsForUser($user->id),
12✔
94
            (int) setting('AuthSecurity.webauthnTimeout'),
12✔
95
        );
12✔
96

97
        $json = $this->serializer->serialize($options, 'json');
12✔
98
        $this->challenges->store('register', $json, $user->id);
12✔
99
        $this->challenges->stashLabel($label);
12✔
100

101
        return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
12✔
102
    }
103

104
    public function finishRegistration(User $user, string $browserJson): WebAuthnCredential
12✔
105
    {
106
        $entry = $this->challenges->pull('register', $user->id);
12✔
107
        if ($entry === null) {
12✔
NEW
108
            throw new WebAuthnException(lang('Auth.webauthnChallengeExpired'));
×
109
        }
110

111
        try {
112
            /** @var PublicKeyCredentialCreationOptions $options */
113
            $options    = $this->serializer->deserialize($entry['options'], PublicKeyCredentialCreationOptions::class, 'json');
12✔
114
            $credential = $this->serializer->deserialize($browserJson, PublicKeyCredential::class, 'json');
12✔
115

116
            if (! $credential->response instanceof AuthenticatorAttestationResponse) {
12✔
NEW
117
                throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
×
118
            }
119

120
            $record = $this->attestationValidator->check($credential->response, $options, $this->rpId());
12✔
121

122
            // Reject a credential that is already stored before attempting the
123
            // insert, so the caller gets a clean conflict instead of a raw
124
            // UNIQUE-constraint DatabaseException leaking out. The UNIQUE index
125
            // on credential_id ignores revoked_at, so this checks all rows.
126
            if ($this->repository->existsByCredentialId($record)) {
11✔
127
                throw new WebAuthnDuplicateCredentialException(lang('Auth.webauthnDuplicate'));
11✔
128
            }
129
        } catch (WebAuthnException $e) {
3✔
130
            throw $e;
2✔
131
        } catch (Throwable $e) {
1✔
132
            log_message('warning', 'WebAuthn registration failed: {m}', ['m' => $e->getMessage()]);
1✔
133

134
            throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
1✔
135
        }
136

137
        return $this->repository->store($user->id, $record, $this->challenges->pullLabel());
11✔
138
    }
139

140
    /**
141
     * @return array<string, mixed> request options ready for JSON
142
     */
143
    public function startLogin(?string $email): array
8✔
144
    {
145
        $allow = [];
8✔
146
        if ($email !== null && $email !== '') {
8✔
147
            $user = model(UserModel::class)->findByCredentials(['email' => $email]);
1✔
148
            if ($user !== null) {
1✔
149
                $allow = $this->repository->descriptorsForUser($user->id); // empty stays usernameless / anti-enumeration
1✔
150
            }
151
        }
152

153
        $options = PublicKeyCredentialRequestOptions::create(
8✔
154
            random_bytes(32),
8✔
155
            $this->rpId(),
8✔
156
            $allow,
8✔
157
            (string) setting('AuthSecurity.webauthnUserVerification'),
8✔
158
            (int) setting('AuthSecurity.webauthnTimeout'),
8✔
159
        );
8✔
160

161
        $json = $this->serializer->serialize($options, 'json');
8✔
162
        $this->challenges->store('login', $json);
8✔
163

164
        return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
8✔
165
    }
166

167
    public function finishLogin(string $browserJson): User
7✔
168
    {
169
        $entry = $this->challenges->pull('login');
7✔
170
        if ($entry === null) {
7✔
171
            throw new WebAuthnException(lang('Auth.webauthnChallengeExpired'));
2✔
172
        }
173

174
        return $this->verifyAssertion($entry['options'], $browserJson, null);
6✔
175
    }
176

177
    /**
178
     * @return array<string, mixed> request options scoped to the pending user
179
     */
180
    public function startTwoFactor(User $pendingUser): array
1✔
181
    {
182
        $options = PublicKeyCredentialRequestOptions::create(
1✔
183
            random_bytes(32),
1✔
184
            $this->rpId(),
1✔
185
            $this->repository->descriptorsForUser($pendingUser->id),
1✔
186
            (string) setting('AuthSecurity.webauthnUserVerification'),
1✔
187
            (int) setting('AuthSecurity.webauthnTimeout'),
1✔
188
        );
1✔
189

190
        $json = $this->serializer->serialize($options, 'json');
1✔
191
        $this->challenges->store('2fa', $json, $pendingUser->id);
1✔
192

193
        return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
1✔
194
    }
195

196
    public function finishTwoFactor(User $pendingUser, string $browserJson): bool
2✔
197
    {
198
        $entry = $this->challenges->pull('2fa', $pendingUser->id);
2✔
199
        if ($entry === null) {
2✔
200
            return false;
1✔
201
        }
202

203
        try {
204
            $resolved = $this->verifyAssertion($entry['options'], $browserJson, $pendingUser->id);
1✔
205

206
            return (string) $resolved->id === (string) $pendingUser->id;
1✔
207
        } catch (WebAuthnException) {
1✔
208
            return false;
1✔
209
        }
210
    }
211

212
    /**
213
     * Shared assertion verification. Looks up the credential by rawId, runs the
214
     * library check (signature, challenge, origin, rpIdHash, UV, counter),
215
     * persists the advanced counter, and returns the owning user.
216
     *
217
     * @param int|string|null $requireUserId when set, the credential must belong to this user
218
     */
219
    private function verifyAssertion(string $optionsJson, string $browserJson, int|string|null $requireUserId): User
7✔
220
    {
221
        try {
222
            /** @var PublicKeyCredentialRequestOptions $options */
223
            $options    = $this->serializer->deserialize($optionsJson, PublicKeyCredentialRequestOptions::class, 'json');
7✔
224
            $credential = $this->serializer->deserialize($browserJson, PublicKeyCredential::class, 'json');
7✔
225

226
            if (! $credential->response instanceof AuthenticatorAssertionResponse) {
7✔
NEW
227
                throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
×
228
            }
229

230
            $credentialId = rtrim(strtr(base64_encode($credential->rawId), '+/', '-_'), '=');
7✔
231
            $userId       = $this->repository->userIdForCredentialId($credentialId);
7✔
232

233
            if ($userId === null || ($requireUserId !== null && (string) $userId !== (string) $requireUserId)) {
7✔
234
                throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
3✔
235
            }
236

237
            $record = $this->repository->findRecordByCredentialId($credentialId);
5✔
238
            if ($record === null) {
5✔
NEW
239
                throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
×
240
            }
241

242
            $updated = $this->assertionValidator->check(
5✔
243
                $record,
5✔
244
                $credential->response,
5✔
245
                $options,
5✔
246
                $this->rpId(),
5✔
247
                $record->userHandle,
5✔
248
            );
5✔
249
        } catch (WebAuthnException $e) {
4✔
250
            throw $e;
3✔
251
        } catch (Throwable $e) {
1✔
252
            log_message('warning', 'WebAuthn assertion failed: {m}', ['m' => $e->getMessage()]);
1✔
253

254
            throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
1✔
255
        }
256

257
        $this->repository->updateCounter($updated);
5✔
258

259
        /** @var User|null $user */
260
        $user = model(UserModel::class)->find($userId);
5✔
261
        if ($user === null) {
5✔
NEW
262
            throw new WebAuthnException(lang('Auth.webauthnVerificationFailed'));
×
263
        }
264

265
        return $user;
5✔
266
    }
267
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc