Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.22% covered (danger)
13.22%
30 / 227
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Authenticator
13.22% covered (danger)
13.22%
30 / 227
18.75% covered (danger)
18.75%
3 / 16
1751.05
0.00% covered (danger)
0.00%
0 / 1
 factory
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
7 / 7
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
 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 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 continueAuthentication
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 startRegistration
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 continueRegistration
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
110
 setSessionData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 clearSessionData
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getSessionData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getAuthInfo
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getRegisterInfo
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
30
 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 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License along
15 * with this program; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 * http://www.gnu.org/copyleft/gpl.html
18 */
19
20namespace MediaWiki\Extension\WebAuthn;
21
22use Cose\Algorithms;
23use FormatJson;
24use IContextSource;
25use MediaWiki\Config\ConfigException;
26use MediaWiki\Extension\OATHAuth\IModule;
27use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
28use MediaWiki\Extension\OATHAuth\OATHUser;
29use MediaWiki\Extension\OATHAuth\OATHUserRepository;
30use MediaWiki\Extension\WebAuthn\Key\WebAuthnKey;
31use MediaWiki\Extension\WebAuthn\Module\WebAuthn;
32use MediaWiki\Logger\LoggerFactory;
33use MediaWiki\MediaWikiServices;
34use MediaWiki\Request\WebRequest;
35use MediaWiki\Status\Status;
36use MediaWiki\User\User;
37use MediaWiki\WikiMap\WikiMap;
38use MWException;
39use Psr\Log\LoggerInterface;
40use RequestContext;
41use stdClass;
42use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
43use Webauthn\AuthenticatorSelectionCriteria;
44use Webauthn\PublicKeyCredentialCreationOptions;
45use Webauthn\PublicKeyCredentialDescriptor;
46use Webauthn\PublicKeyCredentialParameters;
47use Webauthn\PublicKeyCredentialRequestOptions;
48use Webauthn\PublicKeyCredentialRpEntity;
49use Webauthn\PublicKeyCredentialUserEntity;
50
51/**
52 * This class serves as an authentication/registration
53 * proxy, connecting the users to their keys and carrying out
54 * the authentication process
55 */
56class Authenticator {
57    private const SESSION_KEY = 'webauthn_session_data';
58
59    // 60 sec
60    private const CLIENT_ACTION_TIMEOUT = 60000;
61
62    /**
63     * @var string
64     */
65    protected $serverId = '';
66
67    /**
68     * @var OATHUserRepository
69     */
70    protected $userRepo;
71
72    /**
73     * @var WebAuthn
74     */
75    protected $module;
76
77    /**
78     * @var OATHUser
79     */
80    protected $oathUser;
81
82    /**
83     * @var LoggerInterface
84     */
85    protected $logger;
86
87    /**
88     * @var WebRequest
89     */
90    protected $request;
91
92    /**
93     * @var IContextSource
94     */
95    protected $context;
96
97    /**
98     * @param User $user
99     * @param WebRequest|null $request
100     * @return Authenticator
101     * @throws ConfigException
102     * @throws MWException
103     */
104    public static function factory( $user, $request = null ) {
105        /** @var OATHAuthModuleRegistry $moduleRegistry */
106        $moduleRegistry = MediaWikiServices::getInstance()->getService( 'OATHAuthModuleRegistry' );
107        /** @var OATHUserRepository $userRepo */
108        $userRepo = MediaWikiServices::getInstance()->getService( 'OATHUserRepository' );
109        /** @var WebAuthn $module */
110        $module = $moduleRegistry->getModuleByKey( 'webauthn' );
111        $oathUser = $userRepo->findByUser( $user );
112        $context = RequestContext::getMain();
113        $logger = LoggerFactory::getInstance( 'authentication' );
114        if ( $request === null ) {
115            $request = RequestContext::getMain()->getRequest();
116        }
117
118        return new static(
119            $userRepo,
120            $module,
121            $oathUser,
122            $context,
123            $logger,
124            $request
125        );
126    }
127
128    /**
129     * @param OATHUserRepository $userRepo
130     * @param IModule $module
131     * @param OATHUser|null $oathUser
132     * @param IContextSource $context
133     * @param LoggerInterface $logger
134     * @param WebRequest $request
135     * @throws ConfigException
136     */
137    protected function __construct( $userRepo, $module, $oathUser, $context, $logger, $request ) {
138        $this->userRepo = $userRepo;
139        $this->module = $module;
140        $this->oathUser = $oathUser;
141        $this->context = $context;
142        $this->logger = $logger;
143        $this->request = $request;
144        $this->serverId = $this->getServerId();
145    }
146
147    /**
148     * @return bool
149     */
150    public function isEnabled() {
151        return $this->module->isEnabled( $this->oathUser );
152    }
153
154    /**
155     * @return Status
156     */
157    public function canAuthenticate() {
158        if ( !$this->isEnabled() ) {
159            return Status::newFatal(
160                'webauthn-error-module-not-enabled',
161                $this->module->getName(),
162                $this->oathUser->getUser()->getName()
163            );
164        }
165
166        return Status::newGood();
167    }
168
169    /**
170     * @return Status
171     */
172    public function canRegister() {
173        if ( $this->oathUser->getUser()->isAllowed( 'oathauth-enable' ) ) {
174            return Status::newGood();
175        }
176
177        return Status::newFatal(
178            'webauthn-error-cannot-register',
179            $this->oathUser->getUser()->getName()
180        );
181    }
182
183    /**
184     * @return Status
185     * @throws MWException
186     */
187    public function startAuthentication() {
188        $canAuthenticate = $this->canAuthenticate();
189        if ( !$canAuthenticate->isGood() ) {
190            $this->logger->error(
191                "User {$this->oathUser->getUser()->getName()} cannot authenticate"
192            );
193            return $canAuthenticate;
194        }
195        $authInfo = $this->getAuthInfo();
196        $this->setSessionData( $authInfo );
197
198        return Status::newGood( [
199            'json' => json_encode(
200                $authInfo,
201                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
202            ),
203            'raw' => $authInfo
204        ] );
205    }
206
207    /**
208     * @param array $verificationData
209     * @param PublicKeyCredentialRequestOptions|null $authInfo
210     * @return Status
211     */
212    public function continueAuthentication( array $verificationData, $authInfo = null ) {
213        $canAuthenticate = $this->canAuthenticate();
214        if ( !$canAuthenticate->isGood() ) {
215            $this->logger->error(
216                "User {$this->oathUser->getUser()->getName()} lost authenticate ability mid-request"
217            );
218            return $canAuthenticate;
219        }
220
221        if ( $authInfo === null ) {
222            $authInfo = $this->getSessionData(
223                PublicKeyCredentialRequestOptions::class
224            );
225        }
226        $verificationData['authInfo'] = $authInfo;
227        $this->clearSessionData();
228
229        if ( $this->module->verify( $this->oathUser, $verificationData ) ) {
230            $this->logger->info(
231                "User {$this->oathUser->getUser()->getName()} logged in using WebAuthn"
232            );
233            return Status::newGood( $this->oathUser );
234        }
235        $this->logger->warning(
236            "Webauthn login failed for user {$this->oathUser->getUser()->getName()}"
237        );
238        return Status::newFatal( 'webauthn-error-verification-failed' );
239    }
240
241    /**
242     * @return Status
243     * @throws ConfigException
244     */
245    public function startRegistration() {
246        $canRegister = $this->canRegister();
247        if ( !$canRegister->isGood() ) {
248            $this->logger->error(
249                "User {$this->oathUser->getUser()->getName()} cannot register a credential"
250            );
251            return $canRegister;
252        }
253        $registerInfo = $this->getRegisterInfo();
254        $this->setSessionData( $registerInfo );
255
256        return Status::newGood( [
257            'json' => json_encode(
258                $registerInfo,
259                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
260            ),
261            'raw' => $registerInfo
262        ] );
263    }
264
265    /**
266     * @param stdClass $credential
267     * @param PublicKeyCredentialCreationOptions|null $registerInfo
268     * @return Status
269     * @throws ConfigException
270     */
271    public function continueRegistration( $credential, $registerInfo = null ) {
272        $canRegister = $this->canRegister();
273        if ( !$canRegister->isGood() ) {
274            $username = $this->oathUser->getUser()->getName();
275            $this->logger->error(
276                "User $username lost registration ability mid-request"
277            );
278            return $canRegister;
279        }
280
281        if ( $registerInfo === null ) {
282            $registerInfo = $this->getSessionData(
283                PublicKeyCredentialCreationOptions::class
284            );
285            if ( $registerInfo === null ) {
286                return Status::newFatal( 'webauthn-error-registration-failed' );
287            }
288        }
289
290        $key = $this->module->newKey();
291        if ( !( $key instanceof WebAuthnKey ) ) {
292            $this->logger->error(
293                'New Webauthn key registration failed due to invalid key instance'
294            );
295            return Status::newFatal( 'webauthn-error-invalid-new-key' );
296        }
297
298        $friendlyName = $credential->friendlyName;
299        $data = FormatJson::encode( $credential );
300        try {
301            $registered = $key->verifyRegistration(
302                $friendlyName,
303                $data,
304                $registerInfo,
305                $this->oathUser
306            );
307            if ( $registered ) {
308                $maxKeysPerUser = $this->module->getConfig()->get( 'maxKeysPerUser' );
309                if ( count( $this->oathUser->getKeys() ) >= (int)$maxKeysPerUser ) {
310                    return Status::newFatal(
311                        wfMessage( 'webauthn-error-max-keys-reached', $maxKeysPerUser )
312                    );
313                }
314
315                // If user has another module already activated, clear all keys for than module
316                $userModule = $this->oathUser->getModule();
317                if ( $userModule !== null && !$userModule instanceof WebAuthn ) {
318                    // TODO: find a way of doing this without using persist(), but
319                    // without sending broken 'you have disabled two-factor authentication'
320                    // notifications. (Or, just add support for multiple different types of
321                    // authentication so we don't have to worry about this at all.)
322                    $this->oathUser->disable();
323                    $this->userRepo->persist( $this->oathUser, $this->request->getIP() );
324                }
325
326                $this->userRepo->createKey(
327                    $this->oathUser,
328                    $this->module,
329                    $key->jsonSerialize(),
330                    $this->request->getIP()
331                );
332
333                $this->clearSessionData();
334                return Status::newGood();
335            }
336        } catch ( MWException $exception ) {
337            return Status::newFatal( $exception->getMessage() );
338        }
339        return Status::newFatal( 'webauthn-error-registration-failed' );
340    }
341
342    /**
343     * @param PublicKeyCredentialRequestOptions|PublicKeyCredentialCreationOptions $data
344     */
345    private function setSessionData( $data ) {
346        $session = $this->request->getSession();
347        $authData = $session->getSecret( 'authData' );
348        if ( !is_array( $authData ) ) {
349            $authData = [];
350        }
351        $authData[static::SESSION_KEY] = FormatJson::encode( $data );
352        $session->setSecret( 'authData', $authData );
353    }
354
355    private function clearSessionData() {
356        $session = $this->request->getSession();
357        $authData = $session->getSecret( 'authData' );
358        if ( is_array( $authData ) && array_key_exists( static::SESSION_KEY, $authData ) ) {
359            unset( $authData[static::SESSION_KEY] );
360            $session->setSecret( 'authData', $authData );
361        }
362    }
363
364    /**
365     * @param string $returnClass
366     * @return PublicKeyCredentialRequestOptions|PublicKeyCredentialCreationOptions|null
367     */
368    private function getSessionData( $returnClass ) {
369        $authData = $this->request->getSession()->getSecret( 'authData' );
370        if ( !is_array( $authData ) ) {
371            return null;
372        }
373        if ( array_key_exists( static::SESSION_KEY, $authData ) ) {
374            $json = $authData[static::SESSION_KEY];
375            $factory = [ $returnClass, 'createFromString' ];
376            if ( !is_callable( $factory ) ) {
377                return null;
378            }
379            return call_user_func_array( $factory, [ $json ] );
380        }
381        return null;
382    }
383
384    /**
385     * Information to be sent to the client to start the authentication process
386     *
387     * @return PublicKeyCredentialRequestOptions
388     * @throws MWException
389     */
390    protected function getAuthInfo() {
391        $keys = $this->oathUser->getKeys();
392        $credentialDescriptors = [];
393        foreach ( $keys as $key ) {
394            if ( !$key instanceof WebAuthnKey ) {
395                throw new MWException( 'webauthn-key-type-missmatch' );
396            }
397            $credentialDescriptors[$key->getFriendlyName()] = new PublicKeyCredentialDescriptor(
398                $key->getType(),
399                $key->getAttestedCredentialData()->getCredentialId(),
400                $key->getTransports()
401            );
402        }
403
404        return new PublicKeyCredentialRequestOptions(
405            random_bytes( 32 ),
406            static::CLIENT_ACTION_TIMEOUT,
407            $this->serverId,
408            $credentialDescriptors,
409            PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
410            new AuthenticationExtensionsClientInputs()
411        );
412    }
413
414    /**
415     * Information to be sent to the client to start the registration process
416     *
417     * @return PublicKeyCredentialCreationOptions
418     * @throws ConfigException
419     */
420    protected function getRegisterInfo() {
421        $serverName = $this->getServerName();
422        $rpEntity = new PublicKeyCredentialRpEntity( $serverName, $this->serverId );
423
424        $mwUser = $this->context->getUser();
425
426        // Exclude all already registered keys for user
427        $excludedPublicKeyDescriptors = [];
428
429        // If the user already has webauthn enabled, and is just registering another key,
430        // make sure userHandle remains the same across keys
431        $userHandle = null;
432
433        foreach ( $this->oathUser->getKeys() as $key ) {
434            if ( !( $key instanceof WebAuthnKey ) ) {
435                continue;
436            }
437
438            $userHandle = $key->getUserHandle();
439
440            $excludedPublicKeyDescriptors[] = new PublicKeyCredentialDescriptor(
441                PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
442                $key->getAttestedCredentialData()->getCredentialId()
443            );
444        }
445
446        if ( !$userHandle ) {
447            $userHandle = random_bytes( 64 );
448        }
449
450        $realName = $mwUser->getRealName() ?: $mwUser->getName();
451        $userEntity = new PublicKeyCredentialUserEntity(
452            $mwUser->getName(),
453            $userHandle,
454            $realName
455        );
456
457        $publicKeyCredParametersList = [
458            new PublicKeyCredentialParameters(
459                'public-key',
460                Algorithms::COSE_ALGORITHM_ES256
461            )
462        ];
463
464        return new PublicKeyCredentialCreationOptions(
465            $rpEntity,
466            $userEntity,
467            random_bytes( 32 ),
468            $publicKeyCredParametersList,
469            static::CLIENT_ACTION_TIMEOUT,
470            $excludedPublicKeyDescriptors,
471            new AuthenticatorSelectionCriteria(),
472            PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
473            new AuthenticationExtensionsClientInputs()
474        );
475    }
476
477    /**
478     * Get identifier for this server
479     *
480     * @return string|null
481     */
482    private function getServerId() {
483        $rpId = $this->context->getConfig()->get( 'WebAuthnRelyingPartyID' );
484        if ( $rpId && is_string( $rpId ) ) {
485            return $rpId;
486        }
487
488        $server = $this->context->getConfig()->get( 'Server' );
489        $serverBits = wfParseUrl( $server );
490        if ( $serverBits !== false ) {
491            return $serverBits['host'];
492        }
493
494        return null;
495    }
496
497    /**
498     * Get name for this server
499     *
500     * @return string
501     */
502    private function getServerName() {
503        $serverName = $this->context->getConfig()->get( 'WebAuthnRelyingPartyName' );
504        if ( $serverName && is_string( $serverName ) ) {
505            return $serverName;
506        }
507        if ( $this->context->getConfig()->has( 'Sitename' ) ) {
508            return $this->context->getConfig()->get( 'Sitename' );
509        }
510
511        return WikiMap::getCurrentWikiId();
512    }
513}