Passed
Push — master ( f3ad4b...c4e72e )
by Robbie
05:06
created

src/RegisterHandler.php (2 issues)

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