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

LoginHandlerTest::testGetBackURL()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 13
rs 10
1
<?php
2
3
namespace SilverStripe\MFA\Tests\Authenticator;
4
5
use PHPUnit_Framework_MockObject_MockObject;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Control\Session;
9
use SilverStripe\Core\Config\Config;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\Dev\FunctionalTest;
12
use SilverStripe\MFA\Authenticator\LoginHandler;
13
use SilverStripe\MFA\Authenticator\MemberAuthenticator;
14
use SilverStripe\MFA\Extension\MemberExtension;
15
use SilverStripe\MFA\Method\Handler\VerifyHandlerInterface;
16
use SilverStripe\MFA\Method\MethodInterface;
17
use SilverStripe\MFA\Model\RegisteredMethod;
18
use SilverStripe\MFA\Service\MethodRegistry;
19
use SilverStripe\MFA\Service\RegisteredMethodManager;
20
use SilverStripe\MFA\State\Result;
21
use SilverStripe\MFA\Store\SessionStore;
22
use SilverStripe\MFA\Store\StoreInterface;
23
use SilverStripe\MFA\Tests\Stub\BasicMath\Method;
24
use SilverStripe\ORM\FieldType\DBDatetime;
25
use SilverStripe\Security\Member;
26
use SilverStripe\Security\Security;
27
use SilverStripe\SecurityExtensions\Service\SudoModeServiceInterface;
28
use SilverStripe\Security\SecurityToken;
29
use SilverStripe\SiteConfig\SiteConfig;
30
31
class LoginHandlerTest extends FunctionalTest
32
{
33
    protected static $fixture_file = 'LoginHandlerTest.yml';
34
35
    protected function setUp()
36
    {
37
        parent::setUp();
38
        Config::modify()->set(MethodRegistry::class, 'methods', [Method::class]);
39
40
        Injector::inst()->load([
41
            Security::class => [
42
                'properties' => [
43
                    'authenticators' => [
44
                        'default' => '%$' . MemberAuthenticator::class,
45
                    ]
46
                ]
47
            ]
48
        ]);
49
50
        /** @var SudoModeServiceInterface&PHPUnit_Framework_MockObject_MockObject $sudoModeService */
51
        $sudoModeService = $this->createMock(SudoModeServiceInterface::class);
52
        $sudoModeService->expects($this->any())->method('check')->willReturn(true);
53
        Injector::inst()->registerService($sudoModeService, SudoModeServiceInterface::class);
54
    }
55
56
    public function testMFAStepIsAdded()
57
    {
58
        /** @var Member&MemberExtension $member */
59
        $member = $this->objFromFixture(Member::class, 'guy');
60
61
        $this->autoFollowRedirection = false;
62
        $response = $this->doLogin($member, 'Password123');
63
        $this->autoFollowRedirection = true;
64
65
        $this->assertSame(302, $response->getStatusCode());
66
        $this->assertStringEndsWith('/Security/login/default/mfa', $response->getHeader('location'));
67
    }
68
69
    public function testMethodsNotBeingAvailableWillLogin()
70
    {
71
        Config::modify()->set(MethodRegistry::class, 'methods', []);
72
73
        /** @var Member&MemberExtension $member */
74
        $member = $this->objFromFixture(Member::class, 'guy');
75
76
        // Ensure a URL is set to redirect to after successful login
77
        $this->session()->set('BackURL', 'something');
78
79
        $this->autoFollowRedirection = false;
80
        $response = $this->doLogin($member, 'Password123');
81
        $this->autoFollowRedirection = true;
82
83
        $this->assertSame(302, $response->getStatusCode());
84
        $this->assertStringEndsWith('/something', $response->getHeader('location'));
85
    }
86
87
    public function testMFASchemaEndpointIsNotAccessibleByDefault()
88
    {
89
        // Assert that this endpoint is not available if you haven't started the login process
90
        $this->autoFollowRedirection = false;
91
        $response = $this->get('Security/login/default/mfa/schema');
92
        $this->autoFollowRedirection = true;
93
94
        $this->assertSame(302, $response->getStatusCode());
95
    }
96
97
    public function testMFASchemaEndpointReturnsMethodDetails()
98
    {
99
        // "Guy" isn't very security conscious - he has no MFA methods set up
100
        /** @var Member&MemberExtension $member */
101
        $member = $this->objFromFixture(Member::class, 'guy');
102
        $this->scaffoldPartialLogin($member);
103
104
        $result = $this->get('Security/login/default/mfa/schema');
105
106
        $response = json_decode($result->getBody(), true);
107
108
        $this->assertArrayHasKey('registeredMethods', $response);
109
        $this->assertArrayHasKey('availableMethods', $response);
110
        $this->assertArrayHasKey('defaultMethod', $response);
111
112
        $this->assertCount(0, $response['registeredMethods']);
113
        $this->assertCount(1, $response['availableMethods']);
114
        $this->assertNull($response['defaultMethod']);
115
116
        /** @var MethodInterface $method */
117
        $method = Injector::inst()->get(Method::class);
118
        $registerHandler = $method->getRegisterHandler();
119
120
        $methods = $response['availableMethods'];
121
        $this->assertNotEmpty($methods);
122
        $firstMethod = $methods[0];
123
124
        $this->assertSame($method->getURLSegment(), $firstMethod['urlSegment']);
125
        $this->assertSame($registerHandler->getName(), $firstMethod['name']);
126
        $this->assertSame($registerHandler->getDescription(), $firstMethod['description']);
127
        $this->assertSame($registerHandler->getSupportLink(), $firstMethod['supportLink']);
128
        $this->assertContains('client/dist/images', $firstMethod['thumbnail']);
129
        $this->assertSame('BasicMathRegister', $firstMethod['component']);
130
    }
131
132
    public function testMFASchemaEndpointShowsRegisteredMethodsIfSetUp()
133
    {
134
        // "Simon" is security conscious - he uses the cutting edge MFA methods
135
        /** @var Member&MemberExtension $member */
136
        $member = $this->objFromFixture(Member::class, 'simon');
137
        $this->scaffoldPartialLogin($member);
138
139
        $result = $this->get('Security/login/default/mfa/schema');
140
141
        $response = json_decode($result->getBody(), true);
142
143
        $this->assertArrayHasKey('registeredMethods', $response);
144
        $this->assertArrayHasKey('availableMethods', $response);
145
        $this->assertArrayHasKey('defaultMethod', $response);
146
147
        $this->assertCount(1, $response['registeredMethods']);
148
        $this->assertCount(0, $response['availableMethods']);
149
        $this->assertNull($response['defaultMethod']);
150
151
        /** @var MethodInterface $method */
152
        $method = Injector::inst()->get(Method::class);
153
        $verifyHandler = $method->getVerifyHandler();
154
155
        $result = $response['registeredMethods'][0];
156
        $this->assertSame($method->getURLSegment(), $result['urlSegment']);
157
        $this->assertSame($verifyHandler->getLeadInLabel(), $result['leadInLabel']);
158
        $this->assertSame('BasicMathLogin', $result['component']);
159
        $this->assertSame('https://google.com', $result['supportLink']);
160
        $this->assertContains('totp.svg', $result['thumbnail']);
161
    }
162
163
    public function testMFASchemaEndpointProvidesDefaultMethodIfSet()
164
    {
165
        // "Robbie" is security conscious and is also a CMS expert! He set up MFA and set a default method :o
166
        /** @var Member&MemberExtension $member */
167
        $member = $this->objFromFixture(Member::class, 'robbie');
168
        $this->scaffoldPartialLogin($member);
169
170
        $result = $this->get('Security/login/default/mfa/schema');
171
172
        $response = json_decode($result->getBody(), true);
173
174
        $this->assertArrayHasKey('registeredMethods', $response);
175
        $this->assertArrayHasKey('availableMethods', $response);
176
        $this->assertArrayHasKey('defaultMethod', $response);
177
178
        $this->assertCount(1, $response['registeredMethods']);
179
        $this->assertCount(0, $response['availableMethods']);
180
181
        /** @var RegisteredMethod $mathMethod */
182
        $mathMethod = $this->objFromFixture(RegisteredMethod::class, 'robbie-math');
183
        $this->assertSame($mathMethod->getMethod()->getURLSegment(), $response['defaultMethod']);
184
    }
185
186
    /**
187
     * @param bool $mfaRequired
188
     * @param string|null $member
189
     * @dataProvider cannotSkipMFAProvider
190
     */
191
    public function testCannotSkipMFA($mfaRequired, $member = 'robbie')
192
    {
193
        $this->setSiteConfig(['MFARequired' => $mfaRequired]);
194
195
        if ($member) {
196
            $this->scaffoldPartialLogin($this->objFromFixture(Member::class, $member));
197
        }
198
199
        $response = $this->get('Security/login/default/mfa/skip');
200
        $this->assertContains('You cannot skip MFA registration', $response->getBody());
201
    }
202
203
    /**
204
     * @return array[]
205
     */
206
    public function cannotSkipMFAProvider()
207
    {
208
        return [
209
            'mfa is required' => [true],
210
            'mfa is not required, but user already has configured methods' => [false],
211
            'no member is available' => [false, null],
212
        ];
213
    }
214
215
    public function testSkipRegistration()
216
    {
217
        $this->setSiteConfig(['MFARequired' => false]);
218
219
        $member = new Member();
220
        $member->FirstName = 'Some new';
221
        $member->Surname = 'member';
222
        $memberId = $member->write();
223
        $this->logInAs($member);
224
225
        $response = $this->get('Security/login/default/mfa/skip');
226
227
        $this->assertSame(200, $response->getStatusCode());
228
229
        $member = Member::get()->byID($memberId);
230
        $this->assertTrue((bool)$member->HasSkippedMFARegistration);
231
    }
232
233
    /**
234
     * @expectedException \SilverStripe\MFA\Exception\MemberNotFoundException
235
     */
236
    public function testGetMemberThrowsExceptionWithoutMember()
237
    {
238
        $this->logOut();
239
        $handler = new LoginHandler('foo', $this->createMock(MemberAuthenticator::class));
240
        $handler->setRequest(new HTTPRequest('GET', '/'));
241
        $handler->getRequest()->setSession(new Session([]));
242
        $handler->getMember();
243
    }
244
245
    public function testStartVerificationIncludesACSRFToken()
246
    {
247
        SecurityToken::enable();
248
249
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
250
        $member = $this->objFromFixture(Member::class, 'robbie');
251
        $store = new SessionStore($member);
252
        $handler->setStore($store);
253
254
        $request = new HTTPRequest('GET', '/');
255
        $request->setSession(new Session([]));
256
        $request->setRouteParams(['Method' => 'basic-math']);
257
        $response = json_decode($handler->startVerification($request)->getBody());
258
259
        $this->assertNotNull($response->SecurityID);
260
        $this->assertTrue(SecurityToken::inst()->check($response->SecurityID));
261
    }
262
263
    public function testVerifyAssertsValidCSRFToken()
264
    {
265
        SecurityToken::enable();
266
267
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
268
        $member = $this->objFromFixture(Member::class, 'robbie');
269
        $store = new SessionStore($member);
270
        $store->setMethod('basic-math');
271
        $handler->setStore($store);
272
273
        $request = new HTTPRequest('GET', '/');
274
        $request->setSession(new Session([]));
275
276
        $response = $handler->finishVerification($request);
277
278
        $this->assertSame(403, $response->getStatusCode());
279
        $this->assertContains('Your request timed out', $response->getBody());
280
281
        $request = new HTTPRequest('GET', '/', [
282
            SecurityToken::inst()->getName() => SecurityToken::inst()->getValue()
283
        ]);
284
        $request->setSession(new Session([]));
285
286
        // Mock the verification process...
287
        $mockVerifyHandler = $this->createMock(VerifyHandlerInterface::class);
288
        $mockRegisteredMethod = $this->createMock(RegisteredMethod::class);
289
        $mockRegisteredMethodManager = $this->createMock(RegisteredMethodManager::class);
290
291
        $mockRegisteredMethodManager
292
            ->expects($this->once())->method('getFromMember')->willReturn($mockRegisteredMethod);
293
        $mockRegisteredMethod->expects($this->once())->method('getVerifyHandler')->willReturn($mockVerifyHandler);
294
        $mockVerifyHandler->expects($this->once())->method('verify')->willReturn(Result::create());
295
296
        // Register our mock service
297
        Injector::inst()->registerService($mockRegisteredMethodManager, RegisteredMethodManager::class);
298
299
        $response = $handler->finishVerification($request);
300
301
        $this->assertSame(200, $response->getStatusCode());
302
    }
303
304
    public function testStartVerificationReturnsForbiddenWithoutMember()
305
    {
306
        $this->logOut();
307
308
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
309
        $handler->setRequest(new HTTPRequest('GET', '/'));
310
        $handler->getRequest()->setSession(new Session([]));
311
        $handler->setStore($this->createMock(StoreInterface::class));
312
313
        $response = $handler->startVerification($handler->getRequest());
314
        $this->assertSame(403, $response->getStatusCode());
315
    }
316
317
    public function testStartVerificationReturnsForbiddenWithoutSudoMode()
318
    {
319
        /** @var Member&MemberExtension $member */
320
        $member = $this->objFromFixture(Member::class, 'robbie');
321
        $this->scaffoldPartialLogin($member);
322
323
        /** @var SudoModeServiceInterface&PHPUnit_Framework_MockObject_MockObject $sudoModeService */
324
        $sudoModeService = $this->createMock(SudoModeServiceInterface::class);
325
        $sudoModeService->method('check')->willReturn(false);
326
        Injector::inst()->registerService($sudoModeService, SudoModeServiceInterface::class);
327
328
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
329
        $handler->setRequest(new HTTPRequest('GET', '/'));
330
        $handler->getRequest()->setSession(new Session([]));
331
332
        $store = new SessionStore($member);
333
        $store->setMethod('basic-math');
334
        $handler->setStore($store);
335
336
        $response = $handler->startVerification($handler->getRequest());
337
        $this->assertSame(403, $response->getStatusCode());
338
    }
339
340
    public function testFinishVerificationHandlesMembersLockedOut()
341
    {
342
        /** @var Member&MemberExtension $member */
343
        $member = $this->objFromFixture(Member::class, 'robbie');
344
        // Mock the member being locked out for fifteen minutes
345
        $member->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + 15 * 60);
346
        $member->write();
347
348
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
349
        $request = new HTTPRequest('GET', '/');
350
        $request->setSession(new Session([]));
351
352
        $store = new SessionStore($member);
353
        $store->setMethod('basic-math');
354
        $handler->setStore($store);
355
356
        $response = $handler->finishVerification($request);
357
        $this->assertEquals(403, $response->getStatusCode());
358
        $this->assertContains('Your account is temporarily locked', (string) $response->getBody());
359
    }
