Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
13.22% |
30 / 227 |
|
18.75% |
3 / 16 |
CRAP | |
0.00% |
0 / 1 |
Authenticator | |
13.22% |
30 / 227 |
|
18.75% |
3 / 16 |
1751.05 | |
0.00% |
0 / 1 |
factory | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
isEnabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
canAuthenticate | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
canRegister | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
startAuthentication | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
continueAuthentication | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
startRegistration | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
continueRegistration | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
110 | |||
setSessionData | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
clearSessionData | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getSessionData | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getAuthInfo | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
getRegisterInfo | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
30 | |||
getServerId | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
getServerName | |
0.00% |
0 / 6 |
|
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 | |
20 | namespace MediaWiki\Extension\WebAuthn; |
21 | |
22 | use Cose\Algorithms; |
23 | use FormatJson; |
24 | use IContextSource; |
25 | use MediaWiki\Config\ConfigException; |
26 | use MediaWiki\Extension\OATHAuth\IModule; |
27 | use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry; |
28 | use MediaWiki\Extension\OATHAuth\OATHUser; |
29 | use MediaWiki\Extension\OATHAuth\OATHUserRepository; |
30 | use MediaWiki\Extension\WebAuthn\Key\WebAuthnKey; |
31 | use MediaWiki\Extension\WebAuthn\Module\WebAuthn; |
32 | use MediaWiki\Logger\LoggerFactory; |
33 | use MediaWiki\MediaWikiServices; |
34 | use MediaWiki\Request\WebRequest; |
35 | use MediaWiki\Status\Status; |
36 | use MediaWiki\User\User; |
37 | use MediaWiki\WikiMap\WikiMap; |
38 | use MWException; |
39 | use Psr\Log\LoggerInterface; |
40 | use RequestContext; |
41 | use stdClass; |
42 | use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs; |
43 | use Webauthn\AuthenticatorSelectionCriteria; |
44 | use Webauthn\PublicKeyCredentialCreationOptions; |
45 | use Webauthn\PublicKeyCredentialDescriptor; |
46 | use Webauthn\PublicKeyCredentialParameters; |
47 | use Webauthn\PublicKeyCredentialRequestOptions; |
48 | use Webauthn\PublicKeyCredentialRpEntity; |
49 | use 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 | */ |
56 | class 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 | } |