Skip to content

Commit 2136aa9

Browse files
committed
Merge branch 'gh_1584' into 'update_version_110'
Passkey support for login #1584 See merge request gh/20c/django-security-keys!1
2 parents 0b96e9e + 17a38c8 commit 2136aa9

File tree

17 files changed

+304
-244
lines changed

17 files changed

+304
-244
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
Django webauthn security key support
1010

11-
Allows using webauthn for passwordless login and two-factor authentication.
11+
Allows using webauthn for passkey login and two-factor authentication.
1212

1313
2FA integration requires django-two-factor-auth and is handled by extending a custom django-otp device.
1414

docs/quickstart.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ INSTALLED_APPS += [
3737
]
3838
```
3939

40-
For password-less login to work `django_security_keys.backends.PasswordlessAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS`
40+
For passkey login to work `django_security_keys.backends.PasskeyAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS`
4141

4242
It also needs to be added as the first authentication backend.
4343

4444
```
4545
AUTHENTICATION_BACKENDS = (
46-
# for passwordless auth using security-key
46+
# for passkey auth using security-key
4747
# this needs to be first so it can do some clean up
48-
"django_security_keys.backends.PasswordlessAuthenticationBackend",
48+
"django_security_keys.backends.PasskeyAuthenticationBackend",
4949
5050
# additional auth backends
5151
"django.contrib.auth.backends.ModelBackend",

docs/settings.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ There are no default values for these as they are crucial for operation.
1414

1515
## django
1616

17-
For password-less login to work `django_security_keys.backends.PasswordlessAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS`
17+
For passkey login to work `django_security_keys.backends.PasskeyAuthenticationBackend` needs to be added to `AUTHENTICATION_BACKENDS`
1818

1919
It also needs to be added as the first authentication backend.
2020

2121
```
2222
AUTHENTICATION_BACKENDS = (
23-
# for passwordless auth using security-key
23+
# for passkey auth using security-key
2424
# this needs to be first so it can do some clean up
25-
"django_security_keys.backends.PasswordlessAuthenticationBackend",
25+
"django_security_keys.backends.PasskeyAuthenticationBackend",
2626
2727
# additional auth backends
2828
"django.contrib.auth.backends.ModelBackend",

src/django_security_keys/backends.py

+6-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
This backend allows password-less authentication using
2+
This backend allows passkey authentication using
33
a security key device.
44
55
It is important that it comes before any other authentication
@@ -17,10 +17,10 @@
1717
from django_security_keys.models import SecurityKey
1818

1919

20-
class PasswordlessAuthenticationBackend(ModelBackend):
20+
class PasskeyAuthenticationBackend(ModelBackend):
2121

2222
"""
23-
Password-less authentication through webauthn
23+
Passkey authentication through webauthn
2424
"""
2525

2626
def authenticate(
@@ -35,37 +35,29 @@ def authenticate(
3535
if not request:
3636
return
3737

38-
# clean up last used passwordless key
39-
40-
try:
41-
del request.session["webauthn_passwordless"]
42-
except KeyError:
43-
pass
44-
4538
credential = kwargs.get("u2f_credential")
4639

47-
# no username supplied, abort password-less login silently
40+
# no username supplied, abort passkey login silently
4841
# normal login process will raise required-field error
4942
# on username
5043

5144
if not username or not credential:
5245
return
5346

5447
has_credentials = SecurityKey.credentials(
55-
username, request.session, for_login=True
48+
username, for_login=True
5649
)
5750

5851
# no credential supplied
5952

6053
if not has_credentials:
6154
return
6255

63-
# verify password-less login
56+
# verify passkey login
6457
try:
6558
key = SecurityKey.verify_authentication(
6659
username, request.session, credential, for_login=True
6760
)
68-
request.session["webauthn_passwordless"] = key.id
6961
return key.user
7062
except Exception:
7163
raise

src/django_security_keys/ext/two_factor/views.py

+24-21
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
from django_security_keys.ext.two_factor import forms
1515
from django_security_keys.ext.two_factor.forms import SecurityKeyDeviceValidation
16-
from django_security_keys.models import SecurityKey, SecurityKeyDevice
17-
18-
16+
from django_security_keys.models import SecurityKey, SecurityKeyDevice, UserHandle
17+
import json
18+
from webauthn.helpers import base64url_to_bytes
1919
class DisableView(two_factor.views.DisableView):
2020
def dispatch(self, *args: Any, **kwargs: Any) -> HttpResponse:
2121
self.success_url = "/"
@@ -38,7 +38,7 @@ def has_security_key_step(self) -> bool:
3838
return False
3939

4040
return (
41-
len(SecurityKey.credentials(self.get_user().username, self.request.session))
41+
len(SecurityKey.credentials(self.get_user().username))
4242
> 0
4343
)
4444

@@ -52,49 +52,52 @@ def post(
5252
self, *args: Any, **kwargs: Any
5353
) -> HttpResponseRedirect | TemplateResponse:
5454
request = self.request
55-
passwordless = self.attempt_passwordless_auth(request, **kwargs)
56-
if passwordless:
57-
return passwordless
55+
if not request.POST.get("auth-username"):
56+
attempt_passkey_auth = self.attempt_passkey_auth(request, **kwargs)
57+
if attempt_passkey_auth:
58+
return attempt_passkey_auth
5859
return super().post(*args, **kwargs)
5960

60-
def attempt_passwordless_auth(
61+
def attempt_passkey_auth(
6162
self, request: WSGIRequest, **kwargs: Any
6263
) -> HttpResponseRedirect | None:
6364
"""
64-
Prepares and attempts a passwordless authentication
65+
Prepares and attempts a passkey authentication
6566
using a security key credential.
6667
6768
This requires that the auth-username and credential
6869
fields are set in the POST data.
6970
70-
This requires that the PasswordlessAuthenticationBackend is
71-
loaded.
7271
"""
7372

7473
if self.steps.current == "auth":
75-
credential = request.POST.get("credential")
76-
username = request.POST.get("auth-username")
77-
78-
# support password-less login using webauthn
79-
if username and credential:
74+
try:
75+
credential = request.POST.get("credential")
8076
try:
77+
user_handle = base64url_to_bytes(json.loads(credential)['response']['userHandle']).decode('utf-8')
78+
username = UserHandle.objects.get(handle=user_handle).user.username
79+
except:
80+
raise Exception("Failed login using passkey")
81+
# support passkey login using webauthn
82+
if username and credential:
8183
user = authenticate(
8284
request, username=username, u2f_credential=credential
8385
)
86+
if not user:
87+
raise Exception("Failed login using passkey")
8488
self.storage.reset()
8589
self.storage.authenticated_user = user
8690
self.storage.data["authentication_time"] = int(time.time())
8791
form = self.get_form(
8892
data=self.request.POST, files=self.request.FILES
8993
)
90-
9194
if self.steps.current == self.steps.last:
9295
return self.render_done(form, **kwargs)
9396
return self.render_next_step(form)
9497

95-
except Exception as exc:
96-
self.passwordless_error = f"{exc}"
97-
return self.render_goto_step("auth")
98+
except Exception as exc:
99+
self.passkey_error = f"{exc}"
100+
return self.render_goto_step("auth")
98101

99102
def get_context_data(
100103
self, form: AuthenticationForm | SecurityKeyDeviceValidation, **kwargs: Any
@@ -110,7 +113,7 @@ def get_context_data(
110113
if self.has_security_key_step():
111114
context["other_devices"] += [self.get_security_key_device()]
112115

113-
context["passwordless_error"] = getattr(self, "passwordless_error", None)
116+
context["passkey_error"] = getattr(self, "passkey_error", None)
114117

115118
if self.steps.current == "security-key":
116119
context["device"] = self.get_security_key_device()

src/django_security_keys/forms.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
class RegisterKeyForm(forms.Form):
55
name = forms.CharField(required=False)
66
credential = forms.CharField(required=True, widget=forms.HiddenInput)
7-
passwordless_login = forms.BooleanField(required=False)
7+
passkey_login = forms.BooleanField(required=False)
88

99

1010
class LoginForm(forms.Form):
11-
username = forms.CharField(required=True)
11+
username = forms.CharField(required=False)
1212
password = forms.CharField(required=False, widget=forms.PasswordInput)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.db import migrations, models
2+
3+
def migrate_passwordless_login_to_passkey_login(apps, schema_editor):
4+
model = apps.get_model("django_security_keys", "SecurityKey")
5+
try:
6+
model._meta.get_field("updated").auto_now = False
7+
for key in model.objects.all():
8+
key.passkey_login = key.passwordless_login
9+
key.save(update_fields=['passkey_login'])
10+
finally:
11+
model._meta.get_field("updated").auto_now = False
12+
13+
class Migration(migrations.Migration):
14+
15+
dependencies = [
16+
("django_security_keys", "0003_date_fields"),
17+
]
18+
19+
operations = [
20+
migrations.AddField(
21+
model_name="securitykey",
22+
name="passkey_login",
23+
field=models.BooleanField(
24+
default=False, help_text="User has enabled this key for passkey login"
25+
),
26+
),
27+
migrations.RunPython(migrate_passwordless_login_to_passkey_login),
28+
migrations.RemoveField(
29+
model_name="securitykey",
30+
name="passwordless_login",
31+
),
32+
]

0 commit comments

Comments
 (0)