This commit is contained in:
j3d1 2026-08-19 16:45:20 +02:00
parent 6d2167ac66
commit 4787acd8eb
23 changed files with 1241 additions and 25 deletions

View file

@ -1,7 +1,7 @@
from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference
AccountPreference, Group, GroupInvite, GroupInviteIncoming
class ToolshedUserAdmin(admin.ModelAdmin):
@ -29,8 +29,30 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'domain', 'get_members')
search_fields = ('name', 'domain', 'members__username')
@admin.display(description='Members')
def get_members(self, obj):
return ', '.join(str(member) for member in obj.members.all())
class GroupInviteAdmin(admin.ModelAdmin):
list_display = ('secret', 'group', 'invitee_username', 'invitee_domain')
search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain')
class GroupInviteIncomingAdmin(admin.ModelAdmin):
list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)
admin.site.register(Group, GroupAdmin)
admin.site.register(GroupInvite, GroupInviteAdmin)
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)

View file

@ -0,0 +1,56 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_accountpreference_id'),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.CharField(max_length=255)),
('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')),
],
),
migrations.CreateModel(
name='GroupInviteIncoming',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('inviter_username', models.CharField(max_length=255)),
('inviter_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GroupInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('invitee_username', models.CharField(max_length=255)),
('invitee_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')),
],
),
migrations.AddIndex(
model_name='group',
index=models.Index(fields=['name', 'domain'], name='group_idx'),
),
migrations.AlterUniqueTogether(
name='group',
unique_together={('name', 'domain')},
),
]

View file

@ -144,3 +144,42 @@ class FriendRequestIncoming(models.Model):
befriender_public_key = models.CharField(max_length=255)
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class Group(models.Model):
name = models.CharField(max_length=255)
domain = models.CharField(max_length=255)
members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups')
class Meta:
unique_together = ('name', 'domain')
indexes = [
models.Index(fields=['name', 'domain'], name='group_idx'),
]
def __str__(self):
return f"#{self.name}@{self.domain}"
def is_member(self, identity):
return self.members.filter(pk=identity.pk).exists()
class GroupInvite(models.Model):
"""A pending invite tracked on the group's own home backend, checked when the invitee's
accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own
backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance)."""
secret = models.CharField(max_length=255)
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invitee_username = models.CharField(max_length=255)
invitee_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class GroupInviteIncoming(models.Model):
secret = models.CharField(max_length=255)
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
inviter_username = models.CharField(max_length=255)
inviter_domain = models.CharField(max_length=255)
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from authentication.models import ToolshedUser
from authentication.models import ToolshedUser, Group
class OwnerSerializer(serializers.ReadOnlyField):
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
def to_representation(self, value):
return value.username + '@' + value.domain
class GroupOwnerSerializer(serializers.ReadOnlyField):
class Meta:
model = Group
fields = ['name', 'domain']
def to_representation(self, value):
return f"#{value.name}@{value.domain}"

View file

@ -16,6 +16,12 @@ def split_userhandle_or_throw(userhandle):
return username, domain
def split_grouphandle_or_throw(grouphandle):
if not grouphandle.startswith('#'):
raise ValueError('Group handle must be in the format #name@domain')
return split_userhandle_or_throw(grouphandle[1:])
def verify_request(request, raw_request_body):
authentication_header = request.META.get('HTTP_AUTHORIZATION')
@ -74,6 +80,36 @@ def verify_incoming_friend_request(request, raw_request_body):
return False
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
"""Self-certifying verifier for the two legs of the group invite/accept dance that land on a
backend which doesn't have the caller cached as a KnownIdentity yet (see
docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own
backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the
group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors
verify_incoming_friend_request exactly, just with configurable field names."""
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
except ValueError:
return False
try:
claimed_handle = request.data[handle_field]
claimed_key = request.data[key_field]
except KeyError:
return False
if not claimed_handle or not claimed_key:
return False
if username + "@" + domain != claimed_handle:
return False
if len(claimed_key) != 64:
return False
verify_key = VerifyKey(bytes.fromhex(claimed_key))
try:
verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex))
return True
except BadSignatureError:
return False
def authenticate_request_against_known_identities(request, raw_request_body):
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)

View file

@ -3,7 +3,7 @@ import json
from django.test import TestCase, Client
from nacl.encoding import HexEncoder
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from hostadmin.models import Domain
from nacl.signing import SigningKey
@ -86,3 +86,9 @@ class UserTestMixin:
domain=self.f['example_com'].name)
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
class GroupTestMixin:
def prepare_groups(self):
self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name)
self.f['group1'].members.add(self.f['local_user1'].public_identity)