Issues (10)

src/RegisterHandler.php (5 issues)

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

259
            $this->getUserEntity(/** @scrutinizer ignore-type */ $store->getMember()),
Loading history...
260
            random_bytes(32),
261
            [new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256)],
262
            40000,
263
            [],
264
            $this->getAuthenticatorSelectionCriteria(),
265
            PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
266
            new AuthenticationExtensionsClientInputs()
267
        );
268
269
        $store->setState(['credentialOptions' => $credentialOptions] + $state);
270
271
        return $credentialOptions;
272
    }
273
274
    /**
275
     * Returns an "Authenticator Selection Criteria" object which is intended to select the appropriate authenticators
276
     * to participate in the creation operation.
277
     *
278
     * The default is to allow only "cross platform" authenticators, e.g. disabling "single platform" authenticators
279
     * such as touch ID.
280
     *
281
     * For more information: https://github.com/web-auth/webauthn-framework/blob/v1.2/doc/webauthn/PublicKeyCredentialCreation.md#authenticator-selection-criteria
282
     *
283
     * @return AuthenticatorSelectionCriteria
284
     */
285
    protected function getAuthenticatorSelectionCriteria(): AuthenticatorSelectionCriteria
286
    {
287
        return new AuthenticatorSelectionCriteria(
288
            (string) $this->config()->get('authenticator_attachment')
289
        );
290
    }
291
}
292