Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasskeyPrimaryAuthenticationProvider
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 8
462
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 beginPrimaryAuthentication
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 accountCreationType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginPrimaryAccountCreation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 testUserExists
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 providerAllowsAuthenticationDataChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 providerChangeAuthenticationData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types=1 );
3
4namespace MediaWiki\Extension\OATHAuth\Auth;
5
6use BadMethodCallException;
7use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
8use MediaWiki\Auth\AuthenticationRequest;
9use MediaWiki\Auth\AuthenticationResponse;
10use MediaWiki\Auth\AuthManager;
11use MediaWiki\Extension\OATHAuth\OATHAuthLogger;
12use MediaWiki\Extension\OATHAuth\OATHUserRepository;
13use MediaWiki\Extension\OATHAuth\WebAuthnAuthenticator;
14use MediaWiki\Status\Status;
15use MediaWiki\User\UserFactory;
16
17class PasskeyPrimaryAuthenticationProvider extends AbstractPrimaryAuthenticationProvider {
18
19    public function __construct(
20        private readonly WebAuthnAuthenticator $webAuthnAuthenticator,
21        private readonly OATHUserRepository $userRepo,
22        private readonly OATHAuthLogger $oathLogger,
23        private readonly UserFactory $userFactory
24    ) {
25    }
26
27    /** @inheritDoc */
28    public function getAuthenticationRequests( $action, array $options ) {
29        if (
30            !$this->config->get( 'OATHPasswordlessLogin' ) ||
31            $action !== AuthManager::ACTION_LOGIN
32        ) {
33            return [];
34        }
35
36        $user = null;
37        if ( isset( $options['securityLevel'] ) ) {
38            // Reauthentication. Generate authInfo specific to this user's keys
39            $user = $this->userRepo->findByUser( $this->manager->getRequest()->getSession()->getUser() );
40            if ( !$user->hasPasswordlessKeys() ) {
41                // This user doesn't have any keys that support passwordless login, so don't offer it
42                return [];
43            }
44        }
45
46        $authStatus = $this->webAuthnAuthenticator->startPasswordlessAuthentication( $user );
47        if ( !$authStatus->isGood() ) {
48            return [];
49        }
50
51        // TODO make AuthenticationRequest::getUsernameFromRequests() recognize the username
52        // from this, so that ThrottlePreAuthenticationProvider will apply the password throttle
53        return [ new WebAuthnAuthenticationRequest( $authStatus->getValue()['json'], [
54            'isReauth' => isset( $options['securityLevel'] ),
55            'showPrompt' => false,
56            'showButton' => $user === null ? false : 'passwordless'
57        ] ) ];
58    }
59
60    /** @inheritDoc */
61    public function beginPrimaryAuthentication( array $reqs ) {
62        $req = AuthenticationRequest::getRequestByClass( $reqs, WebAuthnAuthenticationRequest::class );
63        if ( !$req || $req->credential === '' ) {
64            return AuthenticationResponse::newAbstain();
65        }
66
67        $oathUser = $this->webAuthnAuthenticator->determineUser( $req->credential );
68        if ( !$oathUser ) {
69            return AuthenticationResponse::newFail(
70                wfMessage( 'oathauth-webauthn-error-passkey-verification-failed' )
71            );
72        }
73        $user = $this->userFactory->newFromUserIdentity( $oathUser->getUser() );
74
75        // Check for (and increment) rate limiter before doing the auth
76        if ( $user->pingLimiter( 'badoath' ) || $user->pingLimiter( 'badoath-long' ) ) {
77            return AuthenticationResponse::newFail(
78                wfMessage( 'oathauth-throttled' )
79            );
80        }
81
82        $authResult = $this->webAuthnAuthenticator->continueAuthentication( $oathUser, $req->credential );
83        if ( $authResult->isGood() ) {
84            $this->logger->info( 'OATHAuth user {user} completed passwordless login from {clientip}', [
85                'user'     => $user->getName(),
86                'clientip' => $user->getRequest()->getIP(),
87            ] );
88            $this->oathLogger->logSuccessfulVerification( $user );
89            // Record the fact that the user logged in with a passkey, so that
90            // our SecondaryAuthenticationProvider will skip the 2FA step
91            $this->manager->setAuthenticationSessionData( SecondaryAuthenticationProvider::SUCCESS_KEY, true );
92            return AuthenticationResponse::newPass( $user->getName() );
93        }
94
95        $this->logger->info( 'OATHAuth user {user} failed passwordless login from {clientip}', [
96            'user'     => $user->getName(),
97            'clientip' => $user->getRequest()->getIP(),
98        ] );
99
100        $this->oathLogger->logFailedVerification( $user );
101
102        $messages = $authResult->getMessages();
103        // Return the first error from the authenticator, if there is any
104        $failureMessage = $messages ? wfMessage( $messages[0] ) :
105            wfMessage( 'oathauth-webauthn-error-passkey-verification-failed' );
106
107        return AuthenticationResponse::newFail( $failureMessage );
108    }
109
110    /** @inheritDoc */
111    public function accountCreationType() {
112        return self::TYPE_NONE;
113    }
114
115    /** @inheritDoc */
116    public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
117        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
118        throw new BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' );
119    }
120
121    /** @inheritDoc */
122    public function testUserExists( $username, $flags = \Wikimedia\Rdbms\IDBAccessObject::READ_NORMAL ) {
123        // We rely on other primary authentication providers to manage user existence
124        return false;
125    }
126
127    /** @inheritDoc */
128    public function providerAllowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
129        // Not supported
130        return Status::newGood( 'ignored' );
131    }
132
133    /** @inheritDoc */
134    public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
135        // Do nothing; passkeys can't be managed this way
136    }
137}