mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-08 09:41:35 +00:00
users: Add support registering, editing, and deleting passkeys
Tests:
- Setup: add domain name mystable.example. Add an entry in /etc/hosts on the
test machine. In Firefox, in about:config, set
'security.webauthn.allow_with_certificate_override' to 'true'.
- Registration
- Passkey successful registration:
- After passkey registration, created time is time at which key is created.
- After passkey registration, domain is the domain with which the interface
is accessed at the time of addition of passkey.
- After passkey registration, Added and Last Used columns show the current
time in UTC. Signature counter and extensions and aaguid values in the DB
are as expected.
- First key's name is 'Key 1'. After that it is 'Key 2' and so on. If a key
is renamed as 'Key 4', then next key will be named 'Key 5'.
- Registering passkeys using testing container stable container works.
- Links:
- 'Manage passkeys' link is show in the user menu in navbar in both desktop
mode and mobile mode. Clicking on it redirects the browser to current
user's passkey management page.
- User's edit page shows 'Use passkeys for better security'. Clicking on the
link redirects the browser to passkey management page for the user who's
account is being edited.
- Listing:
- All passkeys are show properly. Name, domain, added, last used, and
operations show correctly.
- When using a browser without Javascript script shows an error alert.
- If not passkeys are present "No passkeys added to user account." message
is shown.
- Editing the passkey shows correct page. Title, heading, form labels, form
value, and buttons are as expected. After editing, passkey is updated
properly.
- Deleting the passkey shows a model dialog with correct details. After
confirmation, passkey is removed and page is refreshed.
- Error handling:
- On GNOME's Web, clicking the 'Add Passkey' shows the error 'Browser does
not support passkeys'.
- On Chromium, clicking the 'Add passkey' shows the error 'NotAllowedError:
WebAuthn is not supported on sites with TLS certificate errors.'
- Raising an error in passkey_add_begin() results in correct error message
shown with 'Add passkey' button is clicked. Status code is 500.
- Raising an error in passkey_add_complete() results in correct error
message shown after unlocking the hardware token. Status code is 500.
- Canceling the PIN dialog results in '...user denied permission' error
alert.
- Canceling the touch dialog results in '...user denied permission' error
alert.
- Multiple failed attempts result in multiple alerts being shown at the same
time.
- Editing another user's passkeys:
- Listing passkeys show correct list of passkeys for the user account being
managed.
- Adding passkeys adds correctly to the user account being managed.
- Editing passkey correctly edits passkey of the user account being managed.
Redirect happens to the correct page after.
- Deleting passkey correctly edits passkey of the user account being
managed. Redirect happens to the correct page after.
- If a non-admin user tries to access passkeys list/edit/delete URL of
another user, 403 Forbidden error is raised
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
fa22ecaa36
commit
1a8868f0cd
46
plinth/migrations/0006_userpasskey.py
Normal file
46
plinth/migrations/0006_userpasskey.py
Normal file
@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#
|
||||
# Generated by Django 4.2.28 on 2026-03-19 23:52
|
||||
#
|
||||
"""
|
||||
Django migration for adding the user's passkey model.
|
||||
"""
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('plinth', '0005_storednotification'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserPasskey',
|
||||
fields=[
|
||||
('id',
|
||||
models.AutoField(auto_created=True, primary_key=True,
|
||||
serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=256, null=True,
|
||||
blank=True)),
|
||||
('domain', models.CharField(max_length=256)),
|
||||
('created_time', models.DateTimeField(auto_now_add=True)),
|
||||
('last_used_time', models.DateTimeField(auto_now=True)),
|
||||
('signature_counter', models.IntegerField(default=0)),
|
||||
('registration_flags', models.IntegerField()),
|
||||
('extensions', models.JSONField(null=True, blank=True)),
|
||||
('aaguid', models.UUIDField(null=True, blank=True)),
|
||||
('credential_id', models.BinaryField(unique=True)),
|
||||
('public_key', models.BinaryField()),
|
||||
('user',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='passkeys',
|
||||
to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -44,6 +44,28 @@ class UserProfile(models.Model):
|
||||
language = models.CharField(max_length=32, null=True, default=None)
|
||||
|
||||
|
||||
class UserPasskey(models.Model):
|
||||
"""Model to store passkeys for a user account."""
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE, related_name='passkeys')
|
||||
|
||||
# Relying Party information
|
||||
name = models.CharField(max_length=256, null=True, blank=True)
|
||||
domain = models.CharField(max_length=256)
|
||||
created_time = models.DateTimeField(auto_now_add=True)
|
||||
last_used_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Authenticator data
|
||||
signature_counter = models.IntegerField(default=0)
|
||||
registration_flags = models.IntegerField()
|
||||
extensions = models.JSONField(null=True, blank=True)
|
||||
|
||||
# Credential data
|
||||
aaguid = models.UUIDField(null=True, blank=True)
|
||||
credential_id = models.BinaryField(unique=True)
|
||||
public_key = models.BinaryField()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=User)
|
||||
def _on_user_post_save(sender, instance, **kwargs):
|
||||
"""When the user model is saved, user profile too."""
|
||||
|
||||
207
plinth/modules/users/static/passkeys.js
Normal file
207
plinth/modules/users/static/passkeys.js
Normal file
@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the JavaScript
|
||||
* code in this page.
|
||||
*
|
||||
* This file is part of FreedomBox.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @licend The above is the entire license notice for the JavaScript code
|
||||
* in this page.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Decode a given base64 encoded (in web-mode) string to a binary array.
|
||||
*/
|
||||
function base64WebDecode(base64WebString) {
|
||||
let base64String = base64WebString
|
||||
.replaceAll('-', '+')
|
||||
.replaceAll('_', '/');
|
||||
const padding = base64String.length % 4;
|
||||
if (padding != 0) {
|
||||
base64String += '='.repeat(4 - padding);
|
||||
}
|
||||
|
||||
const binaryString = atob(base64String);
|
||||
const binaryArray = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
binaryArray[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return binaryArray;
|
||||
}
|
||||
|
||||
/*
|
||||
* Show an error as an bootstrap alert message and print to browser debug
|
||||
* console.
|
||||
*/
|
||||
function handleError(error_string, exception) {
|
||||
console.log(error_string, exception);
|
||||
const template = document.getElementById('passkey-message-template');
|
||||
template.querySelector('.message').innerText = exception.toString();
|
||||
const messages = document.getElementById('passkey-messages');
|
||||
messages.insertAdjacentHTML('beforeEnd', template.innerHTML);
|
||||
}
|
||||
|
||||
/*
|
||||
* Make a window.fetch() request and handle some common errors.
|
||||
*/
|
||||
async function jsonFetch(relative_url, options, operation) {
|
||||
let response, json = null;
|
||||
const consoleError = 'Could not perform operation: ' + operation;
|
||||
try {
|
||||
const url = new URL(relative_url, window.location.href);
|
||||
response = await window.fetch(url, options);
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
handleError(consoleError, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.ok && json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
if (json && json['error_string']) {
|
||||
handleError(consoleError, json['error_string']);
|
||||
} else {
|
||||
handleError(consoleError, `${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add a passkey. First send a request to the server to begin passkey creation
|
||||
* and get challenge and creation options. Then request the browser to talk to
|
||||
* the authenticator to create a passkey. Finally, pass the public key of the
|
||||
* newly create passkey along with creation results to the server.
|
||||
*/
|
||||
async function addPasskey(csrfToken) {
|
||||
console.log('Adding passkey');
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
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.
|
||||
//
|
||||
let options = await jsonFetch('add-begin/', {
|
||||
'method': 'POST',
|
||||
body: new URLSearchParams({'csrfmiddlewaretoken': csrfToken})
|
||||
}, 'initiate passkey registration');
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
options['publicKey']['user']['id'] = base64WebDecode(
|
||||
options['publicKey']['user']['id']);
|
||||
options['publicKey']['challenge'] = base64WebDecode(
|
||||
options['publicKey']['challenge']);
|
||||
|
||||
//
|
||||
// Create new key pair on the authenticator (via the browser).
|
||||
//
|
||||
let credential;
|
||||
try {
|
||||
credential = await navigator.credentials.create(
|
||||
{'publicKey': options['publicKey']});
|
||||
} catch (error) {
|
||||
handleError('Passkey registration failed.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Send the public key and authenticator response to the server for
|
||||
// verification and storage.
|
||||
//
|
||||
let completeResponse = await jsonFetch('add-complete/', {
|
||||
'method': 'POST',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
'body': JSON.stringify(credential),
|
||||
}, 'passkey registration');
|
||||
if (!completeResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Passkey registration succeeded.');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
/*
|
||||
* Show a confirmation dialog to the user to delete a passkey.
|
||||
*/
|
||||
function onPasskeyDeleteClicked(event) {
|
||||
const modalElement = document.getElementById('passkey-delete-confirm-dialog');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
|
||||
const passkeyElement = event.target.closest('.passkey');
|
||||
const nameElements = modalElement.querySelectorAll('.passkey-name');
|
||||
nameElements.forEach((element) => {
|
||||
element.innerText = passkeyElement.dataset.passkeyName;
|
||||
});
|
||||
modalElement.dataset.passkeyId = passkeyElement.dataset.passkeyId;
|
||||
|
||||
event.preventDefault();
|
||||
modal.show();
|
||||
}
|
||||
|
||||
/*
|
||||
* Send request to the server to delete a passkey by submitting a form (and
|
||||
* refreshing the page).
|
||||
*/
|
||||
function onPasskeyDeleteConfirmed(event) {
|
||||
const modelElement = document.getElementById('passkey-delete-confirm-dialog');
|
||||
const passkeyId = modelElement.dataset.passkeyId;
|
||||
const form = document.querySelector(
|
||||
`[data-passkey-id="${passkeyId}"] .form-passkey-delete`);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
/*
|
||||
* Attach event handler to 'Add Passkey', 'Delete Passkey', and 'Confirm Delete'
|
||||
* buttons. Retrieve CSRF token and pass it along.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const addPasskeyButton = document.getElementById('add-passkey');
|
||||
if (!addPasskeyButton) {
|
||||
// Not part of the Manage Passkeys page.
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = document.getElementsByName('csrfmiddlewaretoken')[0].value;
|
||||
|
||||
addPasskeyButton.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await addPasskey(csrfToken);
|
||||
});
|
||||
|
||||
const deleteButtons = document.querySelectorAll(
|
||||
'.passkey-delete-button');
|
||||
deleteButtons.forEach((element) => {
|
||||
element.addEventListener('click', onPasskeyDeleteClicked);
|
||||
});
|
||||
|
||||
const confirmDeleteButton = document.querySelector(
|
||||
'#passkey-delete-confirm-dialog .confirm');
|
||||
confirmDeleteButton.addEventListener('click', onPasskeyDeleteConfirmed);
|
||||
});
|
||||
7
plinth/modules/users/static/users.css
Normal file
7
plinth/modules/users/static/users.css
Normal file
@ -0,0 +1,7 @@
|
||||
/*
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
.operations form {
|
||||
display: inline;
|
||||
}
|
||||
22
plinth/modules/users/templates/users_passkey_edit.html
Normal file
22
plinth/modules/users/templates/users_passkey_edit.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>{{ title }}</h2>
|
||||
|
||||
<form class="form form-edit-passkey" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|bootstrap }}
|
||||
|
||||
<input type="submit" class="btn btn-primary"
|
||||
value="{% trans "Update Passkey" %}"/>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
157
plinth/modules/users/templates/users_passkeys.html
Normal file
157
plinth/modules/users/templates/users_passkeys.html
Normal file
@ -0,0 +1,157 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load extras %}
|
||||
|
||||
{% block page_js %}
|
||||
<script type="text/javascript" src="{% static 'users/passkeys.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_head %}
|
||||
<link type="text/css" rel="stylesheet" href="{% static 'users/users.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="passkey-message-template" class="d-none">
|
||||
<div class="alert alert-danger alert-dismissible
|
||||
d-flex align-items-center fade show"
|
||||
role="alert">
|
||||
<div class="me-2">
|
||||
{% icon 'exclamation-triangle' %}
|
||||
<span class="visually-hidden">{% trans "Error:" %}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% trans "Adding passkey failed: " %}
|
||||
<span class="message"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"
|
||||
aria-label="{% trans "Close" %}">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="localized-strings" class="d-none">
|
||||
<div id="browser-does-not-support-passkeys">
|
||||
{% trans "Browser does not support passkeys." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="passkey-messages">
|
||||
</div>
|
||||
|
||||
<h2>{% trans "Passkeys" %}</h2>
|
||||
|
||||
<noscript>
|
||||
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
||||
<div class="me-2">
|
||||
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{% trans "Error:" %}</span>
|
||||
</div>
|
||||
<div>
|
||||
{% blocktrans trimmed %}
|
||||
Working with passkeys requires using browser's Javascript API. Please
|
||||
enable Javascript support in your browser to continue.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Passkeys are way to verify user's identity using digital signatures.
|
||||
They are a more secure alternative to passwords. Secret information is
|
||||
kept with the user on their phone, laptop, or a hardware token and
|
||||
unlocked using a PIN, fingerprint, or face ID. No secrets are stored on
|
||||
the server. The server knows only the public information that can used to
|
||||
verify user's signatures.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<a id="add-passkey" href="#" class="btn btn-primary" role="button"
|
||||
title="{% trans 'Add passkey' %}">
|
||||
{% icon "plus" %}
|
||||
{% trans 'Add passkey' %}
|
||||
</a>
|
||||
</p>
|
||||
{% if object_list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "For Domain" %}</th>
|
||||
<th>{% trans "Added" %}</th>
|
||||
<th>{% trans "Last Used" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for passkey in object_list %}
|
||||
<tr class="passkey" data-passkey-name="{{ passkey.name }}"
|
||||
data-passkey-id="{{ passkey.id }}">
|
||||
<td>{{ passkey.name }}</td>
|
||||
<td>{{ passkey.domain }}</td>
|
||||
<td>{{ passkey.created_time }}</td>
|
||||
<td>{{ passkey.last_used_time }}</td>
|
||||
<td class="operations">
|
||||
<a class="btn btn-sm btn-default" role="button"
|
||||
href="{% url 'users:passkey_edit' passkey.user.username passkey.id %}">
|
||||
{% icon "pencil-square-o" %}
|
||||
</a>
|
||||
<form class="form form-passkey-delete" method="post"
|
||||
action="{% url 'users:passkey_delete' passkey.user.username passkey.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-default passkey-delete-button"
|
||||
type="submit">
|
||||
{% icon "trash-o" %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{% trans "No passkeys added to user account." %}
|
||||
{% endif %}
|
||||
|
||||
<div id="passkey-delete-confirm-dialog" class="modal" tabindex="-1"
|
||||
role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{% blocktrans trimmed with username=object.username %}
|
||||
Delete passkey <em class="passkey-name"></em>?
|
||||
{% endblocktrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
aria-label="{% trans 'Close' %}">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You will need this passkey's device to add it back again.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger confirm">
|
||||
{% trans "Delete passkey" %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -19,6 +19,11 @@
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
{% url 'users:passkeys' object.username as passkeys_url %}
|
||||
{% blocktrans trimmed %}
|
||||
Use <a href="{{ passkeys_url }}">passkeys</a> for better security.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% url 'users:change_password' object.username as change_password_url %}
|
||||
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@ -18,6 +18,21 @@ urlpatterns = [
|
||||
re_path(r'^sys/users/(?P<slug>[\w.@+-]+)/change_password/$',
|
||||
non_admin_view(views.UserChangePassword.as_view()),
|
||||
name='change_password'),
|
||||
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/$',
|
||||
non_admin_view(views.PasskeysList.as_view()), name='passkeys'),
|
||||
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/add-begin/$',
|
||||
non_admin_view(views.passkey_add_begin), name='passkey_add_begin'),
|
||||
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/add-complete/$',
|
||||
non_admin_view(views.passkey_add_complete),
|
||||
name='passkey_add_complete'),
|
||||
re_path(
|
||||
r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/'
|
||||
r'(?P<passkey_id>[\d]+)/edit/$',
|
||||
non_admin_view(views.PasskeyEdit.as_view()), name='passkey_edit'),
|
||||
re_path(
|
||||
r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/'
|
||||
r'(?P<passkey_id>[\d]+)/delete/$',
|
||||
non_admin_view(views.PasskeyDelete.as_view()), name='passkey_delete'),
|
||||
re_path(r'^accounts/login/$', public(views.LoginView.as_view()),
|
||||
name='login'),
|
||||
re_path(r'^accounts/logout/$', public(views.logout), name='logout'),
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Django views for user app."""
|
||||
|
||||
import functools
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import axes.utils
|
||||
import django.views.generic
|
||||
import fido2.cbor
|
||||
import fido2.features
|
||||
from django import shortcuts
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
@ -11,23 +20,36 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.auth.views import LoginView as DjangoLoginView
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic.edit import CreateView, FormView, UpdateView
|
||||
from django.views.generic.edit import (CreateView, DeleteView, FormView,
|
||||
UpdateView)
|
||||
from fido2 import webauthn
|
||||
from fido2.server import Fido2Server
|
||||
|
||||
import plinth.modules.ssh.privileged as ssh_privileged
|
||||
from plinth import translation
|
||||
from plinth.models import UserPasskey
|
||||
from plinth.modules import first_boot
|
||||
from plinth.utils import is_user_admin
|
||||
from plinth.views import AppView
|
||||
from plinth.version import Version
|
||||
from plinth.views import AppView, json_exception
|
||||
|
||||
from . import privileged
|
||||
from .forms import (AuthenticationForm, CaptchaForm, CreateUserForm,
|
||||
FirstBootForm, UserChangePasswordForm, UserUpdateForm)
|
||||
|
||||
# Enable fido2 basic features
|
||||
if Version(importlib.metadata.version('fido2')) < Version('2.0.0'):
|
||||
fido2.features.webauthn_json_mapping.enabled = True # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginView(DjangoLoginView):
|
||||
"""View to login to FreedomBox and set language preference."""
|
||||
@ -257,3 +279,170 @@ class FirstBootView(django.views.generic.CreateView):
|
||||
def get_success_url(self):
|
||||
"""Return the next first boot step after valid form submission."""
|
||||
return reverse(first_boot.next_step())
|
||||
|
||||
|
||||
def get_fido2_server(domain: str) -> Fido2Server:
|
||||
"""Return an instance of a Fido2Server."""
|
||||
relying_party = webauthn.PublicKeyCredentialRpEntity(
|
||||
id=domain, name='FreedomBox')
|
||||
return Fido2Server(relying_party)
|
||||
|
||||
|
||||
def require_owner_or_admin(view_func):
|
||||
"""Decorator to check if the view is called by owner or admin."""
|
||||
|
||||
@functools.wraps(view_func)
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
"""Wrap a view method and check ownership is valid."""
|
||||
if (kwargs['username'] != request.user.get_username()
|
||||
and not is_user_admin(request)):
|
||||
raise PermissionDenied
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
@method_decorator(require_owner_or_admin, name='dispatch')
|
||||
class PasskeysList(ContextMixin, django.views.generic.ListView):
|
||||
"""View to show a list of current passkeys for the user."""
|
||||
model = UserPasskey
|
||||
template_name = 'users_passkeys.html'
|
||||
title = gettext_lazy('Passkeys')
|
||||
|
||||
def get_queryset(self):
|
||||
"""Show only list of passkeys for this user."""
|
||||
return self.model.objects.filter(
|
||||
user__username=self.kwargs['username']).order_by('created_time')
|
||||
|
||||
|
||||
@json_exception
|
||||
@require_owner_or_admin
|
||||
@require_POST
|
||||
def passkey_add_begin(request, username):
|
||||
"""Verify passkey registration and add passkey to user's account."""
|
||||
user = User.objects.get(username=username)
|
||||
|
||||
# Domain
|
||||
domain = request.get_host().partition(':')[0]
|
||||
|
||||
# User
|
||||
username = user.get_username()
|
||||
user_entity = webauthn.PublicKeyCredentialUserEntity(
|
||||
id=user.id, name=username, display_name=username)
|
||||
|
||||
# Begin registration
|
||||
server = get_fido2_server(domain)
|
||||
create_options, state = server.register_begin(
|
||||
user_entity,
|
||||
user_verification=webauthn.UserVerificationRequirement.REQUIRED,
|
||||
resident_key_requirement=webauthn.ResidentKeyRequirement.PREFERRED)
|
||||
|
||||
logger.info('Passkey creation began for user - %s (%s)', username, user.id)
|
||||
|
||||
request.session['fido2_server_state'] = state
|
||||
return JsonResponse(dict(create_options))
|
||||
|
||||
|
||||
def _passkey_get_next_name(user):
|
||||
"""Return a suitable name for the next passkey of the given user."""
|
||||
number = 1
|
||||
for passkey in user.passkeys.all():
|
||||
match = re.match(r'^Key (\d+)$', passkey.name)
|
||||
if match:
|
||||
number = max(number, int(match[1]) + 1)
|
||||
|
||||
return f'Key {number}'
|
||||
|
||||
|
||||
@json_exception
|
||||
@require_owner_or_admin
|
||||
@require_POST
|
||||
def passkey_add_complete(request, username):
|
||||
"""Verify passkey registration and add passkey to user's account."""
|
||||
|
||||
def _response(result: bool, error_string: str):
|
||||
"""Return a JsonResponse object."""
|
||||
status = 200
|
||||
if not result:
|
||||
status = 400
|
||||
logger.error('Error completing passkey registration: %s',
|
||||
error_string)
|
||||
|
||||
return JsonResponse({
|
||||
'result': result,
|
||||
'error_string': error_string
|
||||
}, status=status)
|
||||
|
||||
user = User.objects.get(username=username)
|
||||
|
||||
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 registration
|
||||
server = get_fido2_server(domain)
|
||||
try:
|
||||
authenticator_data = server.register_complete(state, response)
|
||||
logger.info('Passkey creation completed for user - %s (%s)',
|
||||
user.get_username(), user.id)
|
||||
assert authenticator_data.is_user_present()
|
||||
assert authenticator_data.is_user_verified()
|
||||
logger.info('Passkey user is present and verified.')
|
||||
except Exception as exception:
|
||||
return _response(False, str(exception))
|
||||
|
||||
try:
|
||||
credential_data = authenticator_data.credential_data
|
||||
UserPasskey.objects.create(
|
||||
user=user,
|
||||
name=_passkey_get_next_name(user),
|
||||
domain=domain,
|
||||
signature_counter=authenticator_data.counter,
|
||||
registration_flags=authenticator_data.flags,
|
||||
extensions=authenticator_data.extensions,
|
||||
aaguid=uuid.UUID(f'urn:uuid:{credential_data.aaguid}'),
|
||||
credential_id=credential_data.credential_id,
|
||||
public_key=fido2.cbor.encode(credential_data.public_key),
|
||||
)
|
||||
except IntegrityError:
|
||||
return _response(False,
|
||||
_('Passkey with that identifier already exists.'))
|
||||
|
||||
return _response(True, None)
|
||||
|
||||
|
||||
@method_decorator(require_owner_or_admin, name='dispatch')
|
||||
class PasskeyEdit(ContextMixin, UpdateView):
|
||||
"""View to allow editing a passkey's name."""
|
||||
model = UserPasskey
|
||||
fields = ['name']
|
||||
pk_url_kwarg = 'passkey_id'
|
||||
title = _('Edit Passkey')
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return the template name to use."""
|
||||
return ['users_passkey_edit.html']
|
||||
|
||||
def get_success_url(self):
|
||||
"""Return the URL to visit if form edit succeeds."""
|
||||
return reverse('users:passkeys', args=[self.kwargs['username']])
|
||||
|
||||
|
||||
@method_decorator(require_owner_or_admin, name='dispatch')
|
||||
@method_decorator(require_POST, name='dispatch')
|
||||
class PasskeyDelete(DeleteView):
|
||||
"""View to delete a passkey."""
|
||||
model = UserPasskey
|
||||
pk_url_kwarg = 'passkey_id'
|
||||
|
||||
def get_success_url(self):
|
||||
"""Return the URL to visit if form edit succeeds."""
|
||||
return reverse('users:passkeys', args=[self.kwargs['username']])
|
||||
|
||||
@ -173,6 +173,13 @@
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="id_manage_passkeys_menu" class="dropdown-item"
|
||||
href="{% url 'users:passkeys' request.user.username %}"
|
||||
title="{% trans "Manage passkeys" %}">
|
||||
{% trans "Manage passkeys" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="id_change_password_menu" class="dropdown-item"
|
||||
href="{% url 'users:change_password' request.user.username %}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user