diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index 7ac9e8d03..5dab50054 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -36,7 +36,7 @@ class AuthenticationForm(DjangoAuthenticationForm): self.fields['username'].widget.attrs.update({ 'autofocus': 'autofocus', 'autocapitalize': 'none', - 'autocomplete': 'username' + 'autocomplete': 'username webauthn' }) diff --git a/plinth/modules/users/static/passkeys.js b/plinth/modules/users/static/passkeys.js index d6959cef4..36528cc7c 100644 --- a/plinth/modules/users/static/passkeys.js +++ b/plinth/modules/users/static/passkeys.js @@ -205,3 +205,120 @@ document.addEventListener('DOMContentLoaded', () => { '#passkey-delete-confirm-dialog .confirm'); confirmDeleteButton.addEventListener('click', onPasskeyDeleteConfirmed); }); + + +let passkeyLoginAbortController = null; + +/* + * Login with a passkey. First send a request to the server to begin logging in + * with passkey and get challenge and login operation options. Then request the + * browser to talk to the authenticator to sign the challenge with a passkey. + * Finally, pass the challenge signed with a known passkey along with other + * results to the server. + */ +async function loginWithPasskey(conditionalMediation, csrfToken, next) { + console.log('Signing in with a passkey. Conditional mediation: ', + conditionalMediation); + + if (!window.PublicKeyCredential) { + if (!conditionalMediation) { + const message = document.getElementById( + 'browser-does-not-support-passkeys').innerText.trim(); + handleError('Browser does not support passkeys', message); + } + return; + } + + // + // Request challenge and options from server. + // + const options = await jsonFetch('passkey-begin/', { + 'method': 'POST', + body: new URLSearchParams({'csrfmiddlewaretoken': csrfToken}) + }, 'initiate passkey login'); + if (!options) { + return; + } + + options['publicKey']['challenge'] = base64WebDecode( + options['publicKey']['challenge']); + + // Abort a previous login operation (such as conditional mediation operation + // if it is running). Firefox automatically does this but Chrome does not. + if (passkeyLoginAbortController) { + console.log('Explicitly aborting previous passkey login operation.'); + passkeyLoginAbortController.abort(); + } + + passkeyLoginAbortController = new AbortController(); + + // + // Sign the server challenge with passkey stored in authenticator (via the + // browser). + // + let credential; + try { + const getOptions = { + 'publicKey': options['publicKey'], + 'signal': passkeyLoginAbortController.signal + }; + if (conditionalMediation) { + getOptions['mediation'] = 'conditional'; + } + credential = await navigator.credentials.get(getOptions); + } catch (error) { + if (conditionalMediation && error.name == 'AbortError') { + // When user clicks on 'Log in with passkey', the process initiated + // with conditional mediation will be aborted. + console.log('Log in initiated with button, ' + + 'conditional mediation aborted.'); + return; + } + if (!conditionalMediation) { + // Don't show error message or retry. + handleError('Login with passkey failed.', error); + } + return; + } + + // Send the signature and the authenticator response to the server to + // complete the login process. + let completeResponse = await jsonFetch('passkey-complete/', { + 'method': 'POST', + 'headers': { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + 'body': JSON.stringify(credential), + }, 'login with passkey'); + if (!completeResponse) { + return; + } + + console.log('Login with passkey succeeded.'); + window.location.href = next; +} + +/* + * Attach an click event listener to 'Log in with passkey' button. + */ +document.addEventListener('DOMContentLoaded', async () => { + const loginWithPasskeyButton = document.getElementById('login-with-passkey'); + if (!loginWithPasskeyButton) { + // Not part of login page. + return; + } + + const csrfToken = document.getElementsByName('csrfmiddlewaretoken')[0].value; + const next = document.getElementsByName('next')[0].value; + loginWithPasskeyButton.addEventListener('click', async (event) => { + event.preventDefault(); + await loginWithPasskey(false, csrfToken, next); + }); + + // Login with conditional mediation. This means that user will see a + // 'passkey' option with autofill in the username field. The login method + // will be wait in the navigator.credentials.get() call until user selects + // that option. Don't await. + loginWithPasskey(true, csrfToken, next); +}); diff --git a/plinth/modules/users/templates/users_login.html b/plinth/modules/users/templates/users_login.html index e1774b884..91bc9068a 100644 --- a/plinth/modules/users/templates/users_login.html +++ b/plinth/modules/users/templates/users_login.html @@ -5,12 +5,46 @@ {% load bootstrap %} {% load i18n %} +{% load static %} +{% load extras %} {% block breadcrumbs %} {% endblock %} +{% block page_js %} + +{% endblock %} + {% block content %} +
+ +
+ +
+
+ {% trans "Browser does not support passkeys." %} +
+
+ +
+
+
@@ -28,4 +62,16 @@ + + {% endblock %} diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index 6aa1ee9bf..448e671c6 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -29,6 +29,11 @@ urlpatterns = [ r'^sys/users/(?P[\w.@+-]+)/passkeys/' r'(?P[\d]+)/edit/$', non_admin_view(views.PasskeyEdit.as_view()), name='passkey_edit'), + re_path(r'^accounts/login/passkey-begin/$', + public(views.passkey_login_begin), name='passkey_login_begin'), + re_path(r'^accounts/login/passkey-complete/$', + public(views.passkey_login_complete), + name='passkey_login_complete'), re_path( r'^sys/users/(?P[\w.@+-]+)/passkeys/' r'(?P[\d]+)/delete/$', diff --git a/plinth/modules/users/views.py b/plinth/modules/users/views.py index f4e5c3f97..42bcbbc82 100644 --- a/plinth/modules/users/views.py +++ b/plinth/modules/users/views.py @@ -12,8 +12,10 @@ import axes.utils import django.views.generic import fido2.cbor import fido2.features +import fido2.utils from django import shortcuts from django.contrib import messages +from django.contrib.auth import login as auth_login from django.contrib.auth import logout as auth_logout from django.contrib.auth import update_session_auth_hash from django.contrib.auth.models import User @@ -31,6 +33,7 @@ from django.views.generic.edit import (CreateView, DeleteView, FormView, UpdateView) from fido2 import webauthn from fido2.server import Fido2Server +from fido2.webauthn import AttestedCredentialData, AuthenticationResponse import plinth.modules.ssh.privileged as ssh_privileged from plinth import translation @@ -446,3 +449,126 @@ class PasskeyDelete(DeleteView): def get_success_url(self): """Return the URL to visit if form edit succeeds.""" return reverse('users:passkeys', args=[self.kwargs['username']]) + + +@json_exception +@require_POST +def passkey_login_begin(request): + """Begin the process of logging-in with passwords.""" + # Domain + domain = request.get_host().partition(':')[0] + + # Begin Authentication + server = get_fido2_server(domain) + request_options, state = server.authenticate_begin( + credentials=None, + user_verification=fido2.webauthn.UserVerificationRequirement.REQUIRED, + challenge=None) + + logger.info('Passkey login begins') + + request.session['fido2_server_state'] = state + return JsonResponse(dict(request_options)) + + +@json_exception +@require_POST +def passkey_login_complete(request): + """Complete the process of logging-in with passwords.""" + + def _response(result: bool, error_string: str): + """Return a JsonResponse object.""" + status = 200 + if not result: + status = 400 + logger.error('Error completing passkey login: %s', error_string) + + return JsonResponse({ + 'result': result, + 'error_string': error_string + }, status=status) + + try: + response = json.loads(request.body) + except json.decoder.JSONDecodeError as exception: + return _response(False, str(exception)) + + # Domain + domain = request.get_host().partition(':')[0] + + # State + state = request.session.get('fido2_server_state') + + # Complete Authentication + server = get_fido2_server(domain) + try: + authentication_repsonse = AuthenticationResponse.from_dict(response) + + if hasattr(authentication_repsonse, 'raw_id'): + # Library python3-fido2 >= 2.0.0 + credential_id = authentication_repsonse.raw_id + else: + # Library python3-fido2 < 2.0.0 + credential_id = authentication_repsonse.id + + selected_passkeys = UserPasskey.objects.filter( + credential_id=credential_id) + + if not len(selected_passkeys): + return _response(False, _('Passkey used is not known.')) + + selected_passkey = selected_passkeys[0] + + credentials = [ + AttestedCredentialData.create( + aaguid=selected_passkey.aaguid.bytes, + credential_id=selected_passkey.credential_id, + public_key=fido2.cbor.decode(selected_passkey.public_key)) + ] + credential_data = server.authenticate_complete( + state=state, credentials=credentials, + response=authentication_repsonse) + except Exception as exception: + return _response(False, str(exception)) + + try: + passkeys = UserPasskey.objects.filter( + credential_id=credential_data.credential_id, + public_key=fido2.cbor.encode(credential_data.public_key)) + except Exception as exception: + return _response(False, str(exception)) + + if not len(passkeys): + return _response(False, _('Passkey used is not known.')) + + assert len(passkeys) == 1 # credential_id is a unique field. + + passkey = passkeys[0] + + # Detect cloned passkeys using signature counter. + # See: https://www.w3.org/TR/webauthn/#signature-counter + authenticator_data = authentication_repsonse.response.authenticator_data + signature_counter = authenticator_data.counter + if (passkey.signature_counter and signature_counter + and signature_counter <= passkey.signature_counter): + # TODO: Notify user of a cloned passkey + logger.warning( + 'Potentially cloned passkey detected. Passkey ID in DB - %s, ' + 'credential ID - %s, signature counters - %s <= %s', passkey.id, + fido2.utils.websafe_encode(passkey.credential_id), + signature_counter, passkey.signature_counter) + + passkey.signature_counter = signature_counter + passkey.save() # Update the last used time + + # Needed by login(), stored in session, and used for permission checks. + passkey.user.backend = 'django.contrib.auth.backends.ModelBackend' + + # Perform user login into Django + auth_login(request, passkey.user) + response = _response(True, None) + if request.user.is_authenticated: + translation.set_language(request, response, + request.user.userprofile.language) + + return response