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 @@