1 | <?php |
||
2 | |||
3 | namespace SilverStripe\WebAuthn\Tests; |
||
4 | |||
5 | use Exception; |
||
6 | use PHPUnit_Framework_MockObject_MockObject; |
||
7 | use Psr\Log\LoggerInterface; |
||
8 | use SilverStripe\Control\HTTPRequest; |
||
9 | use SilverStripe\Core\Injector\Injector; |
||
10 | use SilverStripe\Dev\SapphireTest; |
||
11 | use SilverStripe\MFA\State\Result; |
||
12 | use SilverStripe\MFA\Store\SessionStore; |
||
13 | use SilverStripe\Security\Member; |
||
14 | use SilverStripe\WebAuthn\CredentialRepository; |
||
15 | use SilverStripe\WebAuthn\RegisterHandler; |
||
16 | use Webauthn\AttestationStatement\AttestationObject; |
||
17 | use Webauthn\AttestedCredentialData; |
||
18 | use Webauthn\AuthenticatorAssertionResponse; |
||
19 | use Webauthn\AuthenticatorAttestationResponse; |
||
20 | use Webauthn\AuthenticatorAttestationResponseValidator; |
||
21 | use Webauthn\AuthenticatorData; |
||
22 | use Webauthn\AuthenticatorResponse; |
||
23 | use Webauthn\AuthenticatorSelectionCriteria; |
||
24 | use Webauthn\PublicKeyCredential; |
||
25 | use Webauthn\PublicKeyCredentialCreationOptions; |
||
26 | use Webauthn\PublicKeyCredentialLoader; |
||
27 | use Webauthn\PublicKeyCredentialSource; |
||
28 | |||
29 | class RegisterHandlerTest extends SapphireTest |
||
30 | { |
||
31 | protected $usesDatabase = true; |
||
32 | |||
33 | /** |
||
34 | * @var RegisterHandler |
||
35 | */ |
||
36 | protected $handler; |
||
37 | |||
38 | /** |
||
39 | * @var Member |
||
40 | */ |
||
41 | protected $member; |
||
42 | |||
43 | /** |
||
44 | * @var HTTPRequest |
||
45 | */ |
||
46 | protected $request; |
||
47 | |||
48 | /** |
||
49 | * @var SessionStore |
||
50 | */ |
||
51 | protected $store; |
||
52 | |||
53 | /** |
||
54 | * @var array |
||
55 | */ |
||
56 | protected $originalServer; |
||
57 | |||
58 | protected function setUp() |
||
59 | { |
||
60 | parent::setUp(); |
||
61 | |||
62 | $this->request = new HTTPRequest('GET', '/'); |
||
63 | $this->handler = Injector::inst()->create(RegisterHandler::class); |
||
64 | |||
65 | $memberID = $this->logInWithPermission(); |
||
66 | /** @var Member $member */ |
||
67 | $this->member = Member::get()->byID($memberID); |
||
68 | |||
69 | $this->store = new SessionStore($this->member); |
||
70 | |||
71 | $this->originalServer = $_SERVER; |
||
72 | |||
73 | // Set default configuration settings |
||
74 | RegisterHandler::config()->set( |
||
75 | 'authenticator_attachment', |
||
76 | AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM |
||
77 | ); |
||
78 | } |
||
79 | |||
80 | protected function tearDown() |
||
81 | { |
||
82 | $_SERVER = $this->originalServer; |
||
83 | |||
84 | parent::tearDown(); |
||
85 | } |
||
86 | |||
87 | /** |
||
88 | * @param string $baseUrl |
||
89 | * @param string $expected |
||
90 | * @dataProvider hostProvider |
||
91 | */ |
||
92 | public function testRelyingPartyEntityDomainIncludesSilverStripeDomain(string $baseUrl, string $expected) |
||
93 | { |
||
94 | $_SERVER['HTTP_HOST'] = $baseUrl; |
||
95 | |||
96 | $result = $this->handler->start($this->store); |
||
97 | $this->assertArrayHasKey('keyData', $result); |
||
98 | |||
99 | /** @var PublicKeyCredentialCreationOptions $options */ |
||
100 | $options = $result['keyData']; |
||
101 | $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options); |
||
102 | |||
103 | $relyingPartyEntity = $options->getRp(); |
||
104 | $this->assertSame( |
||
105 | $expected, |
||
106 | $relyingPartyEntity->getId(), |
||
107 | 'Relying party entity should identify the current SilverStripe domain' |
||
108 | ); |
||
109 | } |
||
110 | |||
111 | /** |
||
112 | * @return array |
||
113 | */ |
||
114 | public function hostProvider(): array |
||
115 | { |
||
116 | return [ |
||
117 | 'domain only' => ['http://example.com', 'example.com'], |
||
118 | 'domain with port' => ['https://example.com:8080', 'example.com'], |
||
119 | 'subdomain' => ['https://www.example.com', 'www.example.com'], |
||
120 | 'subdomain with port' => ['http://my.example.com:8887', 'my.example.com'], |
||
121 | 'subfolder' => ['https://example.com/mysite', 'example.com'], |
||
122 | 'subfolder with port' => ['http://example.com:8080/mysite', 'example.com'], |
||
123 | 'subdomain with subfolder' => ['http://my.example.com/mysite', 'my.example.com'], |
||
124 | 'subdomain with port and subfolder' => ['https://my.example.com:8080/mysite', 'my.example.com'], |
||
125 | 'credentials with domain and trailing slash' => ['http://foo:[email protected]/', 'example.com'], |
||
126 | ]; |
||
127 | } |
||
128 | |||
129 | public function testAuthenticatorSelectionCriteriaRequiresCrossPlatformAttachmentByDefault() |
||
130 | { |
||
131 | $result = $this->handler->start($this->store); |
||
132 | $this->assertArrayHasKey('keyData', $result); |
||
133 | |||
134 | /** @var PublicKeyCredentialCreationOptions $options */ |
||
135 | $options = $result['keyData']; |
||
136 | $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options); |
||
137 | |||
138 | $authenticatorSelection = $options->getAuthenticatorSelection(); |
||
139 | $this->assertSame( |
||
140 | AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM, |
||
141 | $authenticatorSelection->getAuthenticatorAttachment() |
||
142 | ); |
||
143 | } |
||
144 | |||
145 | public function testStart() |
||
146 | { |
||
147 | $result = $this->handler->start($this->store); |
||
148 | $this->assertArrayHasKey('keyData', $result); |
||
149 | |||
150 | /** @var PublicKeyCredentialCreationOptions $options */ |
||
151 | $options = $result['keyData']; |
||
152 | $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options); |
||
153 | } |
||
154 | |||
155 | public function testRegisterReturnsErrorWhenRequiredInformationIsMissing() |
||
156 | { |
||
157 | $result = $this->handler->register($this->request, $this->store); |
||
158 | |||
159 | $this->assertFalse($result->isSuccessful()); |
||
160 | $this->assertContains('Incomplete data', $result->getMessage()); |
||
161 | } |
||
162 | |||
163 | /** |
||
164 | * @param AuthenticatorResponse $mockResponse |
||
165 | * @param Result $expectedResult |
||
166 | * @param int $expectedCredentialCount |
||
167 | * @param callable $responseValidatorMockCallback |
||
168 | * @throws Exception |
||
169 | * @dataProvider registerProvider |
||
170 | */ |
||
171 | public function testRegister( |
||
172 | $mockResponse, |
||
173 | $expectedResult, |
||
174 | $expectedCredentialCount, |
||
175 | callable $responseValidatorMockCallback = null, |
||
176 | callable $storeModifier = null |
||
177 | ) { |
||
178 | /** @var RegisterHandler&PHPUnit_Framework_MockObject_MockObject $handlerMock */ |
||
179 | $handlerMock = $this->getMockBuilder(RegisterHandler::class) |
||
180 | ->setMethods(['getPublicKeyCredentialLoader', 'getAuthenticatorAttestationResponseValidator']) |
||
181 | ->getMock(); |
||
182 | |||
183 | $responseValidatorMock = $this->createMock(AuthenticatorAttestationResponseValidator::class); |
||
184 | // Allow the data provider to customise the validation check handling |
||
185 | if ($responseValidatorMockCallback) { |
||
186 | $responseValidatorMockCallback($responseValidatorMock); |
||
187 | } |
||
188 | $handlerMock->expects($this->any())->method('getAuthenticatorAttestationResponseValidator') |
||
189 | ->willReturn($responseValidatorMock); |
||
190 | |||
191 | $loggerMock = $this->createMock(LoggerInterface::class); |
||
192 | $handlerMock->setLogger($loggerMock); |
||
193 | |||
194 | $loaderMock = $this->createMock(PublicKeyCredentialLoader::class); |
||
195 | $handlerMock->expects($this->once())->method('getPublicKeyCredentialLoader')->willReturn($loaderMock); |
||
196 | |||
197 | $publicKeyCredentialMock = $this->createMock(PublicKeyCredential::class); |
||
198 | $loaderMock->expects($this->once())->method('load')->with('example')->willReturn( |
||
199 | $publicKeyCredentialMock |
||
200 | ); |
||
201 | |||
202 | $publicKeyCredentialMock->expects($this->any())->method('getResponse')->willReturn($mockResponse); |
||
203 | |||
204 | $this->request->setBody(json_encode([ |
||
205 | 'credentials' => base64_encode('example'), |
||
206 | ])); |
||
207 | |||
208 | if ($storeModifier) { |
||
209 | $storeModifier($this->store); |
||
210 | } |
||
211 | |||
212 | $result = $handlerMock->register($this->request, $this->store); |
||
213 | |||
214 | $this->assertSame($expectedResult->isSuccessful(), $result->isSuccessful()); |
||
215 | if ($expectedResult->getMessage()) { |
||
216 | $this->assertContains($expectedResult->getMessage(), $result->getMessage()); |
||
217 | } |
||
218 | |||
219 | $this->assertCount( |
||
220 | $expectedCredentialCount, |
||
221 | $result->getContext(), |
||
222 | 'The number of credentials stored is expected' |
||
223 | ); |
||
224 | } |
||
225 | |||
226 | /** |
||
227 | * Some centralised or reusable logic for testRegister. Note that some of the mocks are only used in some of the |
||
228 | * provided data scenarios, but any expected call numbers are based on all scenarios being run. |
||
229 | * |
||
230 | * @return array[] |
||
231 | */ |
||
232 | public function registerProvider() |
||
233 | { |
||
234 | // phpcs:disable |
||
235 | $testSource = PublicKeyCredentialSource::createFromArray([ |
||
236 | 'publicKeyCredentialId' => 'g8e1UH4B1gUYl_7AiDXHTp8SE3cxYnpC6jF3Fo0KMm79FNN_e34hDE1Mnd4FSOoNW6B-p7xB2tqj28svkJQh1Q', |
||
237 | 'type' => 'public-key', |
||
238 | 'transports' => [], |
||
239 | 'attestationType' => 'none', |
||
240 | 'trustPath' => [ |
||
241 | 'type' => 'empty', |
||
242 | ], |
||
243 | 'aaguid' => 'AAAAAAAAAAAAAAAAAAAAAA', |
||
244 | 'credentialPublicKey' => 'pQECAyYgASFYII3gDdvOBje5JfjNO0VhxE2RrV5XoKqWmCZAmR0f9nFaIlggZOUvkovGH9cfeyfXEpJAVOzR1d-rVRZJvwWJf444aLo', |
||
245 | 'userHandle' => 'MQ', |
||
246 | 'counter' => 268, |
||
247 | ]); |
||
248 | // phpcs:enable |
||
249 | |||
250 | $authDataMock = $this->createMock(AuthenticatorData::class); |
||
251 | $authDataMock->expects($this->exactly(4))->method('hasAttestedCredentialData') |
||
252 | // The first call is the "response indicates incomplete data" test case, second is "valid response", |
||
253 | // third is "invalid response" |
||
254 | ->willReturnOnConsecutiveCalls(false, true, true, true); |
||
255 | $authDataMock->expects($this->any())->method('getAttestedCredentialData')->willReturn( |
||
256 | $testSource->getAttestedCredentialData() |
||
257 | ); |
||
258 | $authDataMock->expects($this->any())->method('getSignCount')->willReturn(1); |
||
259 | |||
260 | $attestationMock = $this->createMock(AttestationObject::class); |
||
261 | $attestationMock->expects($this->any())->method('getAuthData')->willReturn($authDataMock); |
||
262 | |||
263 | $responseMock = $this->createMock(AuthenticatorAttestationResponse::class); |
||
264 | $responseMock->expects($this->any())->method('getAttestationObject')->willReturn($attestationMock); |
||
265 | |||
266 | return [ |
||
267 | 'wrong response return type' => [ |
||
268 | // Deliberately the wrong child implementation of \Webauthn\AuthenticatorResponse |
||
269 | $this->createMock(AuthenticatorAssertionResponse::class), |
||
270 | new Result(false, 'Unexpected response type found'), |
||
271 | 0, |
||
272 | ], |
||
273 | 'response indicates incomplete data' => [ |
||
274 | $responseMock, |
||
275 | new Result(false, 'Incomplete data, required information missing'), |
||
276 | 0, |
||
277 | ], |
||
278 | 'valid response' => [ |
||
279 | $responseMock, |
||
280 | new Result(true), |
||
281 | 1, |
||
282 | function (PHPUnit_Framework_MockObject_MockObject $responseValidatorMock) { |
||
283 | // Specifically setting expectations for the result of the response validator's "check" call |
||
284 | $responseValidatorMock->expects($this->once())->method('check')->willReturn(true); |
||
285 | }, |
||
286 | ], |
||
287 | 'valid response with existing credential' => [ |
||
288 | $responseMock, |
||
289 | new Result(true), |
||
290 | 1, |
||
291 | function (PHPUnit_Framework_MockObject_MockObject $responseValidatorMock) { |
||
292 | // Specifically setting expectations for the result of the response validator's "check" call |
||
293 | $responseValidatorMock->expects($this->once())->method('check')->willReturn(true); |
||
294 | }, |
||
295 | function (SessionStore $store) use ($testSource) { |
||
0 ignored issues
–
show
|
|||
296 | $repo = new CredentialRepository((string) $store->getMember()->ID); |
||
297 | // phpcs:disable |
||
298 | $repo->saveCredentialSource(PublicKeyCredentialSource::createFromArray([ |
||
299 | 'publicKeyCredentialId' => 'g8e1UH4B1gUYl_7AiDXHTp8SE3cxYnpC6jF3Fo0KMm79FNN_e34hDE1Mnd4FSOoNW245125129518925891', |
||
300 | 'type' => 'public-key', |
||
301 | 'transports' => [], |
||
302 | 'attestationType' => 'none', |
||
303 | 'trustPath' => [ |
||
304 | 'type' => 'empty', |
||
305 | ], |
||
306 | 'aaguid' => 'AAAAAAAAAAAAAAAAAAAAAA', |
||
307 | 'credentialPublicKey' => 'pQECAyYgASFYII3gDdvOBje5JfjNO0VhxE2RrV5XoKqWmCZAmR0f9nFaIlggZOUvkovGH9cfeyfXEpJAVOzR1d-rVRZJvwWJf444aLo', |
||
308 | 'userHandle' => 'MQ', |
||
309 | 'counter' => 268, |
||
310 | ])); |
||
311 | // phpcs:enable |
||
312 | $store->addState(['repository' => $repo]); |
||
313 | }, |
||
314 | ], |
||
315 | 'invalid response' => [ |
||
316 | $responseMock, |
||
317 | new Result(false, 'I am a test'), |
||
318 | 0, |
||
319 | function (PHPUnit_Framework_MockObject_MockObject $responseValidatorMock) { |
||
320 | // Specifically setting expectations for the result of the response validator's "check" call |
||
321 | $responseValidatorMock->expects($this->once())->method('check') |
||
322 | ->willThrowException(new Exception('I am a test')); |
||
323 | }, |
||
324 | ], |
||
325 | ]; |
||
326 | } |
||
327 | } |
||
328 |
This check looks for imports that have been defined, but are not used in the scope.