Total Complexity | 72 |
Total Lines | 561 |
Duplicated Lines | 0 % |
Changes | 15 | ||
Bugs | 0 | Features | 1 |
Complex classes like LoginHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use LoginHandler, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
29 | class LoginHandler extends BaseLoginHandler |
||
30 | { |
||
31 | use BaseHandlerTrait; |
||
32 | use VerificationHandlerTrait; |
||
|
|||
33 | use RegistrationHandlerTrait; |
||
34 | |||
35 | public const SESSION_KEY = 'MFALogin'; |
||
36 | |||
37 | private static $url_handlers = [ |
||
38 | 'GET mfa/schema' => 'getSchema', // Provides details about existing registered methods, etc. |
||
39 | 'GET mfa/register/$Method' => 'startRegistration', // Initiates registration process for $Method |
||
40 | 'POST mfa/register/$Method' => 'finishRegistration', // Completes registration process for $Method |
||
41 | 'GET mfa/skip' => 'skipRegistration', // Allows the user to skip MFA registration |
||
42 | 'GET mfa/verify/$Method' => 'startVerification', // Initiates verify process for $Method |
||
43 | 'POST mfa/verify/$Method' => 'finishVerification', // Verifies verify via $Method |
||
44 | 'GET mfa/complete' => 'redirectAfterSuccessfulLogin', |
||
45 | 'GET mfa' => 'mfa', // Renders the MFA Login Page to init the app |
||
46 | ]; |
||
47 | |||
48 | private static $allowed_actions = [ |
||
49 | 'mfa', |
||
50 | 'getSchema', |
||
51 | 'startRegistration', |
||
52 | 'finishRegistration', |
||
53 | 'skipRegistration', |
||
54 | 'startVerification', |
||
55 | 'finishVerification', |
||
56 | 'redirectAfterSuccessfulLogin', |
||
57 | ]; |
||
58 | |||
59 | /** |
||
60 | * Provide a user help link that will be available on the Introduction UI |
||
61 | * |
||
62 | * @config |
||
63 | * @var string |
||
64 | */ |
||
65 | // phpcs:disable |
||
66 | private static $user_help_link = 'https://userhelp.silverstripe.org/en/4/optional_features/multi-factor_authentication/'; |
||
67 | // phpcs:enable |
||
68 | |||
69 | /** |
||
70 | * @var string[] |
||
71 | */ |
||
72 | private static $dependencies = [ |
||
73 | 'Logger' => '%$' . LoggerInterface::class . '.mfa', |
||
74 | ]; |
||
75 | |||
76 | /** |
||
77 | * @var LoggerInterface |
||
78 | */ |
||
79 | protected $logger; |
||
80 | |||
81 | /** |
||
82 | * Override the parent "doLogin" to insert extra steps into the flow |
||
83 | * |
||
84 | * @inheritdoc |
||
85 | */ |
||
86 | public function doLogin($data, MemberLoginForm $form, HTTPRequest $request) |
||
87 | { |
||
88 | /** @var Member&MemberExtension $member */ |
||
89 | $member = $this->checkLogin($data, $request, $result); |
||
90 | $enforcementManager = EnforcementManager::singleton(); |
||
91 | |||
92 | // If: |
||
93 | // - there's no member it's an invalid login, or |
||
94 | // - the enforcement manager determines that MFA should not be shown |
||
95 | // then we can delegate to the parent as this will just be the normal login flow (without MFA) |
||
96 | if (!$member || !$enforcementManager->shouldRedirectToMFA($member)) { |
||
97 | return parent::doLogin($data, $form, $request); |
||
98 | } |
||
99 | |||
100 | // Enable sudo mode. This would usually be done by the default login handler's afterLogin() hook. |
||
101 | $this->getSudoModeService()->activate($request->getSession()); |
||
102 | |||
103 | // Create a store for handling MFA for this member |
||
104 | $store = $this->createStore($member); |
||
105 | // We don't need to store the user's password |
||
106 | $request->offsetUnset('Password'); |
||
107 | // User code may adjust the request properties further if they have their own sensitive data which |
||
108 | // should be excluded from the store. |
||
109 | $this->extend('onBeforeSaveRequestToStore', $request, $store); |
||
110 | $store->save($request); |
||
111 | |||
112 | // Store the BackURL for use after the process is complete |
||
113 | if (!empty($data)) { |
||
114 | $request->getSession()->set(static::SESSION_KEY . '.additionalData', $data); |
||
115 | } |
||
116 | |||
117 | // If there is at least one MFA method registered then the user MUST login with it |
||
118 | $request->getSession()->clear(static::SESSION_KEY . '.mustLogin'); |
||
119 | if ($member->RegisteredMFAMethods()->count() > 0) { |
||
120 | $request->getSession()->set(static::SESSION_KEY . '.mustLogin', true); |
||
121 | } else { |
||
122 | // When there are no methods then the user will be promted to register. We re-generate the session ID to |
||
123 | // prevent session fixation on the MFA setup |
||
124 | // NB: There's no SilverStripe API for this |
||
125 | if (!headers_sent()) { |
||
126 | @session_regenerate_id(true); |
||
127 | } |
||
128 | } |
||
129 | |||
130 | // Redirect to the MFA step |
||
131 | return $this->redirect($this->link('mfa')); |
||
132 | } |
||
133 | |||
134 | /** |
||
135 | * Action handler for loading the MFA authentication React app |
||
136 | * Template variables defined here will be used by the rendering controller's template - normally Page.ss |
||
137 | * |
||
138 | * @return HTTPResponse|array |
||
139 | */ |
||
140 | public function mfa(HTTPRequest $request) |
||
141 | { |
||
142 | $store = $this->getStore(); |
||
143 | if (!$store || !$store->getMember() || !$this->getSudoModeService()->check($request->getSession())) { |
||
144 | return $this->redirectBack(); |
||
145 | } |
||
146 | |||
147 | $this->applyRequirements(); |
||
148 | |||
149 | return [ |
||
150 | 'Form' => $this->renderWith($this->getViewerTemplates()), |
||
151 | 'ClassName' => 'mfa', |
||
152 | ]; |
||
153 | } |
||
154 | |||
155 | /** |
||
156 | * Provides information about the current Member's MFA state |
||
157 | * |
||
158 | * @return HTTPResponse |
||
159 | */ |
||
160 | public function getSchema(): HTTPResponse |
||
161 | { |
||
162 | try { |
||
163 | $member = $this->getMember(); |
||
164 | $schema = SchemaGenerator::create()->getSchema($member); |
||
165 | return $this->jsonResponse( |
||
166 | $schema + [ |
||
167 | 'endpoints' => [ |
||
168 | 'register' => $this->Link('mfa/register/{urlSegment}'), |
||
169 | 'verify' => $this->Link('mfa/verify/{urlSegment}'), |
||
170 | 'complete' => $this->Link('mfa/complete'), |
||
171 | 'skip' => $this->Link('mfa/skip'), |
||
172 | ], |
||
173 | ] |
||
174 | ); |
||
175 | } catch (MemberNotFoundException $exception) { |
||
176 | // If we don't have a valid member we shouldn't be here... |
||
177 | return $this->redirectBack(); |
||
178 | } |
||
179 | } |
||
180 | |||
181 | /** |
||
182 | * Handles the request to start a registration |
||
183 | * |
||
184 | * @param HTTPRequest $request |
||
185 | * @return HTTPResponse |
||
186 | */ |
||
187 | public function startRegistration(HTTPRequest $request): HTTPResponse |
||
188 | { |
||
189 | $store = $this->getStore(); |
||
190 | $sessionMember = $store ? $store->getMember() : null; |
||
191 | $loggedInMember = Security::getCurrentUser(); |
||
192 | |||
193 | if ( |
||
194 | ($loggedInMember === null && $sessionMember === null) |
||
195 | || !$this->getSudoModeService()->check($request->getSession()) |
||
196 | ) { |
||
197 | return $this->jsonResponse( |
||
198 | ['errors' => [ |
||
199 | _t( |
||
200 | __CLASS__ . '.NOT_AUTHENTICATING', |
||
201 | 'You must be logged in or logging in. Please refresh the page and try again.' |
||
202 | ) |
||
203 | ]], |
||
204 | 403 |
||
205 | ); |
||
206 | } |
||
207 | |||
208 | $method = $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method')); |
||
209 | |||
210 | // If the user isn't fully logged in and they already have a registered method, they can't register another |
||
211 | // provided that they're not registering a backup method |
||
212 | $registeredMethodCount = $sessionMember && $sessionMember->RegisteredMFAMethods()->count(); |
||
213 | $isRegisteringBackupMethod = |
||
214 | $method instanceof MethodInterface && $this->getMethodRegistry()->isBackupMethod($method); |
||
215 | |||
216 | if ($loggedInMember === null && $sessionMember && $registeredMethodCount > 0 && !$isRegisteringBackupMethod) { |
||
217 | return $this->jsonResponse( |
||
218 | ['errors' => [_t(__CLASS__ . '.MUST_USE_EXISTING_METHOD', 'This member already has an MFA method')]], |
||
219 | 400 |
||
220 | ); |
||
221 | } |
||
222 | |||
223 | // Handle the case where the request hasn't provided an appropriate method to register |
||
224 | if ($method === null) { |
||
225 | return $this->jsonResponse( |
||
226 | ['errors' => [_t(__CLASS__ . '.INVALID_METHOD', 'No such method is available')]], |
||
227 | 400 |
||
228 | ); |
||
229 | } |
||
230 | |||
231 | // Ensure a store is available using the logged in member if the store doesn't exist |
||
232 | if (!$store) { |
||
233 | $store = $this->createStore($loggedInMember); |
||
234 | } |
||
235 | |||
236 | // Delegate to the trait for common handling |
||
237 | $response = $this->createStartRegistrationResponse($store, $method); |
||
238 | |||
239 | // Ensure details are saved to the session |
||
240 | $store->save($request); |
||
241 | |||
242 | return $response; |
||
243 | } |
||
244 | |||
245 | /** |
||
246 | * Handles the request to verify and process a new registration |
||
247 | * |
||
248 | * @param HTTPRequest $request |
||
249 | * @return HTTPResponse |
||
250 | */ |
||
251 | public function finishRegistration(HTTPRequest $request): HTTPResponse |
||
299 | } |
||
300 | |||
301 | /** |
||
302 | * Handle an HTTP request to skip MFA registration |
||
303 | * |
||
304 | * @param HTTPRequest $request |
||
305 | * @return HTTPResponse |
||
306 | * @throws ValidationException |
||
307 | */ |
||
308 | public function skipRegistration(HTTPRequest $request): HTTPResponse |
||
309 | { |
||
310 | $loginUrl = Security::login_url(); |
||
311 | |||
312 | try { |
||
313 | $member = $this->getMember(); |
||
314 | $enforcementManager = EnforcementManager::create(); |
||
315 | |||
316 | if (!$enforcementManager->canSkipMFA($member)) { |
||
317 | Security::singleton()->setSessionMessage( |
||
318 | _t(__CLASS__ . '.CANNOT_SKIP', 'You cannot skip MFA registration'), |
||
319 | ValidationResult::TYPE_ERROR |
||
320 | ); |
||
321 | return $this->redirect($loginUrl); |
||
322 | } |
||
323 | |||
324 | $member->update(['HasSkippedMFARegistration' => true])->write(); |
||
325 | $this->extend('onSkipRegistration', $member); |
||
326 | $this->doPerformLogin($request, $member); |
||
327 | |||
328 | // Redirect the user back to wherever they originally came from when they started the login process |
||
329 | return $this->redirectAfterSuccessfulLogin(); |
||
330 | } catch (MemberNotFoundException $exception) { |
||
331 | Security::singleton()->setSessionMessage( |
||
332 | _t(__CLASS__ . '.CANNOT_SKIP', 'You cannot skip MFA registration'), |
||
333 | ValidationResult::TYPE_ERROR |
||
334 | ); |
||
335 | return $this->redirect($loginUrl); |
||
336 | } |
||
337 | } |
||
338 | |||
339 | /** |
||
340 | * Handles the request to start an authentication process with an authenticator (possibly specified by the request) |
||
341 | * |
||
342 | * @param HTTPRequest $request |
||
343 | * @return HTTPResponse |
||
344 | */ |
||
345 | public function startVerification(HTTPRequest $request): HTTPResponse |
||
346 | { |
||
347 | $store = $this->getStore(); |
||
348 | // If we don't have a valid member we shouldn't be here, or if sudo mode is not active yet. |
||
349 | if (!$store || !$store->getMember() || !$this->getSudoModeService()->check($request->getSession())) { |
||
350 | return $this->jsonResponse(['message' => 'Forbidden'], 403); |
||
351 | } |
||
352 | |||
353 | // Use the provided trait method for handling login |
||
354 | $response = $this->createStartVerificationResponse( |
||
355 | $store, |
||
356 | $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method')) |
||
357 | ); |
||
358 | |||
359 | // Ensure detail is saved to the store |
||
360 | $store->save($request); |
||
361 | |||
362 | // Respond with our method |
||
363 | return $response; |
||
364 | } |
||
365 | |||
366 | /** |
||
367 | * Handles requests to authenticate from any MFA method, directing verification to the Method supplied. |
||
368 | * |
||
369 | * @param HTTPRequest $request |
||
370 | * @return HTTPResponse |
||
371 | */ |
||
372 | public function finishVerification(HTTPRequest $request): HTTPResponse |
||
373 | { |
||
374 | $store = $this->getStore(); |
||
375 | // Enforce sudo mode |
||
376 | if (!$this->getSudoModeService()->check($request->getSession())) { |
||
377 | return $this->jsonResponse([ |
||
378 | 'message' => _t( |
||
379 | __CLASS__ . '.SUDO_MODE_REQUIRED', |
||
380 | 'You need to re-verify your account before continuing. Please reload and try again.' |
||
381 | ), |
||
382 | ], 403); |
||
383 | } |
||
384 | |||
385 | if ($store && ($member = $store->getMember()) && $member->isLockedOut()) { |
||
386 | return $this->jsonResponse([ |
||
387 | 'message' => _t( |
||
388 | __CLASS__ . '.LOCKED_OUT', |
||
389 | 'Your account is temporarily locked. Please try again later.' |
||
390 | ), |
||
391 | ], 403); |
||
392 | } |
||
393 | |||
394 | try { |
||
395 | $result = $this->completeVerificationRequest($store, $request); |
||
396 | } catch (InvalidMethodException $e) { |
||
397 | // Invalid method usually means a timeout. A user might be trying to verify before "starting" |
||
398 | return $this->jsonResponse(['message' => 'Forbidden'], 403); |
||
399 | } |
||
400 | |||
401 | if (!$result->isSuccessful()) { |
||
402 | $store->getMember()->registerFailedLogin(); |
||
403 | $code = $result->getContext()['code'] ?? 401; |
||
404 | |||
405 | return $this->jsonResponse([ |
||
406 | 'message' => $result->getMessage(), |
||
407 | ], $code); |
||
408 | } |
||
409 | |||
410 | if (!$this->isVerificationComplete($store)) { |
||
411 | return $this->jsonResponse([ |
||
412 | 'message' => 'Additional authentication required', |
||
413 | ], 202); |
||
414 | } |
||
415 | |||
416 | // Actually log in the member if the registration is complete |
||
417 | $member = $store->getMember(); |
||
418 | |||
419 | if (EnforcementManager::create()->hasCompletedRegistration($member)) { |
||
420 | $this->doPerformLogin($request, $member); |
||
421 | |||
422 | // And also clear the session |
||
423 | $store->clear($request); |
||
424 | } |
||
425 | |||
426 | // We still indicate login has been completed here. The finalisation of registration should take care of it |
||
427 | return $this->jsonResponse([ |
||
428 | 'message' => 'Login complete', |
||
429 | ], 200); |
||
430 | } |
||
431 | |||
432 | public function redirectAfterSuccessfulLogin(): HTTPResponse |
||
433 | { |
||
434 | // Assert that we have a member logged in already. We explicitly don't use ->getMember as that will pull from |
||
435 | // session during the MFA process |
||
436 | $member = Security::getCurrentUser(); |
||
437 | $loginUrl = Security::login_url(); |
||
438 | |||
439 | if (!$member) { |
||
440 | Security::singleton()->setSessionMessage( |
||
441 | _t(__CLASS__ . '.MFA_LOGIN_INCOMPLETE', 'You must provide MFA login details'), |
||
442 | ValidationResult::TYPE_ERROR |
||
443 | ); |
||
444 | return $this->redirect($this->getBackURL() ?: $loginUrl); |
||
445 | } |
||
446 | |||
447 | $request = $this->getRequest(); |
||
448 | /** @var EnforcementManager $enforcementManager */ |
||
449 | $enforcementManager = EnforcementManager::create(); |
||
450 | |||
451 | // Assert that the member has a valid registration. |
||
452 | // This is potentially redundant logic as the member should only be logged in if they've fully registered. |
||
453 | // They're allowed to login if they can skip - so only do assertions if they're not allowed to skip |
||
454 | // We'll also check that they've registered the required MFA details |
||
455 | if ( |
||
456 | !$enforcementManager->canSkipMFA($member) |
||
457 | && !$enforcementManager->hasCompletedRegistration($member) |
||
458 | ) { |
||
459 | // Log them out again |
||
460 | /** @var IdentityStore $identityStore */ |
||
461 | $identityStore = Injector::inst()->get(IdentityStore::class); |
||
462 | $identityStore->logOut($request); |
||
463 | |||
464 | Security::singleton()->setSessionMessage( |
||
465 | _t(__CLASS__ . '.INVALID_REGISTRATION', 'You must complete MFA registration'), |
||
466 | ValidationResult::TYPE_ERROR |
||
467 | ); |
||
468 | return $this->redirect($this->getBackURL() ?: $loginUrl); |
||
469 | } |
||
470 | |||
471 | // Redirecting after successful login expects a getVar to be set, store it before clearing the session data |
||
472 | /** @see HTTPRequest::offsetSet */ |
||
473 | $request['BackURL'] = $this->getBackURL(); |
||
474 | |||
475 | // Clear the "additional data" |
||
476 | $request->getSession()->clear(static::SESSION_KEY . '.additionalData'); |
||
477 | |||
478 | // Ensure any left over session state is cleaned up |
||
479 | $store = $this->getStore(); |
||
480 | if ($store) { |
||
481 | $store->clear($request); |
||
482 | } |
||
483 | $request->getSession()->clear(static::SESSION_KEY . '.mustLogin'); |
||
484 | |||
485 | // Delegate to parent logic |
||
486 | return parent::redirectAfterSuccessfulLogin(); |
||
487 | } |
||
488 | |||
489 | /** |
||
490 | * @return Member&MemberExtension |
||
491 | * @throws MemberNotFoundException |
||
492 | */ |
||
493 | public function getMember() |
||
494 | { |
||
495 | $store = $this->getStore(); |
||
496 | |||
497 | if ($store && $store->getMember()) { |
||
498 | return $store->getMember(); |
||
499 | } |
||
500 | |||
501 | $member = Security::getCurrentUser(); |
||
502 | |||
503 | // If we don't have a valid member we shouldn't be here... |
||
504 | if (!$member) { |
||
505 | throw new MemberNotFoundException(); |
||
506 | } |
||
507 | |||
508 | return $member; |
||
509 | } |
||
510 | |||
511 | /** |
||
512 | * @param LoggerInterface $logger |
||
513 | * @return $this |
||
514 | */ |
||
515 | public function setLogger(LoggerInterface $logger): self |
||
516 | { |
||
517 | $this->logger = $logger; |
||
518 | return $this; |
||
519 | } |
||
520 | |||
521 | /** |
||
522 | * @return LoggerInterface |
||
523 | */ |
||
524 | public function getLogger(): ?LoggerInterface |
||
527 | } |
||
528 | |||
529 | /** |
||
530 | * Adds more options for the back URL - to be returned from a current MFA session store |
||
531 | * |
||
532 | * @return string|null |
||
533 | */ |
||
534 | public function getBackURL(): ?string |
||
546 | } |
||
547 | |||
548 | /** |
||
549 | * Respond with the given array as a JSON response |
||
550 | * |
||
551 | * @param array $response |
||
552 | * @param int $code The HTTP response code to set on the response |
||
553 | * @return HTTPResponse |
||
554 | */ |
||
555 | public function jsonResponse(array $response, int $code = 200): HTTPResponse |
||
556 | { |
||
557 | return HTTPResponse::create(json_encode($response)) |
||
558 | ->addHeader('Content-Type', 'application/json') |
||
559 | ->setStatusCode($code); |
||
560 | } |
||
561 | |||
562 | /** |
||
563 | * Complete the login process for the given member by calling "performLogin" on the parent class |
||
564 | * |
||
565 | * @param HTTPRequest $request |
||
566 | * @param Member&MemberExtension $member |
||
567 | */ |
||
568 | protected function doPerformLogin(HTTPRequest $request, Member $member) |
||
569 | { |
||
570 | // Load the previously stored data from session and perform the login using it... |
||
571 | $data = $request->getSession()->get(static::SESSION_KEY . '.additionalData') ?: []; |
||
572 | |||
573 | // Check that we don't have a logged in member before actually performing a login |
||
574 | $currentMember = Security::getCurrentUser(); |
||
575 | |||
576 | if (!$currentMember) { |
||
577 | // These next two lines are pulled from "parent::doLogin()" |
||
578 | $this->performLogin($member, $data, $request); |
||
579 | // Allow operations on the member after successful login |
||
580 | parent::extend('afterLogin', $member); |
||
581 | } |
||
582 | } |
||
583 | |||
584 | /** |
||
585 | * @return MethodRegistry |
||
586 | */ |
||
587 | protected function getMethodRegistry(): MethodRegistry |
||
590 | } |
||
591 | } |
||
592 |