360
361
    public function testFinishVerificationChecksSudoModeIsActive()
362
    {
363
        /** @var Member&MemberExtension $member */
364
        $member = $this->objFromFixture(Member::class, 'robbie');
365
366
        $handler = new LoginHandler('mfa', $this->createMock(MemberAuthenticator::class));
367
        $request = new HTTPRequest('GET', '/');
368
        $request->setSession(new Session([]));
369
370
        $store = new SessionStore($member);
371
        $store->setMethod('basic-math');
372
        $handler->setStore($store);
373
374
        /** @var SudoModeServiceInterface&PHPUnit_Framework_MockObject_MockObject $sudoModeService */
375
        $sudoModeService = $this->createMock(SudoModeServiceInterface::class);
376
        $sudoModeService->method('check')->willReturn(false);
377
        Injector::inst()->registerService($sudoModeService, SudoModeServiceInterface::class);
378
379
        $response = $handler->finishVerification($request);
380
        $this->assertEquals(403, $response->getStatusCode());
381
        $this->assertContains('You need to re-verify your account before continuing', (string) $response->getBody());
382
    }
383
384
    public function testFinishVerificationPassesExceptionMessagesThroughFromMethodsWithValidationFailures()
385
    {
386
        /** @var Member&MemberExtension $member */
387
        $member = $this->objFromFixture(Member::class, 'robbie');
388
        $member->config()->set('lock_out_after_incorrect_logins', 5);
389
        $failedLogins = $member->FailedLoginCount;
390
391
        /** @var LoginHandler|PHPUnit_Framework_MockObject_MockObject $handler */
392
        $handler = $this->getMockBuilder(LoginHandler::class)
393
            ->setMethods(['completeVerificationRequest'])
394
            ->disableOriginalConstructor()
395
            ->getMock();
396
397
        $handler->expects($this->once())->method('completeVerificationRequest')->willReturn(
398
            Result::create(false, 'It failed because it\'s mocked, obviously')
399
        );
400
401
        $request = new HTTPRequest('GET', '/');
402
        $request->setSession(new Session([]));
403
        $store = new SessionStore($member);
404
        $store->setMethod('basic-math');
405
        $handler->setStore($store);
406
407
        $response = $handler->finishVerification($request);
408
409
        $this->assertEquals(401, $response->getStatusCode());
410
        $this->assertContains('It failed because it\'s mocked', (string) $response->getBody());
411
        $this->assertSame($failedLogins + 1, $member->FailedLoginCount, 'Failed login is registered');
412
    }
