silverstripe / silverstripe-webauthn-authenticator
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;
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
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;
55

56
    /**
57
     * Dependency injection configuration
58
     *
59
     * @config
60
     * @var array
61
     */
62
    private static $dependencies = [
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 2
    public function setLogger(?LoggerInterface $logger): self
78
    {
79 2
        $this->logger = $logger;
80 2
        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 2
    public function start(StoreInterface $store): array
92
    {
93 2
        $options = $this->getCredentialCreationOptions($store, true);
94

95
        return [
96 2
            '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 2
    public function register(HTTPRequest $request, StoreInterface $store): Result
109
    {
110 2
        $options = $this->getCredentialCreationOptions($store);
111 2
        $data = json_decode((string) $request->getBody(), true);
112

113
        try {
114 2
            if (empty($data['credentials'])) {
115 2
                throw new ResponseDataException('Incomplete data, required information missing');
116
            }
117

118 2
            $decoder = $this->getDecoder();
119 2
            $attestationStatementSupportManager = $this->getAttestationStatementSupportManager($decoder);
120 2
            $attestationObjectLoader = $this->getAttestationObjectLoader($attestationStatementSupportManager, $decoder);
121 2
            $publicKeyCredentialLoader = $this->getPublicKeyCredentialLoader($attestationObjectLoader, $decoder);
122 2
            $publicKeyCredential = $publicKeyCredentialLoader->load(base64_decode($data['credentials']));
123 2
            $response = $publicKeyCredential->getResponse();
124

125 2
            if (!$response instanceof AuthenticatorAttestationResponse) {
126 2
                throw new ResponseTypeException('Unexpected response type found');
127
            }
128

129 2
            if (!$response->getAttestationObject()->getAuthData()->hasAttestedCredentialData()) {
130 2
                throw new ResponseDataException('Incomplete data, required information missing');
131
            }
132

133
            // Create a PSR-7 request
134 2
            $psrRequest = ServerRequest::fromGlobals();
135

136
            // Validate the webauthn response
137 2
            $this->getAuthenticatorAttestationResponseValidator($attestationStatementSupportManager, $store)
138 2
                ->check($response, $options, $psrRequest);
139 2
        } catch (Exception $e) {
140 2
            $this->logger->error($e->getMessage());
141 2
            return Result::create(false, 'Registration failed: ' . $e->getMessage());
142
        }
143

144 2
        $credentialRepository = $this->getCredentialRepository($store);
145

146 2
        $source = PublicKeyCredentialSource::createFromPublicKeyCredential(
147 2
            $publicKeyCredential,
148 2
            $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 2
        $credentialRepository->reset();
154

155
        // Persist the "credential source"
156 2
        $credentialRepository->saveCredentialSource($source);
157

158 2
        return Result::create()->setContext($credentialRepository->toArray());
159
    }
160

161
    /**
162
     * @param AttestationStatementSupportManager $attestationStatementSupportManager
163
     * @param StoreInterface $store
164
     * @return AuthenticatorAttestationResponseValidator
165
     */
166 0
    protected function getAuthenticatorAttestationResponseValidator(
167
        AttestationStatementSupportManager $attestationStatementSupportManager,
168
        StoreInterface $store
169
    ): AuthenticatorAttestationResponseValidator {
170 0
        $credentialRepository = $this->getCredentialRepository($store);
171

172 0
        return new AuthenticatorAttestationResponseValidator(
173 0
            $attestationStatementSupportManager,
174
            $credentialRepository,
175 0
            new TokenBindingNotSupportedHandler(),
176 0
            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 0
    public function getDescription(): string
188
    {
189 0
        return _t(
190 0
            __CLASS__ . '.DESCRIPTION',
191 0
            '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 0
    public function getSupportLink(): string
201
    {
202 0
        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 0
    public function getSupportText(): string
211
    {
212 0
        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 0
    public function getComponent(): string
221
    {
222 0
        return 'WebAuthnRegister';
223
    }
224

225
    /**
226
     * @return PublicKeyCredentialRpEntity
227
     */
228 2
    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 2
        $host = parse_url(Director::host(), PHP_URL_HOST);
233

234 2
        return new PublicKeyCredentialRpEntity(
235 2
            (string) SiteConfig::current_site_config()->Title,
236
            $host,
237 2
            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 2
    protected function getCredentialCreationOptions(
248
        StoreInterface $store,
249
        bool $reset = false
250
    ): PublicKeyCredentialCreationOptions {
251 2
        $state = $store->getState();
252

253 2
        if (!$reset && !empty($state) && !empty($state['credentialOptions'])) {
254 0
            return PublicKeyCredentialCreationOptions::createFromArray($state['credentialOptions']);
255
        }
256

257 2
        $credentialOptions = new PublicKeyCredentialCreationOptions(
258 2
            $this->getRelyingPartyEntity(),
259 2
            $this->getUserEntity($store->getMember()),
260 2
            random_bytes(32),
261 2
            [new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256)],
262 2
            40000,
263 2
            [],
264 2
            $this->getAuthenticatorSelectionCriteria(),
265 2
            PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
266 2
            new AuthenticationExtensionsClientInputs()
267
        );
268

269 2
        $store->setState(['credentialOptions' => $credentialOptions] + $state);
270

271 2
        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 2
    protected function getAuthenticatorSelectionCriteria(): AuthenticatorSelectionCriteria
286
    {
287 2
        return new AuthenticatorSelectionCriteria(
288 2
            (string) $this->config()->get('authenticator_attachment')
289
        );
290
    }
291
}

Read our documentation on viewing source code .

Loading