Completed
Push — master ( 0ab8bd...6880ff )
by Garion
28s queued 10s
created

LoginHandler::getBackURL()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 6
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 12
rs 10
1
<?php declare(strict_types=1);
2
3
namespace SilverStripe\MFA\Authenticator;
4
5
use Psr\Log\LoggerInterface;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\MFA\Exception\InvalidMethodException;
10
use SilverStripe\MFA\Exception\MemberNotFoundException;
11
use SilverStripe\MFA\Extension\MemberExtension;
12
use SilverStripe\MFA\Method\MethodInterface;
13
use SilverStripe\MFA\RequestHandler\BaseHandlerTrait;
14
use SilverStripe\MFA\RequestHandler\RegistrationHandlerTrait;
15
use SilverStripe\MFA\RequestHandler\VerificationHandlerTrait;
16
use SilverStripe\MFA\Service\EnforcementManager;
17
use SilverStripe\MFA\Service\MethodRegistry;
18
use SilverStripe\MFA\Service\SchemaGenerator;
19
use SilverStripe\ORM\ValidationException;
20
use SilverStripe\ORM\ValidationResult;
21
use SilverStripe\Security\IdentityStore;
22
use SilverStripe\Security\Member;
23
use SilverStripe\Security\MemberAuthenticator\LoginHandler as BaseLoginHandler;
24
use SilverStripe\Security\MemberAuthenticator\MemberLoginForm;
25
use SilverStripe\Security\Security;
26
27
class LoginHandler extends BaseLoginHandler
28
{
29
    use BaseHandlerTrait;
30
    use VerificationHandlerTrait;
0 ignored issues
show
Bug introduced by
The trait SilverStripe\MFA\Request...erificationHandlerTrait requires the property $DefaultRegisteredMethod which is not provided by SilverStripe\MFA\Authenticator\LoginHandler.
Loading history...
31
    use RegistrationHandlerTrait;
32
33
    const SESSION_KEY = 'MFALogin';
34
35
    private static $url_handlers = [
0 ignored issues
show
introduced by
The private property $url_handlers is not used, and could be removed.
Loading history...
36
        'GET mfa/schema' => 'getSchema', // Provides details about existing registered methods, etc.
37
        'GET mfa/register/$Method' => 'startRegistration', // Initiates registration process for $Method
38
        'POST mfa/register/$Method' => 'finishRegistration', // Completes registration process for $Method
39
        'GET mfa/skip' => 'skipRegistration', // Allows the user to skip MFA registration
40
        'GET mfa/verify/$Method' => 'startVerification', // Initiates verify process for $Method
41
        'POST mfa/verify/$Method' => 'finishVerification', // Verifies verify via $Method
42
        'GET mfa/complete' => 'redirectAfterSuccessfulLogin',
43
        'GET mfa' => 'mfa', // Renders the MFA Login Page to init the app
44
    ];
45
46
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
47
        'mfa',
48
        'getSchema',
49
        'startRegistration',
50
        'finishRegistration',
51
        'skipRegistration',
52
        'startVerification',
53
        'finishVerification',
54
        'redirectAfterSuccessfulLogin',
55
    ];
56
57
    /**
58
     * Provide a user help link that will be available on the Introduction UI
59
     *
60
     * @config
61
     * @var string
62
     */
63
    private static $user_help_link = '';
0 ignored issues
show
introduced by
The private property $user_help_link is not used, and could be removed.
Loading history...
64
65
    /**
66
     * @var string[]
67
     */
68
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
69
        'Logger' => '%$' . LoggerInterface::class . '.mfa',
70
    ];
71
72
    /**
73
     * @var LoggerInterface
74
     */
75
    protected $logger;
76
77
    /**
78
     * Override the parent "doLogin" to insert extra steps into the flow
79
     *
80
     * @inheritdoc
81
     */
82
    public function doLogin($data, MemberLoginForm $form, HTTPRequest $request)
