Consolidate some webauthn-framework logic between handlers using a BaseHandlerTrait, add more tests
1 |
<?php declare(strict_types=1); |
|
2 |
|
|
3 |
namespace SilverStripe\WebAuthn; |
|
4 |
|
|
5 |
use Cose\Algorithms; |
|
6 |
use Exception; |
|
7 |
use GuzzleHttp\Psr7\ServerRequest; |
|
8 |
use Psr\Log\LoggerInterface; |
|
9 |
use SilverStripe\Control\Director; |
|
10 |
use SilverStripe\Control\HTTPRequest; |
|
11 |
use SilverStripe\Core\Config\Configurable; |
|
12 |
use SilverStripe\Core\Extensible; |
|
13 |
use SilverStripe\MFA\Method\Handler\RegisterHandlerInterface; |
|
14 |
use SilverStripe\MFA\State\Result; |
|
15 |
use SilverStripe\MFA\Store\StoreInterface; |
|
16 |
use SilverStripe\Security\Member; |
|
17 |
use SilverStripe\SiteConfig\SiteConfig; |
|
18 |
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs; |
|
19 |
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; |
|
20 |
use Webauthn\AuthenticatorAttestationResponse; |
|
21 |
use Webauthn\AuthenticatorAttestationResponseValidator; |
|
22 |
use Webauthn\AuthenticatorSelectionCriteria; |
|
23 |
use Webauthn\PublicKeyCredentialCreationOptions; |
|
24 |
use Webauthn\PublicKeyCredentialParameters; |
|
25 |
use Webauthn\PublicKeyCredentialRpEntity; |
|
26 |
use Webauthn\PublicKeyCredentialUserEntity; |
|
27 |
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler; |
|
28 |
|
|
29 |
class RegisterHandler implements RegisterHandlerInterface |
|
30 |
{
|
|
31 |
use BaseHandlerTrait; |
|
32 |
use Extensible; |
|
33 |
use Configurable; |
|
34 |
|
|
35 |
/**
|
|
36 |
* Provide a user help link that will be available when registering backup codes
|
|
37 |
* TODO Will this have a user help link as a default?
|
|
38 |
*
|
|
39 |
* @config
|
|
40 |
* @var string
|
|
41 |
*/
|
|
42 |
private static $user_help_link; |
|
43 |
|
|
44 |
/**
|
|
45 |
* The default attachment mode to use for Authentication Selection Criteria.
|
|
46 |
*
|
|
47 |
* See {@link getAuthenticatorSelectionCriteria()} for more information.
|
|
48 |
*
|
|
49 |
* @config
|
|
50 |
* @var string
|
|
51 |
*/
|
|
52 |
private static $authenticator_attachment = AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM; |
|
53 |
|
|
54 |
/**
|
|
55 |
* Dependency injection configuration
|
|
56 |
*
|
|
57 |
* @config
|
|
58 |
* @var array
|
|
59 |
*/
|
|
60 |
private static $dependencies = [ |
|
61 |
'Logger' => '%$' . LoggerInterface::class . '.mfa', |
|
62 |
];
|
|
63 |
|
|
64 |
/**
|
|
65 |
* @var LoggerInterface
|
|
66 |
*/
|
|
67 |
protected $logger = null; |
|
68 |
|
|
69 |
/**
|
|
70 |
* Sets the {@see $logger} member variable
|
|
71 |
*
|
|
72 |
* @param LoggerInterface|null $logger
|
|
73 |
* @return self
|
|
74 |
*/
|
|
75 | 1 |
public function setLogger(?LoggerInterface $logger): self |
76 |
{
|
|
77 | 1 |
$this->logger = $logger; |
78 | 1 |
return $this; |
79 |
}
|
|
80 |
|
|
81 |
/**
|
|
82 |
* Stores any data required to handle a registration process with a method, and returns relevant state to be applied
|
|
83 |
* to the front-end application managing the process.
|
|
84 |
*
|
|
85 |
* @param StoreInterface $store An object that hold session data (and the Member) that can be mutated
|
|
86 |
* @return array Props to be passed to a front-end component
|
|
87 |
* @throws Exception When there is no valid source of CSPRNG
|
|
88 |
*/
|
|
89 | 1 |
public function start(StoreInterface $store): array |
90 |
{
|
|
91 | 1 |
$options = $this->getCredentialCreationOptions($store, true); |
92 |
|
|
93 |
return [ |
|
94 | 1 |
'keyData' => $options, |
95 |
];
|
|
96 |
}
|
|
97 |
|
|
98 |
/**
|
|
99 |
* Confirm that the provided details are valid, and create a new RegisteredMethod against the member.
|
|
100 |
*
|
|
101 |
* @param HTTPRequest $request
|
|
102 |
* @param StoreInterface $store
|
|
103 |
* @return Result
|
|
104 |
* @throws Exception
|
|
105 |
*/
|
|
106 |
public function register(HTTPRequest $request, StoreInterface $store): Result |
|
107 |
{
|
|
108 |
$options = $this->getCredentialCreationOptions($store); |
|
109 |
$data = json_decode($request->getBody(), true); |
|
110 |
|
|
111 |
$decoder = $this->getDecoder(); |
|
112 |
$attestationStatementSupportManager = $this->getAttestationStatementSupportManager($decoder); |
|
113 |
$attestationObjectLoader = $this->getAttestationObjectLoader($attestationStatementSupportManager, $decoder); |
|
114 |
$publicKeyCredentialLoader = $this->getPublicKeyCredentialLoader($attestationObjectLoader, $decoder); |
|
115 |
|
|
116 |
$credentialRepository = new CredentialRepository($store->getMember()); |
|
117 |
|
|
118 |
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator( |
|
119 |
$attestationStatementSupportManager, |
|
120 |
$credentialRepository, |
|
121 |
new TokenBindingNotSupportedHandler(), |
|
122 |
new ExtensionOutputCheckerHandler() |
|
123 |
);
|
|
124 |
|
|
125 |
// Create a PSR-7 request
|
|
126 |
$psrRequest = ServerRequest::fromGlobals(); |
|
127 |
|
|
128 |
try { |
|
129 |
$publicKeyCredential = $publicKeyCredentialLoader->load(base64_decode($data['credentials'])); |
|
130 |
$response = $publicKeyCredential->getResponse(); |
|
131 |
|
|
132 |
if (!$response instanceof AuthenticatorAttestationResponse) { |
|
133 |
throw new ResponseTypeException('Unexpected response type found'); |
|
134 |
}
|
|
135 |
|
|
136 |
if (!$response->getAttestationObject()->getAuthData()->hasAttestedCredentialData()) { |
|
137 |
throw new ResponseDataException('Incomplete data, required information missing'); |
|
138 |
}
|
|
139 |
|
|
140 |
$authenticatorAttestationResponseValidator->check($response, $options, $psrRequest); |
|
141 |
} catch (Exception $e) { |
|
142 |
$this->logger->error($e->getMessage()); |
|
143 |
return Result::create(false, 'Registration failed: ' . $e->getMessage()); |
|
144 |
}
|
|
145 |
|
|
146 |
return Result::create()->setContext([ |
|
147 |
'descriptor' => $publicKeyCredential->getPublicKeyCredentialDescriptor(), |
|
148 |
'data' => $response->getAttestationObject()->getAuthData()->getAttestedCredentialData(), |
|
149 |
'counter' => null, |
|
150 |
]);
|
|
151 |
}
|
|
152 |
|
|
153 |
/**
|
|
154 |
* Provide a localised name for this MFA Method.
|
|
155 |
*
|
|
156 |
* @return string
|
|
157 |
*/
|
|
158 | 1 |
public function getName(): string |
159 |
{
|
|
160 | 1 |
return _t(__CLASS__ . '.NAME', 'Security key'); |
161 |
}
|
|
162 |
|
|
163 |
/**
|
|
164 |
* Provide a localised description of this MFA Method.
|
|
165 |
*
|
|
166 |
* eg. "Verification codes are created by an app on your phone"
|
|
167 |
*
|
|
168 |
* @return string
|
|
169 |
*/
|
|
170 | 1 |
public function getDescription(): string |
171 |
{
|
|
172 | 1 |
return _t( |
173 | 1 |
__CLASS__ . '.DESCRIPTION', |
174 | 1 |
'A small USB device which is used for verifying you'
|
175 |
);
|
|
176 |
}
|
|
177 |
|
|
178 |
/**
|
|
179 |
* Provide a localised URL to a support article about the registration process for this MFA Method.
|
|
180 |
*
|
|
181 |
* @return string
|
|
182 |
*/
|
|
183 | 1 |
public function getSupportLink(): string |
184 |
{
|
|
185 | 1 |
return static::config()->get('user_help_link') ?: ''; |
186 |
}
|
|
187 |
|
|
188 |
/**
|
|
189 |
* Get the key that a React UI component is registered under (with @silverstripe/react-injector on the front-end)
|
|
190 |
*
|
|
191 |
* @return string
|
|
192 |
*/
|
|
193 | 1 |
public function getComponent(): string |
194 |
{
|
|
195 | 1 |
return 'WebAuthnRegister'; |
196 |
}
|
|
197 |
|
|
198 |
/**
|
|
199 |
* @return PublicKeyCredentialRpEntity
|
|
200 |
*/
|
|
201 | 1 |
protected function getRelyingPartyEntity(): PublicKeyCredentialRpEntity |
202 |
{
|
|
203 |
// Relying party entity ONLY allows domains or subdomains. Remove ports or anything else that isn't already.
|
|
204 |
// See https://github.com/web-auth/webauthn-framework/blob/v1.2.2/doc/webauthn/PublicKeyCredentialCreation.md#relying-party-entity
|
|
205 | 1 |
$host = parse_url(Director::host(), PHP_URL_HOST); |
206 |
|
|
207 | 1 |
return new PublicKeyCredentialRpEntity( |
208 | 1 |
(string) SiteConfig::current_site_config()->Title, |
209 |
$host, |
|
210 | 1 |
static::config()->get('application_logo') |
211 |
);
|
|
212 |
}
|
|
213 |
|
|
214 |
/**
|
|
215 |
* @param Member $member
|
|
216 |
* @return PublicKeyCredentialUserEntity
|
|
217 |
*/
|
|
218 | 1 |
protected function getUserEntity(Member $member): PublicKeyCredentialUserEntity |
219 |
{
|
|
220 | 1 |
return new PublicKeyCredentialUserEntity( |
221 | 1 |
$member->getName(), |
222 | 1 |
(string) $member->ID, |
223 | 1 |
$member->getName() |
224 |
);
|
|
225 |
}
|
|
226 |
|
|
227 |
/**
|
|
228 |
* @param StoreInterface $store
|
|
229 |
* @param bool $reset
|
|
230 |
* @return PublicKeyCredentialCreationOptions
|
|
231 |
* @throws Exception
|
|
232 |
*/
|
|
233 | 1 |
protected function getCredentialCreationOptions( |
234 |
StoreInterface $store, |
|
235 |
bool $reset = false |
|
236 |
): PublicKeyCredentialCreationOptions { |
|
237 | 1 |
$state = $store->getState(); |
238 |
|
|
239 | 1 |
if (!$reset && !empty($state) && !empty($state['credentialOptions'])) { |
240 |
return PublicKeyCredentialCreationOptions::createFromArray($state['credentialOptions']); |
|
241 |
}
|
|
242 |
|
|
243 | 1 |
$credentialOptions = new PublicKeyCredentialCreationOptions( |
244 | 1 |
$this->getRelyingPartyEntity(), |
245 | 1 |
$this->getUserEntity($store->getMember()), |
246 | 1 |
random_bytes(32), |
247 | 1 |
[new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256)], |
248 | 1 |
40000, |
249 | 1 |
[],
|
250 | 1 |
$this->getAuthenticatorSelectionCriteria(), |
251 | 1 |
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, |
252 | 1 |
new AuthenticationExtensionsClientInputs() |
253 |
);
|
|
254 |
|
|
255 | 1 |
$store->setState(['credentialOptions' => $credentialOptions] + $state); |
256 |
|
|
257 | 1 |
return $credentialOptions; |
258 |
}
|
|
259 |
|
|
260 |
/**
|
|
261 |
* Returns an "Authenticator Selection Criteria" object which is intended to select the appropriate authenticators
|
|
262 |
* to participate in the creation operation.
|
|
263 |
*
|
|
264 |
* The default is to allow only "cross platform" authenticators, e.g. disabling "single platform" authenticators
|
|
265 |
* such as touch ID.
|
|
266 |
*
|
|
267 |
* For more information: https://github.com/web-auth/webauthn-framework/blob/v1.2/doc/webauthn/PublicKeyCredentialCreation.md#authenticator-selection-criteria
|
|
268 |
*
|
|
269 |
* @return AuthenticatorSelectionCriteria
|
|
270 |
*/
|
|
271 | 1 |
protected function getAuthenticatorSelectionCriteria(): AuthenticatorSelectionCriteria |
272 |
{
|
|
273 | 1 |
return new AuthenticatorSelectionCriteria( |
274 | 1 |
(string) $this->config()->get('authenticator_attachment') |
275 |
);
|
|
276 |
}
|
|
277 |
}
|
Read our documentation on viewing source code .