Skip to content

Commit

Permalink
Merge branch 'gh_1584' into 'update_version_110'
Browse files Browse the repository at this point in the history
Passkey support for login #1584

See merge request gh/20c/django-security-keys!1
  • Loading branch information
Zep-Tepi committed Sep 5, 2024
2 parents 0b96e9e + 17a38c8 commit 2136aa9
Show file tree
Hide file tree
Showing 17 changed files with 304 additions and 244 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 6 additions & 14 deletions src/django_security_keys/backends.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -35,37 +35,29 @@ 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

if not username or not credential:
return

has_credentials = SecurityKey.credentials(
username, request.session, for_login=True
username, for_login=True
)

# no credential supplied

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
45 changes: 24 additions & 21 deletions src/django_security_keys/ext/two_factor/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/"
Expand All @@ -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
)

Expand All @@ -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
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions src/django_security_keys/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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",
),
]
Loading

0 comments on commit 2136aa9

Please sign in to comment.