83
    {
84
        /** @var Member&MemberExtension $member */
85
        $member = $this->checkLogin($data, $request, $result);
86
        $enforcementManager = EnforcementManager::singleton();
87
88
        // If there's no member it's an invalid login. We'll delegate this to the parent
89
        // Additionally if there are no MFA methods registered then we will also delegate
90
        if (!$member || !$this->getMethodRegistry()->hasMethods()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
91
            return parent::doLogin($data, $form, $request);
92
        }
93
94
        // Enable sudo mode. This would usually be done by the default login handler's afterLogin() hook.
95
        $this->getSudoModeService()->activate($request->getSession());
96
97
        // Create a store for handling MFA for this member
98
        $store = $this->createStore($member);
99
        $store->save($request);
100
101
        // Store the BackURL for use after the process is complete
102
        if (!empty($data)) {
103
            $request->getSession()->set(static::SESSION_KEY . '.additionalData', $data);
104
        }
105
106
        // If there is at least one MFA method registered then the user MUST login with it
107
        $request->getSession()->clear(static::SESSION_KEY . '.mustLogin');
108
        if ($member->RegisteredMFAMethods()->count() > 0) {
109
            $request->getSession()->set(static::SESSION_KEY . '.mustLogin', true);
110
        }
111
112
        // Bypass the MFA UI if the user can and has skipped it or MFA is not enabled
113
        if (!$enforcementManager->shouldRedirectToMFA($member)) {
114
            $this->doPerformLogin($request, $member);
115
            return $this->redirectAfterSuccessfulLogin();
116
        }
117
118
        // Redirect to the MFA step
119
        return $this->redirect($this->link('mfa'));
120
    }
121
122
    /**
123
     * Action handler for loading the MFA authentication React app
124
     * Template variables defined here will be used by the rendering controller's template - normally Page.ss
125
     *
126
     * @return HTTPResponse|array
127
     */
128
    public function mfa()
129
    {
130
        $store = $this->getStore();
131
        if (!$store || !$store->getMember()) {
0 ignored issues
show
introduced by
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
132
            return $this->redirectBack();
133
        }
134
135
        $this->applyRequirements();
136
137
        return [
138
            'Form' => $this->renderWith($this->getViewerTemplates()),
139
            'ClassName' => 'mfa',
140
        ];
141
    }
142
143
    /**
144
     * Provides information about the current Member's MFA state
145
     *
146
     * @return HTTPResponse
147
     */
148
    public function getSchema(): HTTPResponse
149
    {
150
        try {
151
            $member = $this->getMember();
152
            $schema = SchemaGenerator::create()->getSchema($member);
153
            return $this->jsonResponse(
154
                $schema + [
155
                    'endpoints' => [
156
                        'register' => $this->Link('mfa/register/{urlSegment}'),
157
                        'verify' => $this->Link('mfa/verify/{urlSegment}'),
158
                        'complete' => $this->Link('mfa/complete'),
159
                        'skip' => $this->Link('mfa/skip'),
160
                    ],
161
                ]
162
            );
163
        } catch (MemberNotFoundException $exception) {
164
            // If we don't have a valid member we shouldn't be here...
165
            return $this->redirectBack();
166
        }
167
    }
168
169
    /**
170
     * Handles the request to start a registration
171
     *
172
     * @param HTTPRequest $request
173
     * @return HTTPResponse
174
     */
175
    public function startRegistration(HTTPRequest $request): HTTPResponse
176
    {
177
        $store = $this->getStore();
178
        $sessionMember = $store ? $store->getMember() : null;
0 ignored issues
show
introduced by
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
179
        $loggedInMember = Security::getCurrentUser();
180
181
        if ((is_null($loggedInMember) && is_null($sessionMember))
182
            || !$this->getSudoModeService()->check($request->getSession())
183
        ) {
184
            return $this->jsonResponse(
185
                ['errors' => [_t(__CLASS__ . '.NOT_AUTHENTICATING', 'You must be logged or logging in')]],
186
                403
187
            );
188
        }
189
190
        $method = $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method'));
191
192
        // If the user isn't fully logged in and they already have a registered method, they can't register another
193
        // provided that they're not registering a backup method
194
        $registeredMethodCount = $sessionMember && $sessionMember->RegisteredMFAMethods()->count();
195
        $isRegisteringBackupMethod =
196
            $method instanceof MethodInterface && $this->getMethodRegistry()->isBackupMethod($method);
197
198
        if ($loggedInMember === null && $sessionMember && $registeredMethodCount > 0 && !$isRegisteringBackupMethod) {
199
            return $this->jsonResponse(
200
                ['errors' => [_t(__CLASS__ . '.MUST_USE_EXISTING_METHOD', 'This member already has an MFA method')]],
201
                400
202
            );
203
        }
204
205
        // Handle the case where the request hasn't provided an appropriate method to register
206
        if ($method === null) {
207
            return $this->jsonResponse(
208
                ['errors' => [_t(__CLASS__ . '.INVALID_METHOD', 'No such method is available')]],
209
                400
210
            );
211
        }
212
213
        // Ensure a store is available using the logged in member if the store doesn't exist
214
        if (!$store) {
0 ignored issues
show
introduced by
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
215
            $store = $this->createStore($loggedInMember);
216
        }
217
218
        // Delegate to the trait for common handling
219
        $response = $this->createStartRegistrationResponse($store, $method);
220
221
        // Ensure details are saved to the session
222
        $store->save($request);
223
224
        return $response;
225
    }
226
227
    /**
228
     * Handles the request to verify and process a new registration
229
     *
230
     * @param HTTPRequest $request
231
     * @return HTTPResponse
232
     */
233
    public function finishRegistration(HTTPRequest $request): HTTPResponse
234
    {
235
        $store = $this->getStore();
236
        $sessionMember = $store ? $store->getMember() : null;
0 ignored issues
show
introduced by
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
237
        $loggedInMember = Security::getCurrentUser();
238
239
        if ((is_null($loggedInMember) && is_null($sessionMember))
240
            || !$this->getSudoModeService()->check($request->getSession())
241
        ) {
242
            return $this->jsonResponse(
243
                ['errors' => [_t(__CLASS__ . '.NOT_AUTHENTICATING', 'You must be logged or logging in')]],
244
                403
245
            );
246
        }
247
248
        $method = $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method'));
249
        $result = $this->completeRegistrationRequest($store, $method, $request);
0 ignored issues
show
Bug introduced by
It seems like $method can also be of type null; however, parameter $method of SilverStripe\MFA\Authent...teRegistrationRequest() does only seem to accept SilverStripe\MFA\Method\MethodInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

249
        $result = $this->completeRegistrationRequest($store, /** @scrutinizer ignore-type */ $method, $request);
Loading history...
250
251
        if (!$result->isSuccessful()) {
252
            return $this->jsonResponse(
253
                ['errors' => [$result->getMessage()]],
254
                $result->getContext()['code'] ?? 400
255
            );
256
        }
257
258
        // If we've completed registration and the member is not already logged in then we need to log them in
259
        /** @var EnforcementManager $enforcementManager */
260
        $enforcementManager = EnforcementManager::create();
261
        $mustLogin = $request->getSession()->get(static::SESSION_KEY . '.mustLogin');
262
263
        // If the user has a valid registration at this point then we can log them in. We must ensure that they're not
264
        // required to log in though. The "mustLogin" flag is set at the beginning of the MFA process if they have at
265
        // least one method registered. They should always do that first. In that case we should assert
266
        // "isLoginComplete"
267
        if ((!$mustLogin || $this->isVerificationComplete($store))
268
            && $enforcementManager->hasCompletedRegistration($sessionMember)
0 ignored issues
show
Bug introduced by
It seems like $sessionMember can also be of type null; however, parameter $member of SilverStripe\MFA\Service...CompletedRegistration() does only seem to accept SilverStripe\Security\Member, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

268
            && $enforcementManager->hasCompletedRegistration(/** @scrutinizer ignore-type */ $sessionMember)
Loading history...
269
        ) {
270
            $this->doPerformLogin($request, $sessionMember);
0 ignored issues
show
Bug introduced by
It seems like $sessionMember can also be of type null; however, parameter $member of SilverStripe\MFA\Authent...ndler::doPerformLogin() does only seem to accept SilverStripe\Security\Member, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

270
            $this->doPerformLogin($request, /** @scrutinizer ignore-type */ $sessionMember);
Loading history...
271
        }
272
273
        return $this->jsonResponse(['success' => true], 201);
274
    }
275
276
    /**
277
     * Handle an HTTP request to skip MFA registration
278
     *
279
     * @param HTTPRequest $request
280
     * @return HTTPResponse
281
     * @throws ValidationException
282
     */
283
    public function skipRegistration(HTTPRequest $request): HTTPResponse
284
    {
285
        $loginUrl = Security::login_url();
286
287
        try {
288
            $member = $this->getMember();
289
            $enforcementManager = EnforcementManager::create();
290
291
            if (!$enforcementManager->canSkipMFA($member)) {
292
                Security::singleton()->setSessionMessage(
293
                    _t(__CLASS__ . '.CANNOT_SKIP', 'You cannot skip MFA registration'),
294
                    ValidationResult::TYPE_ERROR
295
                );
296
                return $this->redirect($loginUrl);
297
            }
298
299
            $member->update(['HasSkippedMFARegistration' => true])->write();
300
            $this->extend('onSkipRegistration', $member);
301
            $this->doPerformLogin($request, $member);
302
303
            // Redirect the user back to wherever they originally came from when they started the login process
304
            return $this->redirectAfterSuccessfulLogin();
305
        } catch (MemberNotFoundException $exception) {
306
            Security::singleton()->setSessionMessage(
307
                _t(__CLASS__ . '.CANNOT_SKIP', 'You cannot skip MFA registration'),
308
                ValidationResult::TYPE_ERROR
309
            );
310
            return $this->redirect($loginUrl);
311
        }
312
    }
