Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.09% covered (danger)
3.09%
8 / 259
9.09% covered (danger)
9.09%
2 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebAuthnAuthenticator
3.09% covered (danger)
3.09%
8 / 259
9.09% covered (danger)
9.09%
2 / 22
2609.66
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
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canAuthenticate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 canRegister
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 startAuthentication
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 startPasswordlessAuthentication
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 startAuthenticationInternal
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 determineUser
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 continueAuthentication
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 startRegistration
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 continueRegistration
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
42
 getChallengeFromCredential
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 addPendingRequest
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 clearPendingRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPendingRequests
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getPendingRequestWithChallenge
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filterExpiredRequests
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthInfo
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getRegisterInfo
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
12
 getServerId
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 getServerName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3/**
4 * @license GPL-2.0-or-later
5 */
6
7namespace MediaWiki\Extension\OATHAuth;
8
9use Cose\Algorithms;
10use Exception;
11use MediaWiki\Auth\AuthManager;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\Extension\OATHAuth\HTMLForm\KeySessionStorageTrait;
14use MediaWiki\Extension\OATHAuth\Key\WebAuthnKey;
15use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
16use MediaWiki\Extension\OATHAuth\Module\WebAuthn;
17use MediaWiki\Request\WebRequest;
18use MediaWiki\Status\Status;
19use MediaWiki\User\UserFactory;
20use MediaWiki\Utils\UrlUtils;
21use MediaWiki\WikiMap\WikiMap;
22use Psr\Log\LoggerInterface;
23use Throwable;
24use Webauthn\AuthenticatorAssertionResponse;
25use Webauthn\AuthenticatorSelectionCriteria;
26use Webauthn\PublicKeyCredential;
27use Webauthn\PublicKeyCredentialCreationOptions;
28use Webauthn\PublicKeyCredentialDescriptor;
29use Webauthn\PublicKeyCredentialOptions;
30use Webauthn\PublicKeyCredentialParameters;
31use Webauthn\PublicKeyCredentialRequestOptions;
32use Webauthn\PublicKeyCredentialRpEntity;
33use Webauthn\PublicKeyCredentialUserEntity;
34
35/**
36 * This class serves as an authentication/registration
37 * proxy, connecting the users to their keys and carrying out
38 * the authentication process
39 */
40class WebAuthnAuthenticator {
41
42    use KeySessionStorageTrait;
43
44    private const string SESSION_KEY = 'webauthn_session_data';
45
46    /** 5 minutes in ms */
47    private const int CLIENT_ACTION_TIMEOUT = 300000;
48
49    private const int MAX_ACTIVE_CHALLENGES = 5;
50
51    private ?string $serverId;
52
53    public function __construct(
54        private readonly OATHUserRepository $userRepo,
55        private readonly WebAuthn $module,
56        private readonly RecoveryCodes $recoveryCodesModule,
57        private readonly OATHAuthLogger $oathLogger,
58        private readonly IContextSource $context,
59        private readonly LoggerInterface $logger,
60        private readonly AuthManager $authManager,
61        private readonly UrlUtils $urlUtils,
62        private readonly UserFactory $userFactory,
63    ) {
64        $this->serverId = $this->getServerId();
65    }
66
67    public function isEnabled( OATHUser $user ): bool {
68        return $this->module->isEnabled( $user );
69    }
70
71    public function getRequest(): WebRequest {
72        return $this->authManager->getRequest();
73    }
74
75    public function canAuthenticate( OATHUser $user ): Status {
76        if ( !$this->isEnabled( $user ) ) {
77            return Status::newFatal(
78                'oathauth-webauthn-error-module-not-enabled',
79                $this->module->getName(),
80                $user->getUser()->getName()
81            );
82        }
83
84        return Status::newGood();
85    }
86
87    public function canRegister( OATHUser $user ): Status {
88        if ( $this->userFactory->newFromUserIdentity( $user->getUser() )->isAllowed( 'oathauth-enable' ) ) {
89            return Status::newGood();
90        }
91
92        return Status::newFatal(
93            'oathauth-webauthn-error-cannot-register',
94            $user->getUser()->getName()
95        );
96    }
97
98    public function startAuthentication( OATHUser $user ): Status {
99        return $this->startAuthenticationInternal( $user, false );
100    }
101
102    /**
103     * Initiate a new passwordless authentication session.
104     *
105     * This returns a credential request that is not specific to any given user, unless $user is set.
106     * @param ?OATHUser $user If set, make the credential request specific to this user's keys.
107     *   Only use this when reauthenticating a user who is already logged in.
108     */
109    public function startPasswordlessAuthentication( ?OATHUser $user = null ): Status {
110        return $this->startAuthenticationInternal( $user, true );
111    }
112
113    private function startAuthenticationInternal( ?OATHUser $user, bool $userVerificationRequired ): Status {
114        if ( $user ) {
115            $canAuthenticate = $this->canAuthenticate( $user );
116            if ( !$canAuthenticate->isGood() ) {
117                $this->logger->error(
118                    "User {$user->getUser()->getName()} cannot authenticate"
119                );
120                return $canAuthenticate;
121            }
122        }
123        $authInfo = $this->getAuthInfo( $user, $userVerificationRequired );
124        $this->addPendingRequest( $authInfo );
125
126        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
127
128        return Status::newGood( [
129            'json' => $serializer->serialize( $authInfo, 'json' ),
130            'raw' => $authInfo
131        ] );
132    }
133
134    public function determineUser( string $credential ): ?OATHUser {
135        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
136        try {
137            $publicKeyCredential = $serializer->deserialize(
138                $credential,
139                PublicKeyCredential::class,
140                'json',
141            );
142        } catch ( Throwable ) {
143            return null;
144        }
145        $response = $publicKeyCredential->response;
146        if ( !$response instanceof AuthenticatorAssertionResponse ) {
147            return null;
148        }
149        $userHandle = $response->userHandle;
150        if ( $userHandle === null ) {
151            return null;
152        }
153        return $this->userRepo->findByUserHandle( $userHandle );
154    }
155
156    public function continueAuthentication(
157        OATHUser $user,
158        string $credential
159    ): Status {
160        $canAuthenticate = $this->canAuthenticate( $user );
161        if ( !$canAuthenticate->isGood() ) {
162            $this->logger->error(
163                "User {$user->getUser()->getName()} lost authenticate ability mid-request"
164            );
165            return $canAuthenticate;
166        }
167
168        $request = $this->getPendingRequestWithChallenge(
169            PublicKeyCredentialRequestOptions::class,
170            $this->getChallengeFromCredential( $credential )
171        );
172        $this->clearPendingRequests( PublicKeyCredentialRequestOptions::class );
173        if ( $request === null ) {
174            $this->oathLogger->logFailedVerification( $user->getUser() );
175            return Status::newFatal( 'oathauth-webauthn-error-verification-failed' );
176        }
177
178        $verificationData = [
179            'authInfo' => $request,
180            'credential' => $credential
181        ];
182
183        if ( $this->module->verify( $user, $verificationData ) ) {
184            $this->logger->info(
185                "User {$user->getUser()->getName()} logged in using WebAuthn"
186            );
187            return Status::newGood( $user );
188        }
189        $this->logger->warning(
190            "Webauthn login failed for user {$user->getUser()->getName()}"
191        );
192
193        $this->oathLogger->logFailedVerification( $user->getUser() );
194
195        return Status::newFatal( 'oathauth-webauthn-error-verification-failed' );
196    }
197
198    public function startRegistration( OATHUser $user, bool $passkeyMode = false ): Status {
199        $canRegister = $this->canRegister( $user );
200        if ( !$canRegister->isGood() ) {
201            $this->logger->error(
202                "User {$user->getUser()->getName()} cannot register a credential"
203            );
204            return $canRegister;
205        }
206        $registerInfo = $this->getRegisterInfo( $user, $passkeyMode );
207        $this->addPendingRequest( $registerInfo );
208
209        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
210
211        return Status::newGood( [
212            'json' => $serializer->serialize( $registerInfo, 'json' ),
213            'raw' => $registerInfo
214        ] );
215    }
216
217    public function continueRegistration(
218        OATHUser $user, string $credential, string $friendlyName = '', bool $passkeyMode = false
219    ): Status {
220        $canRegister = $this->canRegister( $user );
221        if ( !$canRegister->isGood() ) {
222            $username = $user->getUser()->getName();
223            $this->logger->error(
224                "User $username lost registration ability mid-request"
225            );
226            return $canRegister;
227        }
228
229        $registerInfo = $this->getPendingRequestWithChallenge(
230            PublicKeyCredentialCreationOptions::class,
231            $this->getChallengeFromCredential( $credential )
232        );
233        if ( $registerInfo === null ) {
234            return Status::newFatal( 'oathauth-webauthn-error-registration-failed' );
235        }
236
237        $key = $this->module->newKey();
238        try {
239            $registered = $key->verifyRegistration(
240                $friendlyName,
241                $credential,
242                $registerInfo,
243                $user
244            );
245            if ( $passkeyMode ) {
246                $key->setPasswordlessSupport( true );
247            }
248            if ( $registered ) {
249                $this->userRepo->createKey(
250                    $user,
251                    $this->module,
252                    $key->jsonSerialize(),
253                    $this->getRequest()->getIP()
254                );
255
256                $this->recoveryCodesModule->ensureExistence(
257                    $user,
258                    $this->getKeyDataInSession( 'RecoveryCodeKeys' )
259                );
260
261                $this->clearPendingRequests( PublicKeyCredentialCreationOptions::class );
262                return Status::newGood();
263            }
264        } catch ( Exception $ex ) {
265            $this->logger->warning( 'WebAuthn registration failed due to: {message}', [
266                'message' => $ex->getMessage(),
267                'exception' => $ex,
268                'user' => $user->getUser()->getName(),
269            ] );
270            return Status::newFatal( 'oathauth-webauthn-error-registration-failed' );
271        }
272        return Status::newFatal( 'oathauth-webauthn-error-registration-failed' );
273    }
274
275    private function getChallengeFromCredential( string $credential ): string {
276        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
277        $publicKeyCredential = $serializer->deserialize(
278            $credential,
279            PublicKeyCredential::class,
280            'json',
281        );
282        return $publicKeyCredential->response->clientDataJSON->challenge;
283    }
284
285    private function addPendingRequest( PublicKeyCredentialOptions $data ) {
286        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
287        $sessionKey = self::SESSION_KEY . '_' . $data::class;
288        $requests = $this->getRequest()->getSession()->getSecret( $sessionKey ) ?? [];
289        $requests[] = [
290            'json' => $serializer->serialize( $data, 'json' ),
291            'expires' => time() + ceil( $data->timeout / 1000 )
292        ];
293        $requests = $this->filterExpiredRequests( $requests );
294        $this->getRequest()->getSession()->setSecret( $sessionKey, $requests );
295    }
296
297    private function clearPendingRequests( string $returnClass ) {
298        $this->getRequest()->getSession()->remove( self::SESSION_KEY . '_' . $returnClass );
299    }
300
301    /**
302     * @template T of PublicKeyCredentialOptions
303     * @param class-string<T> $returnClass
304     * @return T[]
305     */
306    private function getPendingRequests( string $returnClass ): array {
307        $sessionKey = self::SESSION_KEY . '_' . $returnClass;
308        $requests = $this->getRequest()->getSession()->getSecret( $sessionKey );
309        if ( $requests === null ) {
310            return [];
311        }
312        $filteredRequests = $this->filterExpiredRequests( $requests );
313        if ( count( $filteredRequests ) < count( $requests ) ) {
314            $this->getRequest()->getSession()->setSecret( $sessionKey, $filteredRequests );
315        }
316        $serializer = ( new WebAuthnSerializerFactory( WebAuthnKey::getAttestationSupportManager() ) )->create();
317        return array_map(
318            static fn ( $r ) => $serializer->deserialize( $r['json'], $returnClass, 'json' ),
319            $filteredRequests
320        );
321    }
322
323    /**
324     * @template T of PublicKeyCredentialOptions
325     * @param class-string<T> $returnClass
326     * @param string $challenge
327     * @return T|null
328     */
329    private function getPendingRequestWithChallenge( string $returnClass, string $challenge ) {
330        $requests = $this->getPendingRequests( $returnClass );
331        return array_find( $requests, static fn ( $request ) => $request->challenge === $challenge );
332    }
333
334    private function filterExpiredRequests( array $requests ): array {
335        $now = time();
336        return array_slice(
337            array_filter( $requests, static fn ( $r ) => $r['expires'] >= $now ),
338            -self::MAX_ACTIVE_CHALLENGES
339        );
340    }
341
342    /**
343     * Information to be sent to the client to start the authentication process
344     */
345    private function getAuthInfo( ?OATHUser $user, bool $userVerificationRequired ): PublicKeyCredentialRequestOptions {
346        $keys = $user ? WebAuthn::getWebAuthnKeys( $user ) : [];
347        $credentialDescriptors = [];
348        foreach ( $keys as $key ) {
349            // If user verification is required, skip keys that don't support UV
350            if ( $userVerificationRequired && !$key->supportsPasswordlessLogin() ) {
351                continue;
352            }
353            $credentialDescriptors[] = new PublicKeyCredentialDescriptor(
354                $key->getType(),
355                $key->getAttestedCredentialData()->credentialId,
356                $key->getTransports()
357            );
358        }
359
360        return PublicKeyCredentialRequestOptions::create(
361            random_bytes( 32 ),
362            $this->serverId,
363            $credentialDescriptors,
364            $userVerificationRequired ?
365                PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED :
366                PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
367            self::CLIENT_ACTION_TIMEOUT
368        );
369    }
370
371    /**
372     * Information to be sent to the client to start the registration process
373     */
374    private function getRegisterInfo(
375        OATHUser $user,
376        bool $passkeyMode = false
377    ): PublicKeyCredentialCreationOptions {
378        $serverName = $this->getServerName();
379        $rpEntity = new PublicKeyCredentialRpEntity( $serverName, $this->serverId );
380
381        // Exclude all already registered keys for user
382        /** @var WebAuthnKey[] $webauthnKeys */
383        $webauthnKeys = $user->getKeysForModule( WebAuthn::MODULE_ID );
384        '@phan-var WebAuthnKey[] $webauthnKeys';
385        $excludedPublicKeyDescriptors = array_map( static fn ( $key ) => new PublicKeyCredentialDescriptor(
386            PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
387            $key->getAttestedCredentialData()->credentialId
388        ), $webauthnKeys );
389
390        $mwUser = $this->userFactory->newFromUserIdentity( $user->getUser() );
391        $userHandle = $user->getUserHandle() ?? random_bytes( 64 );
392        $realName = $mwUser->getRealName() ?: $mwUser->getName();
393        $userEntity = new PublicKeyCredentialUserEntity(
394            $mwUser->getName(),
395            $userHandle,
396            $realName
397        );
398
399        $publicKeyCredParametersList = [
400            new PublicKeyCredentialParameters(
401                'public-key',
402                Algorithms::COSE_ALGORITHM_ES256
403            ),
404            new PublicKeyCredentialParameters(
405                'public-key',
406                Algorithms::COSE_ALGORITHM_ES512
407            ),
408            new PublicKeyCredentialParameters(
409                'public-key',
410                Algorithms::COSE_ALGORITHM_EDDSA
411            ),
412            new PublicKeyCredentialParameters(
413                'public-key',
414                Algorithms::COSE_ALGORITHM_RS1
415            ),
416            new PublicKeyCredentialParameters(
417                'public-key',
418                Algorithms::COSE_ALGORITHM_RS256
419            ),
420            new PublicKeyCredentialParameters(
421                'public-key',
422                Algorithms::COSE_ALGORITHM_RS512
423            ),
424        ];
425
426        if ( $passkeyMode ) {
427            $authSelectorCriteria = AuthenticatorSelectionCriteria::create(
428                userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
429                residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
430            );
431        } else {
432            $authSelectorCriteria = AuthenticatorSelectionCriteria::create(
433                authenticatorAttachment: AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM,
434            );
435        }
436
437        return PublicKeyCredentialCreationOptions::create(
438            $rpEntity,
439            $userEntity,
440            random_bytes( 32 ),
441            $publicKeyCredParametersList,
442            $authSelectorCriteria,
443            PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
444            $excludedPublicKeyDescriptors,
445            self::CLIENT_ACTION_TIMEOUT
446        );
447    }
448
449    /**
450     * Get identifier for this server
451     */
452    private function getServerId(): ?string {
453        $rpId = $this->context->getConfig()->get( 'WebAuthnRelyingPartyID' );
454        if ( $rpId && is_string( $rpId ) ) {
455            return $rpId;
456        }
457
458        $server = $this->context->getConfig()->get( 'Server' );
459        $serverBits = $this->urlUtils->parse( $server );
460        if ( $serverBits !== null ) {
461            return $serverBits['host'];
462        }
463
464        return null;
465    }
466
467    /**
468     * Get the name for this server
469     */
470    private function getServerName(): string {
471        $serverName = $this->context->getConfig()->get( 'WebAuthnRelyingPartyName' );
472        if ( $serverName && is_string( $serverName ) ) {
473            return $serverName;
474        }
475        if ( $this->context->getConfig()->has( 'Sitename' ) ) {
476            return $this->context->getConfig()->get( 'Sitename' );
477        }
478
479        return WikiMap::getCurrentWikiId();
480    }
481}