From 17a38c85c28eb0cdcf33a5d3813092aa1c074a79 Mon Sep 17 00:00:00 2001 From: Samsul Hadi Date: Thu, 5 Sep 2024 23:06:02 +0000 Subject: [PATCH] Passkey support for login #1584 --- README.md | 2 +- docs/quickstart.md | 6 +- docs/settings.md | 6 +- src/django_security_keys/backends.py | 20 +-- .../ext/two_factor/views.py | 45 +++--- src/django_security_keys/forms.py | 4 +- ...securitykey_passwordless_login_and_more.py | 32 ++++ src/django_security_keys/models.py | 79 ++++----- .../django-security-keys.js | 153 ++++++++++-------- .../django-security-keys/manage-keys.html | 4 +- src/django_security_keys/utils.py | 11 ++ src/django_security_keys/views.py | 69 ++++---- tests/fixtures.py | 58 +++++-- tests/project/settings.py | 4 +- tests/project/templates/two_factor/_base.html | 7 +- tests/test_models.py | 14 +- tests/test_views.py | 34 ++-- 17 files changed, 304 insertions(+), 244 deletions(-) create mode 100644 src/django_security_keys/migrations/0004_remove_securitykey_passwordless_login_and_more.py create mode 100644 src/django_security_keys/utils.py diff --git a/README.md b/README.md index b8e3471..a368cce 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Django webauthn security key support -Allows using webauthn for passwordless login and two-factor authentication. +Allows using webauthn for passkey login and two-factor authentication. 2FA integration requires django-two-factor-auth and is handled by extending a custom django-otp device. diff --git a/docs/quickstart.md b/docs/quickstart.md index c5c3ba4..670c24c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -37,15 +37,15 @@ INSTALLED_APPS += [ ] ``` -For password-less login to work `django_security_keys.backends.PasswordlessAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS` +For passkey login to work `django_security_keys.backends.PasskeyAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS` It also needs to be added as the first authentication backend. ``` AUTHENTICATION_BACKENDS = ( - # for passwordless auth using security-key + # for passkey auth using security-key # this needs to be first so it can do some clean up - "django_security_keys.backends.PasswordlessAuthenticationBackend", + "django_security_keys.backends.PasskeyAuthenticationBackend", # additional auth backends "django.contrib.auth.backends.ModelBackend", diff --git a/docs/settings.md b/docs/settings.md index e1df350..925dda1 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -14,15 +14,15 @@ There are no default values for these as they are crucial for operation. ## django -For password-less login to work `django_security_keys.backends.PasswordlessAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS` +For passkey login to work `django_security_keys.backends.PasskeyAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS` It also needs to be added as the first authentication backend. ``` AUTHENTICATION_BACKENDS = ( - # for passwordless auth using security-key + # for passkey auth using security-key # this needs to be first so it can do some clean up - "django_security_keys.backends.PasswordlessAuthenticationBackend", + "django_security_keys.backends.PasskeyAuthenticationBackend", # additional auth backends "django.contrib.auth.backends.ModelBackend", diff --git a/src/django_security_keys/backends.py b/src/django_security_keys/backends.py index e992ce0..0e0ad37 100644 --- a/src/django_security_keys/backends.py +++ b/src/django_security_keys/backends.py @@ -1,5 +1,5 @@ """ -This backend allows password-less authentication using +This backend allows passkey authentication using a security key device. It is important that it comes before any other authentication @@ -17,10 +17,10 @@ from django_security_keys.models import SecurityKey -class PasswordlessAuthenticationBackend(ModelBackend): +class PasskeyAuthenticationBackend(ModelBackend): """ - Password-less authentication through webauthn + Passkey authentication through webauthn """ def authenticate( @@ -35,16 +35,9 @@ def authenticate( if not request: return - # clean up last used passwordless key - - try: - del request.session["webauthn_passwordless"] - except KeyError: - pass - credential = kwargs.get("u2f_credential") - # no username supplied, abort password-less login silently + # no username supplied, abort passkey login silently # normal login process will raise required-field error # on username @@ -52,7 +45,7 @@ def authenticate( return has_credentials = SecurityKey.credentials( - username, request.session, for_login=True + username, for_login=True ) # no credential supplied @@ -60,12 +53,11 @@ def authenticate( if not has_credentials: return - # verify password-less login + # verify passkey login try: key = SecurityKey.verify_authentication( username, request.session, credential, for_login=True ) - request.session["webauthn_passwordless"] = key.id return key.user except Exception: raise diff --git a/src/django_security_keys/ext/two_factor/views.py b/src/django_security_keys/ext/two_factor/views.py index 6090cfa..8d4b269 100644 --- a/src/django_security_keys/ext/two_factor/views.py +++ b/src/django_security_keys/ext/two_factor/views.py @@ -13,9 +13,9 @@ from django_security_keys.ext.two_factor import forms from django_security_keys.ext.two_factor.forms import SecurityKeyDeviceValidation -from django_security_keys.models import SecurityKey, SecurityKeyDevice - - +from django_security_keys.models import SecurityKey, SecurityKeyDevice, UserHandle +import json +from webauthn.helpers import base64url_to_bytes class DisableView(two_factor.views.DisableView): def dispatch(self, *args: Any, **kwargs: Any) -> HttpResponse: self.success_url = "/" @@ -38,7 +38,7 @@ def has_security_key_step(self) -> bool: return False return ( - len(SecurityKey.credentials(self.get_user().username, self.request.session)) + len(SecurityKey.credentials(self.get_user().username)) > 0 ) @@ -52,49 +52,52 @@ def post( self, *args: Any, **kwargs: Any ) -> HttpResponseRedirect | TemplateResponse: request = self.request - passwordless = self.attempt_passwordless_auth(request, **kwargs) - if passwordless: - return passwordless + if not request.POST.get("auth-username"): + attempt_passkey_auth = self.attempt_passkey_auth(request, **kwargs) + if attempt_passkey_auth: + return attempt_passkey_auth return super().post(*args, **kwargs) - def attempt_passwordless_auth( + def attempt_passkey_auth( self, request: WSGIRequest, **kwargs: Any ) -> HttpResponseRedirect | None: """ - Prepares and attempts a passwordless authentication + Prepares and attempts a passkey authentication using a security key credential. This requires that the auth-username and credential fields are set in the POST data. - This requires that the PasswordlessAuthenticationBackend is - loaded. """ if self.steps.current == "auth": - credential = request.POST.get("credential") - username = request.POST.get("auth-username") - - # support password-less login using webauthn - if username and credential: + try: + credential = request.POST.get("credential") try: + user_handle = base64url_to_bytes(json.loads(credential)['response']['userHandle']).decode('utf-8') + username = UserHandle.objects.get(handle=user_handle).user.username + except: + raise Exception("Failed login using passkey") + # support passkey login using webauthn + if username and credential: user = authenticate( request, username=username, u2f_credential=credential ) + if not user: + raise Exception("Failed login using passkey") self.storage.reset() self.storage.authenticated_user = user self.storage.data["authentication_time"] = int(time.time()) form = self.get_form( data=self.request.POST, files=self.request.FILES ) - if self.steps.current == self.steps.last: return self.render_done(form, **kwargs) return self.render_next_step(form) - except Exception as exc: - self.passwordless_error = f"{exc}" - return self.render_goto_step("auth") + except Exception as exc: + self.passkey_error = f"{exc}" + return self.render_goto_step("auth") def get_context_data( self, form: AuthenticationForm | SecurityKeyDeviceValidation, **kwargs: Any @@ -110,7 +113,7 @@ def get_context_data( if self.has_security_key_step(): context["other_devices"] += [self.get_security_key_device()] - context["passwordless_error"] = getattr(self, "passwordless_error", None) + context["passkey_error"] = getattr(self, "passkey_error", None) if self.steps.current == "security-key": context["device"] = self.get_security_key_device() diff --git a/src/django_security_keys/forms.py b/src/django_security_keys/forms.py index 3f514c9..28130b8 100644 --- a/src/django_security_keys/forms.py +++ b/src/django_security_keys/forms.py @@ -4,9 +4,9 @@ class RegisterKeyForm(forms.Form): name = forms.CharField(required=False) credential = forms.CharField(required=True, widget=forms.HiddenInput) - passwordless_login = forms.BooleanField(required=False) + passkey_login = forms.BooleanField(required=False) class LoginForm(forms.Form): - username = forms.CharField(required=True) + username = forms.CharField(required=False) password = forms.CharField(required=False, widget=forms.PasswordInput) diff --git a/src/django_security_keys/migrations/0004_remove_securitykey_passwordless_login_and_more.py b/src/django_security_keys/migrations/0004_remove_securitykey_passwordless_login_and_more.py new file mode 100644 index 0000000..478d8b5 --- /dev/null +++ b/src/django_security_keys/migrations/0004_remove_securitykey_passwordless_login_and_more.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + +def migrate_passwordless_login_to_passkey_login(apps, schema_editor): + model = apps.get_model("django_security_keys", "SecurityKey") + try: + model._meta.get_field("updated").auto_now = False + for key in model.objects.all(): + key.passkey_login = key.passwordless_login + key.save(update_fields=['passkey_login']) + finally: + model._meta.get_field("updated").auto_now = False + +class Migration(migrations.Migration): + + dependencies = [ + ("django_security_keys", "0003_date_fields"), + ] + + operations = [ + migrations.AddField( + model_name="securitykey", + name="passkey_login", + field=models.BooleanField( + default=False, help_text="User has enabled this key for passkey login" + ), + ), + migrations.RunPython(migrate_passwordless_login_to_passkey_login), + migrations.RemoveField( + model_name="securitykey", + name="passwordless_login", + ), + ] diff --git a/src/django_security_keys/models.py b/src/django_security_keys/models.py index feecd79..c71761c 100644 --- a/src/django_security_keys/models.py +++ b/src/django_security_keys/models.py @@ -1,5 +1,5 @@ """ -Allows for passwordless login as well as using FIDO U2F for 2FA through django-two-factor. +Allows for passkey login as well as using FIDO U2F for 2FA through django-two-factor. 2FA integration is handled by extending a custom django-two-factor device. @@ -102,7 +102,7 @@ def require_for_user(cls, user: User | SimpleLazyObject) -> UserHandle: class SecurityKey(models.Model): """ - Describes a Webauthn (U2F) SecurityKey be used for passwordless + Describes a Webauthn (U2F) SecurityKey be used for passkey login or 2FA 2FA is handled through SecurityKeyDevice which allows integration @@ -129,8 +129,8 @@ class Meta: ) type = models.CharField(max_length=64) - passwordless_login = models.BooleanField( - default=False, help_text=_("User has enabled this key for passwordless login") + passkey_login = models.BooleanField( + default=False, help_text=_("User has enabled this key for passkey login") ) created = models.DateTimeField(auto_now_add=True) @@ -197,13 +197,14 @@ def generate_registration(cls, user: User, session: SessionStore) -> str: - `str` JSON string """ - + existing_credentials = SecurityKey.credentials(user.username,ignore_credential_filter=True) opts = webauthn.generate_registration_options( rp_id=settings.WEBAUTHN_RP_ID, rp_name=settings.WEBAUTHN_RP_NAME, user_id=UserHandle.require_for_user(user).handle, user_name=user.username, attestation=getattr(settings, "WEBAUTHN_ATTESTATION", "none"), + exclude_credentials=existing_credentials ) cls.set_challenge(session, opts.challenge) @@ -228,7 +229,7 @@ def verify_registration( - raw_credential (`str`): JSON formatted credential as returned by navigator.credentials.create - name (`str`="main"): nick name for the key - - passwordless_login (`bool`=False): enable the key for password-less + - passkey_login (`bool`=False): enable the key for passkey login Returns: @@ -270,7 +271,7 @@ def verify_registration( ), sign_count=verified_registration.sign_count, name=kwargs.get("name", "main"), - passwordless_login=kwargs.get("passwordless_login", False), + passkey_login=kwargs.get("passkey_login", False), attestation=bytes_to_base64url(verified_registration.attestation_object), type="security-key", ) @@ -281,24 +282,10 @@ def verify_registration( SecurityKeyDevice.require_for_user(user) return key - @classmethod - def clear_session(cls, session: SessionStore): - """ - Cleans up webauthn data for session - - Arguments: - - - session: request session - """ - - try: - del session["webauthn_passwordless"] - except KeyError: - pass @classmethod def credentials( - cls, username: User | str, session: SessionStore, for_login: bool = False + cls, username: User | str, for_login: bool = False, ignore_credential_filter = False ) -> list[PublicKeyCredentialDescriptor]: """ Returns a list of credentials for the specified username @@ -306,10 +293,9 @@ def credentials( Arguments: - username (`str`) - - session: django request session - for_login (`bool`=False): if True indicates that the - credentials are to be used for password-less login. - + credentials are to be used for passkey login. + - ignore_credential_filter (`bool`=False): if True it will ignore the for_login filter if False indicates that the credentials are to be used as a two-factor step @@ -319,22 +305,14 @@ def credentials( """ qset = cls.objects.filter(user__username=username) - - # if a security key was used for passwordless auth - # it should not be available for two factor auth - - pl_key_id = session.get("webauthn_passwordless") - if pl_key_id and not for_login: - qset = qset.exclude(id=pl_key_id) - - # if to be used for password-less login, exclude - # credentials that are not enabled for that. - if for_login: - qset = qset.filter(passwordless_login=True) - + # ignore credential_filter to get all credentials data + # example: used for excludeCredentials to prevent duplication of keys in 1 account in the same key + if not ignore_credential_filter: + # if to be used for passkey login, exclude + # credentials that are not enabled for that. + qset = qset.filter(passkey_login=for_login) return [ PublicKeyCredentialDescriptor( - type="public-key", id=base64url_to_bytes(key.credential_id), ) for key in qset @@ -352,21 +330,22 @@ def generate_authentication( - username (`str`) - session: django request session - - for_login: (`bool`=False): authentication options for password-less + - for_login: (`bool`=False): authentication options for passkey login Returns: - `str` JSON """ - - opts = webauthn.generate_authentication_options( - rp_id=settings.WEBAUTHN_RP_ID, - allow_credentials=cls.credentials(username, session, for_login=for_login), - ) - + options = { + "rp_id":settings.WEBAUTHN_RP_ID, + } + if not for_login: + options.update({ + "allow_credentials":cls.credentials(username, for_login=for_login) + }) + opts = webauthn.generate_authentication_options(**options) cls.set_challenge(session, opts.challenge) - return webauthn.options_to_json(opts) @classmethod @@ -386,7 +365,7 @@ def verify_authentication( - session: django request session - raw_credentials (`str`): JSON formatted PublicKeyCredential as returned from `navigator.credentials.get` - - for_login: (`bool`=False): verify a password-less login attempt + - for_login: (`bool`=False): verify a passkey login attempt Returns: @@ -409,8 +388,8 @@ def verify_authentication( except SecurityKey.DoesNotExist: raise ValueError(_("Security key authentication failed")) - if for_login and not key.passwordless_login: - raise ValueError(_("Security key not enabled for password-less login")) + if for_login and not key.passkey_login: + raise ValueError(_("Security key not enabled for passkey login")) # verify authentication diff --git a/src/django_security_keys/static/django-security-keys/django-security-keys.js b/src/django_security_keys/static/django-security-keys/django-security-keys.js index 8c6d6cb..1c74928 100644 --- a/src/django_security_keys/static/django-security-keys/django-security-keys.js +++ b/src/django_security_keys/static/django-security-keys/django-security-keys.js @@ -22,12 +22,63 @@ window.SecurityKeys = { this.config = config; - this.init_passwordless_login(); this.init_two_factor(); this.init_key_registration(); }, + init_autofill : async function(config) { + + this.config = config; + + await this.init_passkey_autofill(); + + }, + init_passkey_autofill : async function() { + var login_form = $(".login-form form") + + if ( + typeof window.PublicKeyCredential !== 'undefined' + && typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function' + ) { + const available = await PublicKeyCredential.isConditionalMediationAvailable(); + var url = this.config.url_request_authentication; + payload = {for_login:true} + payload.csrfmiddlewaretoken = this.config.csrf_token; + + if (available){ + $.post(url, payload, (response) => { + + response.challenge = base64url.decode(response.challenge); + + var assertion = navigator.credentials.get({publicKey: response,mediation: "conditional",}); + assertion.catch((exc) => { + if(error) + error(exc); + }); + assertion.then((PublicKeyCredential) => { + const decoder = new TextDecoder(); + var credentials = { + id: PublicKeyCredential.id, + rawId: base64url.encode(PublicKeyCredential.rawId), + response: { + authenticatorData: base64url.encode(PublicKeyCredential.response.authenticatorData), + clientDataJSON: base64url.encode(PublicKeyCredential.response.clientDataJSON), + signature: base64url.encode(PublicKeyCredential.response.signature), + userHandle: decoder.decode(PublicKeyCredential.response.userHandle) + }, + type: PublicKeyCredential.type + } + + payload.credential = JSON.stringify(credentials); + login_form.append($('').val(payload.credential)); + login_form.submit(); + }); + + }); + } + } + }, /** * Convert array-buffer to uint8 array * @@ -64,72 +115,6 @@ window.SecurityKeys = { return base64url.decode(b); }, - /** - * Initializes password-less login support for django-login - * form - * - * This is called automatically by `init()` - * - * @method init_passwordless_login() - */ - - init_passwordless_login : function() { - var login_form = $(".login-form form") - var login_step = login_form.find('[name="login_view-current_step"]'); - - // normal or unknown django login (no django-two-factor wizard found) - var normal_login = (login_form.length && !login_step.length); - - // django-two-factor login (wizard found and step is at "auth") - var two_factor_login = (login_step.val() == "auth"); - - if(normal_login || two_factor_login) { - var button_next = login_form.find('button[type="submit"]').filter('.btn-login,.btn-primary'); - var fn_submit = function(ev) { - var password = login_form.find("#id_auth-password, #id_password").val(); - var username= login_form.find("#id_auth-username, #id_username").val(); - - if(password == "" && username != "") { - - // prevent default form submit since we need to wait - // for credentials. - ev.preventDefault(); - - window.SecurityKeys.request_authenticate( - username, - true, - - (payload) => { - - // auth assertion successful, attach credentials - - login_form.append($('').val(payload.credential)); - login_form.submit(); - - }, - - () => { - - console.log("No credentials for user"); - - // no registered credentials - - login_form.submit(); - - } - ); - } - }; - - button_next.click(fn_submit); - login_form.find('input').on('keydown', (ev) => { - if(ev.which==13) { - fn_submit(ev); - } - }); - } - }, - /** * Initializes security keys for django-two-factor * @@ -269,7 +254,7 @@ window.SecurityKeys = { this.id = base64url.decode(this.id); }); - if(!response.allowCredentials.length) { + if(!for_login && !response.allowCredentials.length) { if(no_credentials) return no_credentials(); return; @@ -302,6 +287,35 @@ window.SecurityKeys = { }); }, + /** + * Converts a Base64 encoded string to an ArrayBuffer. + * + * This function takes a Base64 encoded string as input and converts it to an ArrayBuffer. + * It first decodes the Base64 string into a binary string, then creates an ArrayBuffer + * of the appropriate size and populates it with the decoded bytes. + * + * Note: + * - The function replaces '_' with '/' and '-' with '+' in the input string to handle URL-safe Base64 encoding. + * - If the input string is `null`, the function returns `null`. + * + * @param {string} b64_encoded_string - The Base64 encoded string to be converted. + * @returns {ArrayBuffer|null} The resulting ArrayBuffer containing the decoded bytes, + * or `null` if the input is `null`. + */ + b64str2ab : function(b64_encoded_string) { + if (b64_encoded_string == null) { + return null; + }; + + let string = atob(b64_encoded_string.replace(/_/g, '/').replace(/-/g, '+')), + buf = new ArrayBuffer(string.length), + bufView = new Uint8Array(buf); + for (var i = 0, strLen = string.length; i < strLen; i++) { + bufView[i] = string.charCodeAt(i); + } + return buf; + }, + /** * Security key registration process * @@ -325,6 +339,9 @@ window.SecurityKeys = { var challenge_str = SecurityKeys.base64_to_array_buffer(response.challenge); response.challenge = challenge_str; response.user.id = SecurityKeys.array_buffer_to_uint8(response.user.id); + response.excludeCredentials.forEach((credential) => { + credential.id = SecurityKeys.b64str2ab(credential.id); + }); navigator.credentials.create( {publicKey: response} ).then((credential) => { diff --git a/src/django_security_keys/templates/django-security-keys/manage-keys.html b/src/django_security_keys/templates/django-security-keys/manage-keys.html index 54142f6..51b3b9b 100644 --- a/src/django_security_keys/templates/django-security-keys/manage-keys.html +++ b/src/django_security_keys/templates/django-security-keys/manage-keys.html @@ -6,7 +6,7 @@