313
314
    /**
315
     * Handles the request to start an authentication process with an authenticator (possibly specified by the request)
316
     *
317
     * @param HTTPRequest $request
318
     * @return HTTPResponse
319
     */
320
    public function startVerification(HTTPRequest $request): HTTPResponse
321
    {
322
        $store = $this->getStore();
323
        $member = $store->getMember();
324
325
        // If we don't have a valid member we shouldn't be here, or if sudo mode is not active yet.
326
        if (!$member || !$this->getSudoModeService()->check($request->getSession())) {
327
            return $this->jsonResponse(['message' => 'Forbidden'], 403);
328
        }
329
330
        // Use the provided trait method for handling login
331
        $response = $this->createStartVerificationResponse(
332
            $store,
333
            $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method'))
334
        );
335
336
        // Ensure detail is saved to the store
337
        $store->save($request);
338
339
        // Respond with our method
340
        return $response;
341
    }
342
343
    /**
344
     * Handles requests to authenticate from any MFA method, directing verification to the Method supplied.
345
     *
346
     * @param HTTPRequest $request
347
     * @return HTTPResponse
348
     */
349
    public function finishVerification(HTTPRequest $request): HTTPResponse
350
    {
351
        $store = $this->getStore();
352
        $member = $store->getMember();
353
354
        if ($member->isLockedOut()) {
355
            return $this->jsonResponse([
356
                'message' => _t(
357
                    __CLASS__ . '.LOCKED_OUT',
358
                    'Your account is temporarily locked. Please try again later.'
359
                ),
360
            ], 403);
361
        }
362
363
        // Enforce sudo mode
364
        if (!$this->getSudoModeService()->check($request->getSession())) {
365
            return $this->jsonResponse([
366
                'message' => _t(
367
                    __CLASS__ . '.SUDO_MODE_REQUIRED',
368
                    'You need to re-verify your account before continuing. Please reload and try again.'
369
                ),
370
            ], 403);
371
        }
372
373
        try {
374
            $result = $this->completeVerificationRequest($store, $request);
375
        } catch (InvalidMethodException $e) {
376
            // Invalid method usually means a timeout. A user might be trying to verify before "starting"
377
            return $this->jsonResponse(['message' => 'Forbidden'], 403);
378
        }
379
380
        if (!$result->isSuccessful()) {
381
            $store->getMember()->registerFailedLogin();
382
            $code = $result->getContext()['code'] ?? 401;
383
384
            return $this->jsonResponse([
385
                'message' => $result->getMessage(),
386
            ], $code);
387
        }
388
389
        if (!$this->isVerificationComplete($store)) {
390
            return $this->jsonResponse([
391
                'message' => 'Additional authentication required',
392
            ], 202);
393
        }
394
395
        // Actually log in the member if the registration is complete
396
        $member = $store->getMember();
397
398
        if (EnforcementManager::create()->hasCompletedRegistration($member)) {
399
            $this->doPerformLogin($request, $member);
0 ignored issues
show
Bug introduced by
It seems like $member can also be of type null; however, parameter $member of SilverStripe\MFA\Authent...ndler::doPerformLogin() does only seem to accept SilverStripe\Security\Member, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

399
            $this->doPerformLogin($request, /** @scrutinizer ignore-type */ $member);
Loading history...
400
401
            // And also clear the session
402
            $store->clear($request);
403
        }
404
405
        // We still indicate login has been completed here. The finalisation of registration should take care of it
406
        return $this->jsonResponse([
407
            'message' => 'Login complete',
408
        ], 200);
409
    }
410
411
    public function redirectAfterSuccessfulLogin(): HTTPResponse
