Snapshot: alpha-2026-9
This commit is contained in:
parent
9acf5a97e2
commit
d00b5c7961
241 changed files with 85546 additions and 2409 deletions
|
|
@ -1,6 +1,14 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as DjangoPermissionGroup
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
|
||||
|
||||
# django.contrib.auth's own permission-groups model is unused (this app authenticates via
|
||||
# signature_auth, not Django's built-in permission system) and otherwise collides in the admin
|
||||
# sidebar with our own Group (the actor-type group), which is registered below under the same
|
||||
# "Groups" label.
|
||||
admin.site.unregister(DjangoPermissionGroup)
|
||||
|
||||
|
||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||
|
|
@ -8,6 +16,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
|
|||
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
|
||||
|
||||
|
||||
class AccountPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'key', 'value')
|
||||
search_fields = ('user__username', 'key')
|
||||
|
||||
|
||||
class KnownIdentityAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'domain', 'public_key')
|
||||
search_fields = ('username', 'domain', 'public_key')
|
||||
|
|
@ -23,7 +36,36 @@ 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')
|
||||
|
||||
|
||||
class GroupMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'group_name', 'group_domain', 'created_at')
|
||||
search_fields = ('user__username', 'group_name', 'group_domain')
|
||||
|
||||
|
||||
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)
|
||||
admin.site.register(GroupMembership, GroupMembershipAdmin)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ from rest_framework.authtoken.models import Token
|
|||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||
from authentication.models import ToolshedUser, AccountPreference
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
|
||||
split_userhandle_or_throw
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
from hostadmin.models import Domain
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
|
|
@ -53,15 +56,71 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def getUserInfo(request):
|
||||
"""Get or update the authenticated local user's own account info; only the account owner may
|
||||
call this on their own home server (see getUserProfile for viewing a friend's public profile)."""
|
||||
user = request.user
|
||||
if request.method == 'PATCH':
|
||||
old_file = user.profile_picture
|
||||
if 'profile_picture' in request.data:
|
||||
profile_picture = request.data.get('profile_picture')
|
||||
if profile_picture is None:
|
||||
user.profile_picture = None
|
||||
elif type(profile_picture) == dict:
|
||||
serializer = FileSerializer(data=profile_picture)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=400)
|
||||
user.profile_picture = serializer.save()
|
||||
else:
|
||||
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
|
||||
elif 'profile_picture_id' in request.data:
|
||||
profile_picture_id = request.data.get('profile_picture_id')
|
||||
if profile_picture_id is None:
|
||||
user.profile_picture = None
|
||||
else:
|
||||
try:
|
||||
user.profile_picture = File.objects.get(id=profile_picture_id)
|
||||
except File.DoesNotExist:
|
||||
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
|
||||
user.save()
|
||||
|
||||
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \
|
||||
and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0:
|
||||
old_file.file.delete(save=False)
|
||||
old_file.delete()
|
||||
|
||||
return Response({
|
||||
'username': user.username,
|
||||
'domain': user.domain,
|
||||
'email': user.email
|
||||
'email': user.email,
|
||||
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
def getUserProfile(request, handle):
|
||||
"""Get another local user's public profile by handle, e.g. so a friend can look up an avatar;
|
||||
caller must be a friend of that user (or the user itself, signing with their own known
|
||||
identity rather than local credentials)."""
|
||||
try:
|
||||
username, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
return Response(status=400)
|
||||
try:
|
||||
target = ToolshedUser.objects.get(username=username, domain=domain)
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return Response(status=404)
|
||||
if target not in request.user.friends_or_self():
|
||||
return Response(status=403)
|
||||
return Response({
|
||||
'username': target.username,
|
||||
'domain': target.domain,
|
||||
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -99,11 +158,40 @@ def registerUser(request):
|
|||
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
|
||||
|
||||
|
||||
@api_view(['GET', 'PUT'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def account_preferences(request):
|
||||
"""Get or bulk-upsert the authenticated user's account-level preferences: GET returns the
|
||||
current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified
|
||||
keys untouched."""
|
||||
if request.method == 'PUT':
|
||||
if not isinstance(request.data, dict):
|
||||
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
|
||||
for key, value in request.data.items():
|
||||
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
|
||||
|
||||
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
|
||||
return Response(preferences)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def account_preference_detail(request, key):
|
||||
"""Reset a single account-level preference back to its default by deleting it."""
|
||||
AccountPreference.objects.filter(user=request.user, key=key).delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
router.register(r'users', UserViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('user/', getUserInfo),
|
||||
path('user/<str:handle>/', getUserProfile),
|
||||
path('register/', registerUser),
|
||||
path('token/', UserAuthToken.as_view()),
|
||||
path('self/preferences/', account_preferences),
|
||||
path('self/preferences/<str:key>/', account_preference_detail),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0001_initial'),
|
||||
('authentication', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='toolsheduser',
|
||||
name='profile_picture',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='profile_picture_users', to='files.file'),
|
||||
),
|
||||
]
|
||||
|
||||
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0002_toolsheduser_profile_picture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountPreference',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.JSONField()),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
|
||||
to='authentication.toolsheduser')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'key')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.2 on 2026-08-09 13:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0003_accountpreference'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountpreference',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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')},
|
||||
),
|
||||
]
|
||||
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 4.2.2 on 2026-08-26 16:25
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GroupMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('group_name', models.CharField(max_length=255)),
|
||||
('group_domain', models.CharField(max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'group_name', 'group_domain')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
|
|||
domain = models.CharField(max_length=255, default='localhost')
|
||||
private_key = models.CharField(max_length=255)
|
||||
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
|
||||
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='profile_picture_users')
|
||||
objects = ToolshedUserManager()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -111,6 +113,20 @@ class ToolshedUser(AbstractUser):
|
|||
return self.public_identity.public_key
|
||||
|
||||
|
||||
class AccountPreference(models.Model):
|
||||
"""A single account-level (server-synced, cross-device) preference as a key/value pair;
|
||||
device-level preferences are intentionally *not* stored here."""
|
||||
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'key')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user}: {self.key}"
|
||||
|
||||
|
||||
class FriendRequestOutgoing(models.Model):
|
||||
secret = models.CharField(max_length=255)
|
||||
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
|
||||
|
|
@ -125,3 +141,62 @@ 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 (mirror: GroupInviteIncoming on the invitee's backend; see
|
||||
docs/design-in-progress/groups-mvp.md)."""
|
||||
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)
|
||||
|
||||
|
||||
class GroupMembership(models.Model):
|
||||
"""A pointer kept on a member's own home backend, recording that the local user is a member
|
||||
of a group that may be hosted here or on a remote domain (mirrors FriendRequestIncoming's
|
||||
role: independently recorded on the member's own side, not just the group's authoritative
|
||||
backend). Written once the invitee's accept request against the group's home backend has
|
||||
succeeded (see GroupInvitesIncomingAccept), analogous to how a friendship is independently
|
||||
recorded on both sides via KnownIdentity.friends rather than only on one. See
|
||||
docs/design-in-progress/groups-mvp.md's 'Known limitation'."""
|
||||
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_memberships')
|
||||
group_name = models.CharField(max_length=255)
|
||||
group_domain = models.CharField(max_length=255)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'group_name', 'group_domain')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} MEMBER_OF #{self.group_name}@{self.group_domain}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from nacl.exceptions import BadSignatureError
|
|||
from nacl.signing import VerifyKey
|
||||
from rest_framework import authentication
|
||||
|
||||
from authentication.models import KnownIdentity, ToolshedUser
|
||||
from authentication.models import Group, KnownIdentity, ToolshedUser
|
||||
|
||||
|
||||
def split_userhandle_or_throw(userhandle):
|
||||
|
|
@ -16,6 +16,27 @@ 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 resolve_owner_handle(handle):
|
||||
"""Resolves a "user@domain" or "+name@domain" handle to (owner_user, owner_group), exactly one set. Raises ValueError if the handle doesn't parse at all (no '@'); returns (None, None) if it parses but names nothing that exists."""
|
||||
is_group = handle.startswith('+')
|
||||
name, domain = split_userhandle_or_throw(handle[1:] if is_group else handle)
|
||||
if is_group:
|
||||
try:
|
||||
return None, Group.objects.get(name=name, domain=domain)
|
||||
except Group.DoesNotExist:
|
||||
return None, None
|
||||
try:
|
||||
return ToolshedUser.objects.get(username=name, domain=domain), None
|
||||
except ToolshedUser.DoesNotExist:
|
||||
return None, None
|
||||
|
||||
|
||||
def verify_request(request, raw_request_body):
|
||||
authentication_header = request.META.get('HTTP_AUTHORIZATION')
|
||||
|
||||
|
|
@ -74,6 +95,32 @@ 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 group invite/accept dance. See
|
||||
docs/implementation.md#group-invite-and-accept-self-certifying-verification."""
|
||||
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)
|
||||
|
|
@ -106,11 +153,17 @@ def authenticate_request_against_local_users(request, raw_request_body):
|
|||
|
||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
return authenticate_request_against_known_identities(
|
||||
request, request.body.decode('utf-8')), None
|
||||
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
||||
# Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead
|
||||
# of treating the request as authenticated with an empty user.
|
||||
if identity is None:
|
||||
return None
|
||||
return identity, None
|
||||
|
||||
|
||||
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
return authenticate_request_against_local_users(
|
||||
request, request.body.decode('utf-8')), None
|
||||
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
|
||||
if user is None:
|
||||
return None
|
||||
return user, None
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import base64
|
||||
|
||||
from django.test import Client, RequestFactory
|
||||
from nacl.encoding import HexEncoder
|
||||
|
|
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
|
|||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
|
||||
from files.models import File
|
||||
|
||||
|
||||
class AuthorizationTestCase(ToolshedTestCase):
|
||||
|
|
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertEqual(reply.json()['email'], 'test1@abc.de')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
|
||||
def test_user_info2(self):
|
||||
target = "/auth/user/"
|
||||
|
|
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
|
||||
def test_user_info_patch_profile_picture(self):
|
||||
content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
|
||||
'profile_picture': {
|
||||
'data': content,
|
||||
'mime_type': 'image/png'
|
||||
}
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertTrue(reply.json()['profile_picture'])
|
||||
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
|
||||
self.assertEqual(File.objects.count(), 1)
|
||||
self.f['local_user1'].refresh_from_db()
|
||||
self.assertIsNotNone(self.f['local_user1'].profile_picture)
|
||||
|
||||
def test_user_info_patch_profile_picture_clear(self):
|
||||
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
|
||||
self.f['local_user1'].profile_picture = test_file
|
||||
self.f['local_user1'].save()
|
||||
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
self.f['local_user1'].refresh_from_db()
|
||||
self.assertIsNone(self.f['local_user1'].profile_picture)
|
||||
self.assertFalse(File.objects.filter(id=test_file.id).exists())
|
||||
|
||||
def test_user_info_patch_profile_picture_invalid(self):
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_user_info_patch_profile_picture_id(self):
|
||||
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
|
||||
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
|
||||
|
||||
def test_user_info_patch_profile_picture_id_not_found(self):
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_user_info_fail(self):
|
||||
reply = self.anonymous_client.get('/auth/user/')
|
||||
|
|
@ -308,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
|
||||
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
|
||||
self.anonymous_client = Client(SERVER_NAME='testserver')
|
||||
self.client = SignatureAuthClient()
|
||||
|
||||
def test_view_friend_profile(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
self.assertNotIn('email', reply.json())
|
||||
|
||||
def test_view_own_profile_via_handle(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
|
||||
def test_view_profile_not_friend(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.client.get(target, self.f['ext_user2'])
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_view_profile_unknown_user(self):
|
||||
target = '/auth/user/nosuchuser@example.com/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
def test_view_profile_bad_handle(self):
|
||||
target = '/auth/user/notahandle/'
|
||||
reply = self.client.get(target, self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_view_profile_unauthenticated(self):
|
||||
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||
reply = self.anonymous_client.get(target)
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
|
||||
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
@ -319,26 +412,26 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.client = SignatureAuthClient()
|
||||
|
||||
def test_friend_local(self):
|
||||
reply = self.client.get('/api/friends/', self.f['local_user1'])
|
||||
reply = self.client.get('/api/v1/friends/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_friend_external(self):
|
||||
reply = self.client.get('/api/friends/', self.f['ext_user1'])
|
||||
reply = self.client.get('/api/v1/friends/', self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_friend_fail(self):
|
||||
reply = self.anonymous_client.get('/api/friends/')
|
||||
reply = self.anonymous_client.get('/api/v1/friends/')
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail2(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
signature = self.f['local_user1'].sign("http://testserver2" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(self.f['local_user1']) + ':' + signature}
|
||||
reply = self.anonymous_client.get(target, **header)
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail3(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
unknown_user = DummyExternalUser('extuser3', 'external.org', False)
|
||||
signature = unknown_user.sign("http://testserver" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Signature ' + str(unknown_user) + ':' + signature}
|
||||
|
|
@ -346,7 +439,7 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 403)
|
||||
|
||||
def test_friend_fail4(self):
|
||||
target = "/api/friends/"
|
||||
target = "/api/v1/friends/"
|
||||
signature = self.f['local_user1'].sign("http://testserver" + target)
|
||||
header = {'HTTP_AUTHORIZATION': 'Auth ' + str(self.f['local_user1']) + ':' + signature}
|
||||
reply = self.anonymous_client.get(target, **header)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue