FreedomBox/plinth/models.py
Sunil Mohan Adapa 1a8868f0cd
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>
2026-03-31 07:48:50 -04:00

116 lines
3.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Django models for the main application
"""
import json
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.dispatch import receiver
from . import db
class KVStore(models.Model):
"""Model to store retrieve key/value configuration"""
key = models.TextField(primary_key=True)
value_json = models.TextField()
@property
def value(self):
"""Return the JSON decoded value of the key/value pair"""
return json.loads(self.value_json)
@value.setter
def value(self, val):
"""Store the value of the key/value pair by JSON encoding it"""
self.value_json = json.dumps(val)
class Module(models.Model):
"""Model to store current setup versions of a module."""
name = models.TextField(primary_key=True)
setup_version = models.IntegerField()
class UserProfile(models.Model):
"""Model to store user profile details that are not auth related."""
user = models.OneToOneField(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
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."""
with db.lock:
if hasattr(instance, 'userprofile'):
instance.userprofile.save()
else:
UserProfile.objects.update_or_create(user=instance)
class JSONField(models.TextField):
"""Store and retrieve JSON data into a TextField."""
def to_python(self, value):
"""Deserialize a text string from form field to Python dict."""
if not value:
return self.default()
try:
return json.loads(value)
except json.decoder.JSONDecodeError:
raise ValidationError('Invalid JSON value')
def from_db_value(self, value, *args, **kwargs):
"""Deserialize a value from DB to Python dict."""
return self.to_python(value)
def get_prep_value(self, value):
"""Serialize the Python dict to text for form field."""
return json.dumps(value or self.default())
class StoredNotification(models.Model):
"""Model to store a user notification."""
id = models.CharField(primary_key=True, max_length=128)
app_id = models.CharField(max_length=128, null=True, default=None)
severity = models.CharField(max_length=32)
title = models.CharField(max_length=256)
message = models.TextField(null=True, default=None)
actions = JSONField(default=list)
body_template = models.CharField(max_length=128, null=True, default=None)
data = JSONField(default=dict)
created_time = models.DateTimeField(auto_now_add=True)
last_update_time = models.DateTimeField(auto_now=True)
user = models.CharField(max_length=128, null=True, default=None)
group = models.CharField(max_length=128, null=True, default=None)
dismissed = models.BooleanField(default=False)