Your keys

Name - Password-less login + passkey login @@ -14,7 +14,7 @@

Your keys

{% for key in request.user.webauthn_security_keys.all %} {{ key.name }} - {% if key.passwordless_login %}yes{% else %}no{% endif %} + {% if key.passkey_login %}yes{% else %}no{% endif %}
{% csrf_token %} diff --git a/src/django_security_keys/utils.py b/src/django_security_keys/utils.py new file mode 100644 index 0000000..8e4f211 --- /dev/null +++ b/src/django_security_keys/utils.py @@ -0,0 +1,11 @@ +def convert_to_bool(data: bool) -> bool: + if data is None: + return False + + if isinstance(data, bool): + return data + + if isinstance(data, str): + return data.lower() == "true" + + return False diff --git a/src/django_security_keys/views.py b/src/django_security_keys/views.py index 9c86ce4..732cb4f 100644 --- a/src/django_security_keys/views.py +++ b/src/django_security_keys/views.py @@ -16,21 +16,9 @@ from django.utils.translation import gettext_lazy as _ from django_security_keys.forms import LoginForm, RegisterKeyForm -from django_security_keys.models import SecurityKey - - -def convert_to_bool(data: bool) -> bool: - if data is None: - return False - - if isinstance(data, bool): - return data - - if isinstance(data, str): - return data.lower() == "true" - - return False - +from django_security_keys.models import SecurityKey, UserHandle +from django_security_keys.utils import convert_to_bool +from webauthn.helpers import base64url_to_bytes def basic_logout(request: WSGIRequest) -> HttpResponseRedirect: """ @@ -44,7 +32,7 @@ def basic_logout(request: WSGIRequest) -> HttpResponseRedirect: def basic_login(request: WSGIRequest) -> HttpResponse | HttpResponseRedirect: """ - Very basic login handler that supports password-less login + Very basic login handler that supports passkey login mostly provided for example / testing purposes, you should likely create your own implementation of this """ @@ -54,19 +42,24 @@ def basic_login(request: WSGIRequest) -> HttpResponse | HttpResponseRedirect: form = LoginForm(request.POST) if form.is_valid(): - # basic form validation ok (at this point only username requirement + # basic form validation ok # has been validated - password = form.cleaned_data["password"] username = form.cleaned_data["username"] credential = request.POST.get("credential") - - if credential: - # credential is set, provide it in the authenticate request - - user = authenticate( - request, username=username, u2f_credential=credential - ) + user = None + if credential and not (username or password): + # credential is set and not set username, password, check username in credential.response.userHandle + try: + user_handle = base64url_to_bytes(json.loads(credential)['response']['userHandle']).decode('utf-8') + username = UserHandle.objects.get(handle=user_handle).user.username + user = authenticate( + request, username=username, u2f_credential=credential + ) + except: + import traceback + print(traceback.format_exc()) + form.add_error("__all__", "Failed login using passkey") else: # no credential, attempt to do a normal login with name and password @@ -88,8 +81,8 @@ def basic_login(request: WSGIRequest) -> HttpResponse | HttpResponseRedirect: else: # authentication failure - - form.add_error("__all__", "Invalid username / password") + if not form.has_error("__all__"): + form.add_error("__all__", "Invalid username / password") return render(request, "django-security-keys/login.html", {"form": form}) else: @@ -125,16 +118,12 @@ def request_authentication(request: WSGIRequest, **kwargs: Any) -> JsonResponse: """ Requests webauthn authentications options from the server as a JSON response - - Expects a `username` POST parameter """ username = request.POST.get("username") - for_login = request.POST.get("for_login") - - if not username: - return JsonResponse({"non_field_errors": _("No username supplied")}, status=403) - + for_login = convert_to_bool(request.POST.get("for_login",False)) + if not for_login and not username: + return JsonResponse({"non_field_errors": _("No username supplied")}, status=403) return JsonResponse( json.loads( SecurityKey.generate_authentication( @@ -154,21 +143,21 @@ def register_security_key(request: WSGIRequest, **kwargs: Any) -> JsonResponse: - credential(`base64`): registration credential - name(`str`): key nick name - - passwordless_login (`bool`): allow passwordless login + - passkey_login (`bool`): allow passkey login Returns a JSON response """ name = request.POST.get("name", "security-key") credential = request.POST.get("credential") - passwordless_login = convert_to_bool(request.POST.get("passwordless_login", False)) + passkey_login = convert_to_bool(request.POST.get("passkey_login", False)) security_key = SecurityKey.verify_registration( request.user, request.session, credential, name=name, - passwordless_login=passwordless_login, + passkey_login=passkey_login, ) return JsonResponse( @@ -188,7 +177,7 @@ def register_security_key_form( - credential(`base64`): registration credential - name(`str`): key nick name - - passwordless_login (`string`): "on" if enabled + - passkey_login (`string`): "on" if enabled This will return a html response """ @@ -201,7 +190,7 @@ def register_security_key_form( request.session, form.cleaned_data["credential"], name=form.cleaned_data["name"] or "security-key", - passwordless_login=form.cleaned_data["passwordless_login"], + passkey_login=form.cleaned_data["passkey_login"], ) return redirect(reverse("security-keys:manage-keys")) else: @@ -224,7 +213,7 @@ def verify_authentication(request: WSGIRequest) -> JsonResponse: #### login - the attempt is for a passwordless login process and will only + the attempt is for a passkey login process and will only success if the chosen key has that option enabled. #### 2fa diff --git a/tests/fixtures.py b/tests/fixtures.py index 360fc81..d6c7a3a 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -10,10 +10,11 @@ "user", "test_credential", "test_auth_credential", + "test_auth_credential_passkey", "invalid_auth_credential", "invalid_test_credential", "security_key", - "security_key_passwordless", + "security_key_passkey", ] @@ -51,10 +52,14 @@ def invalid_test_credential(): def test_auth_credential(): return _test_auth_credential() +@pytest.fixture +def test_auth_credential_passkey(): + return _test_auth_credential_passkey() + @pytest.fixture def invalid_auth_credential(): - user, session, cred = _test_auth_credential() + user, session, cred = _test_auth_credential_passkey() cred = json.loads(cred) cred["response"]["signature"] = cred["response"]["signature"].replace("o", "A") @@ -69,8 +74,8 @@ def security_key(): @pytest.fixture -def security_key_passwordless(): - return _security_key(passwordless_login=True) +def security_key_passkey(): + return _security_key(passkey_login=True) def _test_credential(): @@ -81,9 +86,9 @@ def _test_credential(): session.create() # update user handle to fit the test-credential below - UserHandle.require_for_user(user) - user.webauthn_user_handle.handle = "12345" - user.webauthn_user_handle.save() + # UserHandle.require_for_user(user) + # user.webauthn_user_handle.handle = "12345" + # user.webauthn_user_handle.save() # update challenge to fit the test-credential below SecurityKey.set_challenge( @@ -151,13 +156,48 @@ def _test_auth_credential(): return (user, session, cred) -def _security_key(passwordless_login=False): +def _test_auth_credential_passkey(): + from django_security_keys.models import SecurityKey, UserHandle + + user, session, key = _security_key() + + # update challenge to fit the test-credential below + SecurityKey.set_challenge( + session, + base64url_to_bytes( + "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" + ), + ) + UserHandle.objects.create( + user=user, + handle="xyW3XGlevvnRg2XgN7CeBuLKr_YJwmS2i_GM9eLt330" + ) + + cred = json.dumps( + { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", + "userHandle": "eHlXM1hHbGV2dm5SZzJYZ043Q2VCdUxLcl9ZSndtUzJpX0dNOWVMdDMzMA", + }, + "type": "public-key", + "clientExtensionResults": {}, + } + ) + + return (user, session, cred) + + +def _security_key(passkey_login=False): from django_security_keys.models import SecurityKey user, session, cred = _test_credential() key = SecurityKey.verify_registration( - user, session, cred, passwordless_login=passwordless_login + user, session, cred, passkey_login=passkey_login ) return (user, session, key) diff --git a/tests/project/settings.py b/tests/project/settings.py index 6bdb60a..33ca450 100644 --- a/tests/project/settings.py +++ b/tests/project/settings.py @@ -81,9 +81,9 @@ WSGI_APPLICATION = "project.wsgi.application" AUTHENTICATION_BACKENDS = ( - # for passwordless auth using security-key + # for passkey auth using security-key # this needs to be first so it can do some clean up - "django_security_keys.backends.PasswordlessAuthenticationBackend", + "django_security_keys.backends.PasskeyAuthenticationBackend", "django.contrib.auth.backends.ModelBackend", ) diff --git a/tests/project/templates/two_factor/_base.html b/tests/project/templates/two_factor/_base.html index 6b7ca6e..30f913c 100644 --- a/tests/project/templates/two_factor/_base.html +++ b/tests/project/templates/two_factor/_base.html @@ -15,11 +15,11 @@ {% if wizard.steps.current == "auth" %} - {% if passwordless_error %} + {% if passkey_error %}

- {{ passwordless_error }} + {{ passkey_error }}

@@ -28,8 +28,7 @@

{% blocktrans trimmed %} - For password-less authentication leave the password field - empty and make sure your + For passkey authentication click on username then follow the passkey options provided and ensure your registered U2F FIDO security key is available / connected. {% endblocktrans %}
diff --git a/tests/test_models.py b/tests/test_models.py index 18099ae..bbdc701 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -55,16 +55,16 @@ def test_security_key_verify_registration(test_credential): def test_security_key_credentials(security_key): user, session, key = security_key - assert len(SecurityKey.credentials(user.username, session)) == 1 - assert len(SecurityKey.credentials(user.username, session, for_login=True)) == 0 + assert len(SecurityKey.credentials(user.username)) == 1 + assert len(SecurityKey.credentials(user.username, for_login=True)) == 0 @pytest.mark.django_db -def test_security_key_credentials_passwordless(security_key_passwordless): - user, session, key = security_key_passwordless +def test_security_key_credentials_passwordless(security_key_passkey): + user, session, key = security_key_passkey - assert len(SecurityKey.credentials(user.username, session)) == 1 - assert len(SecurityKey.credentials(user.username, session, for_login=True)) == 1 + assert len(SecurityKey.credentials(user.username)) == 0 + assert len(SecurityKey.credentials(user.username, for_login=True)) == 1 @pytest.mark.django_db @@ -102,7 +102,7 @@ def test_security_key_verify_authentication_passwordless_success(test_auth_crede user, session, cred = test_auth_credential key = user.webauthn_security_keys.first() - key.passwordless_login = True + key.passkey_login = True key.save() assert SecurityKey.verify_authentication( diff --git a/tests/test_views.py b/tests/test_views.py index 08f9eca..41a1cd6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3,7 +3,6 @@ import pytest from django.test import Client from django.urls import reverse -from webauthn.helpers.exceptions import InvalidAuthenticationResponse from django_security_keys.models import SecurityKey @@ -22,11 +21,11 @@ def test_login(user): @pytest.mark.django_db -def test_passwordless_login(test_auth_credential): - user, session, cred = test_auth_credential +def test_passkey_login(test_auth_credential_passkey): + user, session, cred = test_auth_credential_passkey key = user.webauthn_security_keys.first() - key.passwordless_login = True + key.passkey_login = True key.save() c = Client() @@ -36,8 +35,7 @@ def test_passwordless_login(test_auth_credential): client_session = c.session SecurityKey.set_challenge(client_session, SecurityKey.get_challenge(session)) client_session.save() - - response = c.post(reverse("login"), {"username": user.username, "credential": cred}) + response = c.post(reverse("login"), {"credential": cred}) assert response.status_code == 302 response = c.get(reverse("security-keys:manage-keys")) @@ -45,11 +43,11 @@ def test_passwordless_login(test_auth_credential): @pytest.mark.django_db -def test_passwordless_login_failure_invalid_signature(invalid_auth_credential): +def test_passkey_login_failure_invalid_signature(invalid_auth_credential): user, session, cred = invalid_auth_credential key = user.webauthn_security_keys.first() - key.passwordless_login = True + key.passkey_login = True key.save() c = Client() @@ -60,17 +58,16 @@ def test_passwordless_login_failure_invalid_signature(invalid_auth_credential): SecurityKey.set_challenge(client_session, SecurityKey.get_challenge(session)) client_session.save() - with pytest.raises(InvalidAuthenticationResponse): - response = c.post( - reverse("login"), {"username": user.username, "credential": cred} - ) + response = c.post( + reverse("login"), {"credential": cred} + ) response = c.get(reverse("security-keys:manage-keys")) assert "Your keys" not in response.content.decode("utf-8") @pytest.mark.django_db -def test_passwordless_login_failure_key_not_enabled(test_auth_credential): +def test_passkey_login_failure_key_not_enabled(test_auth_credential): user, session, cred = test_auth_credential c = Client() @@ -81,7 +78,7 @@ def test_passwordless_login_failure_key_not_enabled(test_auth_credential): SecurityKey.set_challenge(client_session, SecurityKey.get_challenge(session)) client_session.save() - response = c.post(reverse("login"), {"username": user.username, "credential": cred}) + response = c.post(reverse("login"), {"credential": cred}) response = c.get(reverse("security-keys:manage-keys")) assert "Your keys" not in response.content.decode("utf-8") @@ -110,11 +107,11 @@ def test_django_two_factor_auth(test_auth_credential): @pytest.mark.django_db -def test_django_two_factor_auth_passwordless_login(test_auth_credential): - user, session, cred = test_auth_credential +def test_django_two_factor_auth_passkey_login(test_auth_credential_passkey): + user, session, cred = test_auth_credential_passkey key = user.webauthn_security_keys.first() - key.passwordless_login = True + key.passkey_login = True key.save() c = Client() @@ -125,8 +122,9 @@ def test_django_two_factor_auth_passwordless_login(test_auth_credential): response = c.post( reverse("two-factor-auth:login"), - {"auth-username": user.username, "credential": cred}, + {"credential": cred}, ) + print(response.content) assert response.status_code == 302