412
    {
413
        // Assert that we have a member logged in already. We explicitly don't use ->getMember as that will pull from
414
        // session during the MFA process
415
        $member = Security::getCurrentUser();
416
        $loginUrl = Security::login_url();
417
418
        if (!$member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
419
            Security::singleton()->setSessionMessage(
420
                _t(__CLASS__ . '.MFA_LOGIN_INCOMPLETE', 'You must provide MFA login details'),
421
                ValidationResult::TYPE_ERROR
422
            );
423
            return $this->redirect($this->getBackURL() ?: $loginUrl);
424
        }
425
426
        $request = $this->getRequest();
427
        /** @var EnforcementManager $enforcementManager */
428
        $enforcementManager = EnforcementManager::create();
429
430
        // Assert that the member has a valid registration.
431
        // This is potentially redundant logic as the member should only be logged in if they've fully registered.
432
        // They're allowed to login if they can skip - so only do assertions if they're not allowed to skip
433
        // We'll also check that they've registered the required MFA details
434
        if (!$enforcementManager->hasCompletedRegistration($member)
435
            && $enforcementManager->shouldRedirectToMFA($member)
436
        ) {
437
            // Log them out again
438
            /** @var IdentityStore $identityStore */
439
            $identityStore = Injector::inst()->get(IdentityStore::class);
440
            $identityStore->logOut($request);
441
442
            Security::singleton()->setSessionMessage(
443
                _t(__CLASS__ . '.INVALID_REGISTRATION', 'You must complete MFA registration'),
444
                ValidationResult::TYPE_ERROR
445
            );
446
            return $this->redirect($this->getBackURL() ?: $loginUrl);
447
        }
448
449
        // Clear the "additional data"
450
        $request->getSession()->clear(static::SESSION_KEY . '.additionalData');
451
452
        // Ensure any left over session state is cleaned up
453
        $store = $this->getStore();
454
        if ($store) {
0 ignored issues
show
introduced by
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
455
            $store->clear($request);
456
        }
457
        $request->getSession()->clear(static::SESSION_KEY . '.mustLogin');
458
459
        // Delegate to parent logic
460
        return parent::redirectAfterSuccessfulLogin();
461
    }
462
463
    /**
464
     * @return Member&MemberExtension
465
     * @throws MemberNotFoundException
466
     */
467
    public function getMember()
468
    {
469
        $store = $this->getStore();
470
471
        if ($store && $store->getMember()) {
472
            return $store->getMember();
473
        }
474
475
        $member = Security::getCurrentUser();
476
477
        // If we don't have a valid member we shouldn't be here...
478
        if (!$member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
479
            throw new MemberNotFoundException();
480
        }
481
482
        return $member;
483
    }
484
485
    /**
486
     * @param LoggerInterface $logger
487
     * @return $this
488
     */
489
    public function setLogger(LoggerInterface $logger): self
490
    {
491
        $this->logger = $logger;
492
        return $this;
493
    }
494
495
    /**
496
     * @return LoggerInterface
497
     */
498
    public function getLogger(): ?LoggerInterface
499
    {
500
        return $this->logger;
501
    }
502
503
    /**
504
     * Adds another option for the back URL to be returned from a current MFA session store
505
     *
506
     * @return string|null
507
     */
508
    public function getBackURL(): ?string
509
    {
510
        $backURL = parent::getBackURL();
511
512
        if (!$backURL && $this->getRequest()) {
513
            $data = $this->getRequest()->getSession()->get(static::SESSION_KEY . '.additionalData');
514
            if (isset($data['BackURL'])) {
515
                $backURL = $data['BackURL'];
516
            }
517
        }
518
519
        return $backURL;
520
    }
521
522
    /**
523
     * Respond with the given array as a JSON response
524
     *
525
     * @param array $response
526
     * @param int $code The HTTP response code to set on the response
527
     * @return HTTPResponse
528
     */
529
    public function jsonResponse(array $response, int $code = 200): HTTPResponse
530
    {
531
        return HTTPResponse::create(json_encode($response))
532
            ->addHeader('Content-Type', 'application/json')
533
            ->setStatusCode($code);
534
    }
535
536
    /**
537
     * Complete the login process for the given member by calling "performLogin" on the parent class
538
     *
539
     * @param HTTPRequest $request
540
     * @param Member&MemberExtension $member
541
     */
542
    protected function doPerformLogin(HTTPRequest $request, Member $member)
543
    {
544
        // Load the previously stored data from session and perform the login using it...
545
        $data = $request->getSession()->get(static::SESSION_KEY . '.additionalData') ?: [];
546
547
        // Check that we don't have a logged in member before actually performing a login
548
        $currentMember = Security::getCurrentUser();
549
550
        if (!$currentMember) {
0 ignored issues
show
introduced by
$currentMember is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
551
            // These next two lines are pulled from "parent::doLogin()"
552
            $this->performLogin($member, $data, $request);
553
            // Allow operations on the member after successful login
554
            parent::extend('afterLogin', $member);
555
        }
556
    }
557
558
    /**
559
     * @return MethodRegistry
560
     */
561
    protected function getMethodRegistry(): MethodRegistry
562
    {
563
        return MethodRegistry::singleton();
564
    }
565
}
566