Passed
Push — master ( 7e81b0...7eb007 )
by Robbie
12:39 queued 11s
created

src/Authenticator/LoginHandler.php (19 issues)

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
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
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
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
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
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
$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
        // We don't need to store the user's password
100
        $request->offsetUnset('Password');
101
        // User code may adjust the request properties further if they have their own sensitive data which
102
        // should be excluded from the store.
103
        $this->extend('onBeforeSaveRequestToStore', $request, $store);
104
        $store->save($request);
105
106
        // Store the BackURL for use after the process is complete
107
        if (!empty($data)) {
108
            $request->getSession()->set(static::SESSION_KEY . '.additionalData', $data);
109
        }
110
111
        // If there is at least one MFA method registered then the user MUST login with it
112
        $request->getSession()->clear(static::SESSION_KEY . '.mustLogin');
113
        if ($member->RegisteredMFAMethods()->count() > 0) {
114
            $request->getSession()->set(static::SESSION_KEY . '.mustLogin', true);
115
        }
116
117
        // Bypass the MFA UI if the user can and has skipped it or MFA is not enabled
118
        if (!$enforcementManager->shouldRedirectToMFA($member)) {
119
            $this->doPerformLogin($request, $member);
120
            return $this->redirectAfterSuccessfulLogin();
121
        }
122
123
        // Redirect to the MFA step
124
        return $this->redirect($this->link('mfa'));
125
    }
126
127
    /**
128
     * Action handler for loading the MFA authentication React app
129
     * Template variables defined here will be used by the rendering controller's template - normally Page.ss
130
     *
131
     * @return HTTPResponse|array
132
     */
133
    public function mfa(HTTPRequest $request)
134
    {
135
        $store = $this->getStore();
136
        if (!$store || !$store->getMember() || !$this->getSudoModeService()->check($request->getSession())) {
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
137
            return $this->redirectBack();
138
        }
139
140
        $this->applyRequirements();
141
142
        return [
143
            'Form' => $this->renderWith($this->getViewerTemplates()),
144
            'ClassName' => 'mfa',
145
        ];
146
    }
147
148
    /**
149
     * Provides information about the current Member's MFA state
150
     *
151
     * @return HTTPResponse
152
     */
153
    public function getSchema(): HTTPResponse
154
    {
155
        try {
156
            $member = $this->getMember();
157
            $schema = SchemaGenerator::create()->getSchema($member);
158
            return $this->jsonResponse(
159
                $schema + [
160
                    'endpoints' => [
161
                        'register' => $this->Link('mfa/register/{urlSegment}'),
162
                        'verify' => $this->Link('mfa/verify/{urlSegment}'),
163
                        'complete' => $this->Link('mfa/complete'),
164
                        'skip' => $this->Link('mfa/skip'),
165
                    ],
166
                ]
167
            );
168
        } catch (MemberNotFoundException $exception) {
169
            // If we don't have a valid member we shouldn't be here...
170
            return $this->redirectBack();
171
        }
172
    }
173
174
    /**
175
     * Handles the request to start a registration
176
     *
177
     * @param HTTPRequest $request
178
     * @return HTTPResponse
179
     */
180
    public function startRegistration(HTTPRequest $request): HTTPResponse
181
    {
182
        $store = $this->getStore();
183
        $sessionMember = $store ? $store->getMember() : null;
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
184
        $loggedInMember = Security::getCurrentUser();
185
186
        if (($loggedInMember === null && $sessionMember === null)
187
            || !$this->getSudoModeService()->check($request->getSession())
188
        ) {
189
            return $this->jsonResponse(
190
                ['errors' => [
191
                    _t(
192
                        __CLASS__ . '.NOT_AUTHENTICATING',
193
                        'You must be logged in or logging in. Please refresh the page and try again.'
194
                    )
195
                ]],
196
                403
197
            );
198
        }
199
200
        $method = $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method'));
201
202
        // If the user isn't fully logged in and they already have a registered method, they can't register another
203
        // provided that they're not registering a backup method
204
        $registeredMethodCount = $sessionMember && $sessionMember->RegisteredMFAMethods()->count();
205
        $isRegisteringBackupMethod =
206
            $method instanceof MethodInterface && $this->getMethodRegistry()->isBackupMethod($method);
207
208
        if ($loggedInMember === null && $sessionMember && $registeredMethodCount > 0 && !$isRegisteringBackupMethod) {
209
            return $this->jsonResponse(
210
                ['errors' => [_t(__CLASS__ . '.MUST_USE_EXISTING_METHOD', 'This member already has an MFA method')]],
211
                400
212
            );
213
        }
214
215
        // Handle the case where the request hasn't provided an appropriate method to register
216
        if ($method === null) {
217
            return $this->jsonResponse(
218
                ['errors' => [_t(__CLASS__ . '.INVALID_METHOD', 'No such method is available')]],
219
                400
220
            );
221
        }
222
223
        // Ensure a store is available using the logged in member if the store doesn't exist
224
        if (!$store) {
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
225
            $store = $this->createStore($loggedInMember);
226
        }
227
228
        // Delegate to the trait for common handling
229
        $response = $this->createStartRegistrationResponse($store, $method);
230
231
        // Ensure details are saved to the session
232
        $store->save($request);
233
234
        return $response;
235
    }
236
237
    /**
238
     * Handles the request to verify and process a new registration
239
     *
240
     * @param HTTPRequest $request
241
     * @return HTTPResponse
242
     */
243
    public function finishRegistration(HTTPRequest $request): HTTPResponse
244
    {
245
        $store = $this->getStore();
246
        $sessionMember = $store ? $store->getMember() : null;
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
247
        $loggedInMember = Security::getCurrentUser();
248
249
        if (($loggedInMember === null && $sessionMember === null)
250
            || !$this->getSudoModeService()->check($request->getSession())
251
        ) {
252
            return $this->jsonResponse(
253
                ['errors' => [
254
                    _t(
255
                        __CLASS__ . '.NOT_AUTHENTICATING',
256
                        'You must be logged in or logging in. Please refresh the page and try again.'
257
                    )
258
                ]],
259
                403
260
            );
261
        }
262
263
        $method = $this->getMethodRegistry()->getMethodByURLSegment($request->param('Method'));
264
        $result = $this->completeRegistrationRequest($store, $method, $request);
0 ignored issues
show
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

264
        $result = $this->completeRegistrationRequest($store, /** @scrutinizer ignore-type */ $method, $request);
Loading history...
265
266
        if (!$result->isSuccessful()) {
267
            return $this->jsonResponse(
268
                ['errors' => [$result->getMessage()]],
269
                $result->getContext()['code'] ?? 400
270
            );
271
        }
272
273
        // If we've completed registration and the member is not already logged in then we need to log them in
274
        /** @var EnforcementManager $enforcementManager */
275
        $enforcementManager = EnforcementManager::create();
276
        $mustLogin = $request->getSession()->get(static::SESSION_KEY . '.mustLogin');
277
278
        // If the user has a valid registration at this point then we can log them in. We must ensure that they're not
279
        // required to log in though. The "mustLogin" flag is set at the beginning of the MFA process if they have at
280
        // least one method registered. They should always do that first. In that case we should assert
281
        // "isLoginComplete"
282
        if ((!$mustLogin || $this->isVerificationComplete($store))
283
            && $enforcementManager->hasCompletedRegistration($sessionMember)
0 ignored issues
show
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

283
            && $enforcementManager->hasCompletedRegistration(/** @scrutinizer ignore-type */ $sessionMember)
Loading history...
284
        ) {
285
            $this->doPerformLogin($request, $sessionMember);
0 ignored issues
show
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

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

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