Completed
Push — master ( c14fb2...0f9e03 )
by Robbie
22s queued 11s
created

testVerifyAssertsValidCSRFToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 39
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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