413
414
    public function testGetBackURL()
415
    {
416
        $handler = new LoginHandler('foo', $this->createMock(MemberAuthenticator::class));
417
418
        $request = new HTTPRequest('GET', '/');
419
        $handler->setRequest($request);
420
421
        $session = new Session([]);
422
        $request->setSession($session);
423
424
        $session->set(LoginHandler::SESSION_KEY . '.additionalData', ['BackURL' => 'foobar']);
425
426
        $this->assertSame('foobar', $handler->getBackURL());
427
    }
428
429
    /**
430
     * Mark the given user as partially logged in - ie. they've entered their email/password and are currently going
431
     * through the MFA process
432
     * @param Member $member
433
     */
434
    protected function scaffoldPartialLogin(Member $member)
435
    {
436
        $this->logOut();
437
438
        $this->session()->set(SessionStore::SESSION_KEY, new SessionStore($member));
439
    }
440
441
    /**
442
     * @param Member $member
443
     * @param string $password
444
     * @return HTTPResponse
445
     */
446
    protected function doLogin(Member $member, $password)
447
    {
448
        $this->get(Config::inst()->get(Security::class, 'login_url'));
449
450
        return $this->submitForm(
451
            'MemberLoginForm_LoginForm',
452
            null,
453
            array(
454
                'Email' => $member->Email,
455
                'Password' => $password,
456
                'AuthenticationMethod' => MemberAuthenticator::class,
457
                'action_doLogin' => 1,
458
            )
459
        );
460
    }
461
462
    /**
463
     * Helper method for changing the current SiteConfig values
464
     *
465
     * @param array $data
466
     */
467
    protected function setSiteConfig(array $data)
468
    {
469
        $siteConfig = SiteConfig::current_site_config();
470
        $siteConfig->update($data);
471
        $siteConfig->write();
472
    }
473
}
474