Coverage for src/django_otp_webauthn/helpers.py: 0%
155 statements
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:15 +0000
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:15 +0000
1import hashlib
2import json
3from typing import Optional
5from django.contrib.auth import get_user_model
6from django.contrib.auth.models import AbstractUser
7from django.http import HttpRequest
8from webauthn import (
9 base64url_to_bytes,
10 generate_authentication_options,
11 generate_registration_options,
12 options_to_json,
13 verify_authentication_response,
14 verify_registration_response,
15)
16from webauthn.helpers import (
17 generate_challenge,
18 parse_attestation_object,
19 parse_authentication_credential_json,
20 parse_registration_credential_json,
21)
22from webauthn.helpers.structs import (
23 AttestationConveyancePreference,
24 AuthenticatorAttachment,
25 AuthenticatorSelectionCriteria,
26 COSEAlgorithmIdentifier,
27 CredentialDeviceType,
28 PublicKeyCredentialRpEntity,
29 PublicKeyCredentialUserEntity,
30 RegistrationCredential,
31 ResidentKeyRequirement,
32 UserVerificationRequirement,
33)
34from webauthn.registration.verify_registration_response import VerifiedRegistration
36from django_otp_webauthn import exceptions
37from django_otp_webauthn.models import (
38 AbstractWebAuthnAttestation,
39 AbstractWebAuthnCredential,
40)
41from django_otp_webauthn.settings import app_settings
42from django_otp_webauthn.utils import get_attestation_model, get_credential_model
44User = get_user_model()
45WebAuthnCredential = get_credential_model()
46WebAuthnAttestation = get_attestation_model()
49class WebAuthnHelper:
50 """A wrapper class around the PyWebAuthn library."""
52 def __init__(self, request: HttpRequest):
53 self.request = request
55 def generate_challenge(self) -> bytes:
56 """Generate a challenge for the client to sign."""
57 return generate_challenge()
59 def get_relying_party_domain(self) -> str:
60 """Get the domain of the relying party.
62 This is the domain of your website or company. Like "acme.com".
64 The default implementation reads the setting `OTP_WEBAUTHN_RP_ID`.
66 See also: https://www.w3.org/TR/webauthn-3/#rp-id
67 """
68 # The domain for the relying party. The WebAuthn spec calls this the RP
69 # ID.
70 if app_settings.OTP_WEBAUTHN_RP_ID_CALLABLE:
71 func = app_settings._get_callable_setting("OTP_WEBAUTHN_RP_ID_CALLABLE")
72 return func(request=self.request)
73 return app_settings.OTP_WEBAUTHN_RP_ID
75 def get_relying_party_name(self) -> str:
76 """Get the name of the relying party. This is the name of your website
77 or company. Like "Acme Corporation".
79 This is sometimes displayed to the user during credential registration.
80 ('do you want to register a credential with Acme Corporation?')
82 The default implementation reads the setting `OTP_WEBAUTHN_RP_NAME`.
84 See also: https://www.w3.org/TR/webauthn-3/#rp-name
85 """
86 if app_settings.OTP_WEBAUTHN_RP_NAME_CALLABLE:
87 func = app_settings._get_callable_setting("OTP_WEBAUTHN_RP_NAME_CALLABLE")
88 return func(request=self.request)
89 return app_settings.OTP_WEBAUTHN_RP_NAME
91 def get_relying_party(self) -> PublicKeyCredentialRpEntity:
92 """Get the relying party entity."""
93 return PublicKeyCredentialRpEntity(
94 name=self.get_relying_party_name(),
95 id=self.get_relying_party_domain(),
96 )
98 def get_discoverable_credentials_preference(self) -> ResidentKeyRequirement:
99 """Determines if we'd like the authenticator to store the credential in
100 authenticator memory instead of encrypting and storing it on the the
101 server. When stored on the server, the private key is encrypted and
102 encoded in the credential ID in an opaque format. In such a case, the
103 server must provide the credential ID back to the client before the
104 client can authenticate using the credential.
106 Storing the credential in the authenticator makes it possible to do full
107 passwordless authentication, without the relying party having to provide
108 all credential IDs for a given user. Providing credentials to
109 unauthenticated users leaks information about the existence of the user
110 and their registered credential.
112 By default, this is set to 'preferred', which means we'd like the
113 authenticator to store the private key + metadata in persistent
114 authenticator memory if possible, but it's not required.
116 Some devices - like security keys - have limited memory and can't store
117 the private key and associated metadata for more than a couple
118 credentials. This is why we don't set this to 'required' by default.
120 Noteworthy is that some authenticators (like Apple iCloud and Android)
121 will always create discoverable credentials. Even if you set this to
122 'discouraged'.
124 **Note**: historically, this was referred to as "resident keys".
125 Resident keys mean the same thing as "discoverable credentials".
126 Resident key is now an outdated term used in older versions of the
127 WebAuthn spec but it still lives on in some places. This library
128 attempts to apply the term "discoverable credentials" consistently.
130 For more information about discoverable credentials / resident keys see:
131 - https://www.w3.org/TR/webauthn-2/#client-side-discoverable-public-key-credential-source
132 - https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/Resident_Keys.html
133 """
135 # If passwordless login is allowed, we require discoverable credentials
136 # because non-discoverable credentials can't be used for
137 # passwordless login. Users expect to be able to log in without a
138 # password if we show them that option. Why some passkeys don't work
139 # will cause confusion. Don't allow these devices to be registered in
140 # the first place.
141 if app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN:
142 return ResidentKeyRequirement.REQUIRED
144 # What 'preferred' means depends entirely on the browser.
145 # Some browsers will prefer creating discoverable credentials and some
146 # just don't. Firefox is known to not create discoverable credentials.
147 return ResidentKeyRequirement.PREFERRED
149 def get_attestation_conveyance_preference(self) -> AttestationConveyancePreference:
150 """Determines if we'd like the authenticator to send an attestation statement.
152 By default this is set to "none", which means we don't want the
153 authenticator to send an attestation statement.
155 The attestation statement is a signed statement from the authenticator
156 that can be used to verify the authenticity of the authenticator. It can
157 help answer the following question: "is this authenticator really from
158 the manufacturer it claims to be from?".
160 An example use case of this could be to ensure only authenticators from
161 a certain manufacturer are allowed to register - if that were a
162 requirement for your application.
164 This package uses py_webauthn which supports verifying attestation
165 statements. If you want to use this feature, you can set this to
166 "indirect" or "direct".
168 For more information about attestation, see: -
169 https://www.w3.org/TR/webauthn-2/#attestation-conveyance -
170 https://www.w3.org/TR/webauthn-2/#sctn-attestation -
171 https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/Attestation.html
172 """
173 return AttestationConveyancePreference.NONE
175 def get_authenticator_attachment_preference(
176 self,
177 ) -> Optional[AuthenticatorAttachment]:
178 """Get the authenticator attachment preference.
180 By default, this is set to None, which means we don't have a preference.
182 For more information about authenticator attachment, see:
183 - https://www.w3.org/TR/webauthn-2/#enumdef-authenticatorattachment
184 """
185 return None
187 def get_credential_display_name(self, user: AbstractUser) -> str:
188 """Get the display name for the credential.
190 This is used to display a name during registration and authentication.
192 The default implementation calls User.get_full_name() and falls back to
193 User.get_username().
194 """
196 if user.get_full_name():
197 return f"{user.get_full_name()} ({user.get_username()})"
199 return user.get_username()
201 def get_credential_name(self, user: AbstractUser) -> str:
202 """Get the name for the credential.
204 This is used to display the user's name during registration and
205 authentication.
207 The default implementation calls User.get_username()
208 """
209 return user.get_username()
211 def get_unique_anonymous_user_id(self, user: AbstractUser) -> bytes:
212 """Get a unique identifier for the user to use during WebAuthn
213 ceremonies. It must be a unique byte sequence no longer than 64 bytes.
215 To preserve user privacy, it must not contain any information that may
216 lead to the identification of the user. UUIDs may be a good choice for
217 this, plain email addresses and usernames are definitely not.
219 Clients can use this to identify if they already have a credential
220 stored for this user account and act accordingly.
222 For more information, see: -
223 https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id
224 """
225 # Because we lack a dedicated field to store random bytes in on the user
226 # model, we'll instead resort to hashing the user's primary key as that
227 # is unique too and will never change. Since this value doesn't have to
228 # be unique across different relying parties, we don't need to salt it.
229 # SHA-256 is used because it is fast, commonly used, and produces a
230 # 64-byte hash. This is good enough privacy, though not as perfect as
231 # random bytes.
232 # SECURITY NOTE: The attack vector for de-anonymizing by
233 # linking authenticator back to a specific user is small, but still
234 # present. If an attacker suspects the authenticator belongs to a
235 # specific user, they can obtain the suspected user's primary key and
236 # hash it to see if it matches the user ID stored on the authenticator.
237 # Random bytes never shared with anyone don't have this issue.
238 # TODO: document the need to override this method and to use random
239 # bytes instead.
240 return hashlib.sha256(bytes(user.pk)).digest()
242 def get_user_entity(self, user: AbstractUser) -> PublicKeyCredentialUserEntity:
243 """Get information about the user account a credential is being registered for."""
244 return PublicKeyCredentialUserEntity(
245 id=self.get_unique_anonymous_user_id(user),
246 name=self.get_credential_name(user),
247 display_name=self.get_credential_display_name(user),
248 )
250 def get_supported_key_algorithms(self) -> list[COSEAlgorithmIdentifier] | None:
251 """Get the key algorithms we support.
253 Should return a list of COSE algorithm identifiers that we support. Or
254 the special value of `None` to default to py_webauthn's algorithm list.
256 For example, to only support ECDSA_SHA_256 and ECDSA_SHA_512, you would
257 return [-7, -36] as those are the COSE algorithm identifiers for those
258 algorithms.
260 For more information, see: -
261 https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier -
262 https://www.iana.org/assignments/cose/cose.xhtml#algorithms
263 """
265 raw_algorithms = app_settings.OTP_WEBAUTHN_SUPPORTED_COSE_ALGORITHMS
266 if raw_algorithms == "all":
267 # Indicates all py_webauthn supported algorithms
268 return None
270 algorithms = [COSEAlgorithmIdentifier(a) for a in raw_algorithms if a in COSEAlgorithmIdentifier]
271 return algorithms
273 def get_generate_registration_options_kwargs(self, *, user: AbstractUser) -> dict:
274 """Get the keyword arguments to pass to `webauthn.generate_registration_options`."""
275 challenge = self.generate_challenge()
276 rp = self.get_relying_party()
277 user_entity = self.get_user_entity(user)
278 attestation_preference = self.get_attestation_conveyance_preference()
279 discoverable_credentials = self.get_discoverable_credentials_preference()
280 user_verification = UserVerificationRequirement.PREFERRED
281 exclude_credentials = WebAuthnCredential.get_credential_descriptors_for_user(user)
282 supported_algorithms = self.get_supported_key_algorithms()
283 authenticator_selection = AuthenticatorSelectionCriteria(
284 user_verification=user_verification, resident_key=discoverable_credentials
285 )
287 options = {
288 "attestation": attestation_preference,
289 "authenticator_selection": authenticator_selection,
290 "challenge": challenge,
291 "exclude_credentials": exclude_credentials,
292 "rp_id": rp.id,
293 "rp_name": rp.name,
294 "user_display_name": user_entity.display_name,
295 "user_id": user_entity.id,
296 "user_name": user_entity.name,
297 # Timeout is in milliseconds, but the setting is in seconds
298 "timeout": app_settings.OTP_WEBAUTHN_TIMEOUT_SECONDS * 1000,
299 }
301 if supported_algorithms:
302 options["supported_pub_key_algs"] = supported_algorithms
304 return options
306 def get_registration_extensions(self) -> dict:
307 """Get the extensions to request during registration. Data must be JSON serializable."""
308 return {
309 # https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/WebAuthn_extensions#credprops
310 # Request that the authenticator tell us if a discoverable
311 # credential was created. We keep track of this to determine if
312 # passwordless authentication is possible using this credential.
313 "credProps": True,
314 }
316 def get_registration_state(self, creation_options: dict) -> dict:
317 """Get the state to store during registration. This state will be used
318 to verify the registration later on."""
320 return {
321 "challenge": creation_options["challenge"],
322 "require_user_verification": creation_options["authenticatorSelection"]["userVerification"]
323 == UserVerificationRequirement.REQUIRED.value,
324 }
326 def register_begin(self, user: AbstractUser) -> tuple[dict, dict]:
327 """Begin the registration process."""
329 kwargs = self.get_generate_registration_options_kwargs(user=user)
330 options = generate_registration_options(**kwargs)
332 json_string = options_to_json(options)
333 # We work with dicts and not JSON strings, so we need to load the JSON
334 # string again. Sadly this causes extra overhead but it'll have to do
335 # for now.
336 data = json.loads(json_string)
338 # Manually add the extensions to the options, as the PyWebAuthn library
339 # doesn't support this yet.
340 extensions = self.get_registration_extensions()
341 data["extensions"] = extensions
343 state = self.get_registration_state(data)
345 return data, state
347 def get_allowed_origins(self) -> list[str]:
348 """Get the expected origins."""
349 origins = app_settings.OTP_WEBAUTHN_ALLOWED_ORIGINS
350 return origins
352 def register_complete(self, user: AbstractUser, state: dict, data: dict):
353 """Complete the registration process."""
354 credential = parse_registration_credential_json(data)
356 expected_challenge = base64url_to_bytes(state["challenge"])
357 allowed_origins = self.get_allowed_origins()
358 require_user_verification = state["require_user_verification"]
359 expected_rp_id = self.get_relying_party_domain()
360 supported_algorithms = self.get_supported_key_algorithms()
362 kwargs = {}
363 if supported_algorithms:
364 kwargs["supported_pub_key_algs"] = supported_algorithms
366 response = verify_registration_response(
367 credential=credential,
368 expected_challenge=expected_challenge,
369 expected_rp_id=expected_rp_id,
370 expected_origin=allowed_origins,
371 require_user_verification=require_user_verification,
372 **kwargs,
373 )
375 device = self.create_credential(user, response, credential, data)
376 device.save()
377 self.create_attestation(device, response.attestation_object, credential.response.client_data_json)
378 return device
380 def _check_discoverable(self, original_data: dict) -> Optional[bool]:
381 """Check the clientExtensionResults to determine if the credential was
382 created as discoverable.
384 SECURITY NOTE: clientExtensionResults is not signed by the
385 authenticator and is open to tampering by a malicious client. Since we
386 are only using it to inform the user and not to make security decisions,
387 this is fine.
388 """
389 if (
390 "clientExtensionResults" not in original_data
391 or "credProps" not in original_data["clientExtensionResults"]
392 or "rk" not in original_data["clientExtensionResults"]["credProps"]
393 ):
394 return None
396 return bool(original_data["clientExtensionResults"]["credProps"]["rk"])
398 def create_credential(
399 self,
400 user: AbstractUser,
401 response: VerifiedRegistration,
402 parsed_credential: RegistrationCredential,
403 original_data: dict,
404 ):
405 """Save the credential to the database."""
406 discoverable = self._check_discoverable(original_data)
407 transports = (
408 [x.value for x in parsed_credential.response.transports] if parsed_credential.response.transports else []
409 )
411 # We can't use the backup_eligible flag directly because it's not
412 # exposed in the py_webauthn API. We can however infer it from the
413 # credential device type. If it's a multi-device credential, it's backup
414 # eligible.
415 backup_eligible = response.credential_device_type == CredentialDeviceType.MULTI_DEVICE
416 backup_state = response.credential_backed_up
418 device = WebAuthnCredential(
419 user=user,
420 aaguid=response.aaguid,
421 credential_id=response.credential_id,
422 public_key=response.credential_public_key,
423 sign_count=response.sign_count,
424 transports=transports,
425 discoverable=discoverable,
426 backup_eligible=backup_eligible,
427 backup_state=backup_state,
428 )
429 # We don't save the device yet, this could result in errors if a custom
430 # model has added additional fields. The device will be saved by the
431 # caller. This method can be extended to set additional fields on the
432 # device before saving.
433 return device
435 def create_attestation(
436 self,
437 credential: AbstractWebAuthnCredential,
438 attestation_object: bytes,
439 client_data_json: bytes,
440 ) -> AbstractWebAuthnAttestation:
441 """Create an attestation statement for the device."""
442 parsed = parse_attestation_object(attestation_object)
444 return WebAuthnAttestation.objects.create(
445 credential=credential,
446 fmt=parsed.fmt,
447 data=attestation_object,
448 client_data_json=client_data_json,
449 )
451 def get_authentication_extensions(self) -> dict:
452 """Get the extensions to request during authentication. Data must be
453 JSON serializable."""
454 return {}
456 def get_generate_authentication_options_kwargs(
457 self, *, user: Optional[AbstractUser] = None, require_user_verification: bool
458 ) -> dict:
459 """Get the keyword arguments to pass to `webauth.generate_authentication_options`."""
461 kwargs = {
462 "challenge": generate_challenge(),
463 "rp_id": self.get_relying_party_domain(),
464 "timeout": app_settings.OTP_WEBAUTHN_TIMEOUT_SECONDS * 1000,
465 "user_verification": (
466 UserVerificationRequirement.REQUIRED
467 if require_user_verification
468 else UserVerificationRequirement.DISCOURAGED
469 ),
470 }
472 if user:
473 kwargs["allow_credentials"] = WebAuthnCredential.get_credential_descriptors_for_user(user)
475 return kwargs
477 def get_authentication_state(self, options: dict) -> dict:
478 """Get the state to store during authentication. This state will be used
479 to verify the authentication later on."""
480 return {
481 "challenge": options["challenge"],
482 "require_user_verification": options["userVerification"] == UserVerificationRequirement.REQUIRED.value,
483 }
485 def authenticate_begin(
486 self,
487 user: Optional[AbstractUser] = None,
488 require_user_verification: bool = True,
489 ):
490 """Begin the authentication process."""
491 kwargs = self.get_generate_authentication_options_kwargs(
492 user=user, require_user_verification=require_user_verification
493 )
494 options = generate_authentication_options(**kwargs)
496 json_string = options_to_json(options)
497 # We work with dicts and not JSON strings, so we need to load the JSON
498 # string again. Sadly this causes extra overhead but it'll have to do
499 # for now.
500 data = json.loads(json_string)
502 # Manually add the extensions to the options, as the PyWebAuthn library
503 # doesn't support this yet.
504 extensions = self.get_authentication_extensions()
505 data["extensions"] = extensions
507 state = self.get_authentication_state(data)
508 return data, state
510 def authenticate_complete(self, user: Optional[AbstractUser], state: dict, data: dict):
511 """Complete the authentication process."""
513 credential = parse_authentication_credential_json(data)
515 try:
516 device = WebAuthnCredential.get_by_credential_id(credential.raw_id)
517 except WebAuthnCredential.DoesNotExist:
518 raise exceptions.CredentialNotFound()
520 expected_challenge = base64url_to_bytes(state["challenge"])
521 expected_origins = self.get_allowed_origins()
522 require_user_verification = state["require_user_verification"]
523 expected_rp_id = self.get_relying_party_domain()
524 supported_algorithms = self.get_supported_key_algorithms()
526 kwargs = {}
527 if supported_algorithms:
528 kwargs["supported_algorithms"] = supported_algorithms
530 response = verify_authentication_response(
531 credential=credential,
532 credential_current_sign_count=device.sign_count,
533 credential_public_key=device.public_key,
534 expected_challenge=expected_challenge,
535 expected_rp_id=expected_rp_id,
536 expected_origin=expected_origins,
537 require_user_verification=require_user_verification,
538 **kwargs,
539 )
541 device.sign_count = response.new_sign_count
542 device.backup_state = response.credential_backed_up
543 device.set_last_used_timestamp(commit=False)
544 device.save(update_fields=["sign_count", "last_used_at", "backup_state"])
546 return device