Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passkey support #28

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
22 changes: 6 additions & 16 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,27 @@ 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
)
has_credentials = SecurityKey.credentials(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
48 changes: 26 additions & 22 deletions src/django_security_keys/ext/two_factor/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import time
from typing import Any

Expand All @@ -10,10 +11,11 @@
from django.http.response import HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.views.generic import FormView
from webauthn.helpers import base64url_to_bytes

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


class DisableView(two_factor.views.DisableView):
Expand All @@ -37,10 +39,7 @@ def has_security_key_step(self) -> bool:
if token_step_data:
return False

return (
len(SecurityKey.credentials(self.get_user().username, self.request.session))
> 0
)
return len(SecurityKey.credentials(self.get_user().username)) > 0

condition_dict = {
"backup": two_factor.views.LoginView.has_backup_step,
Expand All @@ -52,49 +51,54 @@ 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 Exception as exc:
raise Exception(f"Failed login using passkey: {exc}")
# 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 +114,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,34 @@
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
Loading