197 lines
10 KiB
Python
197 lines
10 KiB
Python
import secrets
|
|
|
|
from django.urls import path
|
|
from rest_framework import status
|
|
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
|
from rest_framework.generics import get_object_or_404
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework.viewsets import ViewSetMixin
|
|
|
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity, \
|
|
ToolshedUser
|
|
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
|
|
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
|
verify_incoming_group_invite
|
|
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
|
|
|
|
|
|
class Groups(APIView, ViewSetMixin):
|
|
authentication_classes = [SignatureAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, format=None):
|
|
groups = request.user.member_of_groups.all()
|
|
return Response(GroupSerializer(groups, many=True).data)
|
|
|
|
def post(self, request, format=None):
|
|
name = request.data.get('name')
|
|
if not name:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
|
|
if Group.objects.filter(name=name, domain=request.user.domain).exists():
|
|
return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'})
|
|
group = Group.objects.create(name=name, domain=request.user.domain)
|
|
group.members.add(request.user)
|
|
return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data)
|
|
|
|
|
|
class GroupDetail(APIView, ViewSetMixin):
|
|
authentication_classes = [SignatureAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, handle, format=None):
|
|
try:
|
|
name, domain = split_userhandle_or_throw(handle)
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
|
|
group = get_object_or_404(Group, name=name, domain=domain)
|
|
if not group.is_member(request.user):
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
return Response(GroupSerializer(group).data)
|
|
|
|
|
|
@api_view(['DELETE'])
|
|
@authentication_classes([SignatureAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def removeGroupMember(request, handle, identity_id, format=None):
|
|
try:
|
|
name, domain = split_userhandle_or_throw(handle)
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
|
|
group = get_object_or_404(Group, name=name, domain=domain)
|
|
if not group.is_member(request.user):
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
member = get_object_or_404(group.members, pk=identity_id)
|
|
if group.members.count() <= 1:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST,
|
|
data={'status': "cannot remove the group's last member"})
|
|
group.members.remove(member)
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([SignatureAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def createGroupInvite(request, handle, format=None):
|
|
try:
|
|
name, domain = split_userhandle_or_throw(handle)
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid group handle'})
|
|
group = get_object_or_404(Group, name=name, domain=domain)
|
|
if not group.is_member(request.user):
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
invitee = request.data.get('invitee')
|
|
if not invitee:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'})
|
|
try:
|
|
invitee_username, invitee_domain = split_userhandle_or_throw(invitee)
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'})
|
|
if group.members.filter(username=invitee_username, domain=invitee_domain).exists():
|
|
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'})
|
|
secret = secrets.token_hex(64)
|
|
GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain,
|
|
secret=secret)
|
|
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'})
|
|
|
|
|
|
class GroupInvitesIncoming(APIView, ViewSetMixin):
|
|
def get(self, request, format=None):
|
|
raw_request = request.body.decode('utf-8')
|
|
if not (user := authenticate_request_against_local_users(request, raw_request)):
|
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
|
invites = user.group_invites_incoming.all()
|
|
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
|
|
|
|
def post(self, request, format=None):
|
|
raw_request = request.body.decode('utf-8')
|
|
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
|
|
if field not in request.data:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
|
|
if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'):
|
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
|
try:
|
|
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
|
|
inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter'])
|
|
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
|
|
try:
|
|
invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain)
|
|
except ToolshedUser.DoesNotExist:
|
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'})
|
|
GroupInviteIncoming.objects.create(
|
|
group_name=group_name, group_domain=group_domain,
|
|
inviter_username=inviter_username, inviter_domain=inviter_domain,
|
|
invitee_user=invitee_user, secret=request.data['secret'])
|
|
return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'})
|
|
|
|
|
|
@api_view(['DELETE'])
|
|
@authentication_classes([SignatureAuthenticationLocal])
|
|
@permission_classes([IsAuthenticated])
|
|
def declineGroupInvite(request, pk, format=None):
|
|
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([SignatureAuthenticationLocal])
|
|
@permission_classes([IsAuthenticated])
|
|
def recordGroupMembership(request, pk, format=None):
|
|
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
|
|
membership, _ = GroupMembership.objects.get_or_create(
|
|
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
|
|
invite.delete()
|
|
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
|
|
|
|
|
|
class GroupMemberships(APIView, ViewSetMixin):
|
|
authentication_classes = [SignatureAuthenticationLocal]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, format=None):
|
|
memberships = request.user.group_memberships.all()
|
|
return Response(GroupMembershipSerializer(memberships, many=True).data)
|
|
|
|
|
|
@api_view(['POST'])
|
|
def acceptGroupInvite(request, format=None):
|
|
raw_request = request.body.decode('utf-8')
|
|
for field in ('group', 'invitee', 'invitee_key', 'secret'):
|
|
if field not in request.data:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
|
|
if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'):
|
|
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
|
|
try:
|
|
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
|
|
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
|
|
try:
|
|
group = Group.objects.get(name=group_name, domain=group_domain)
|
|
except Group.DoesNotExist:
|
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'})
|
|
invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username,
|
|
invitee_domain=invitee_domain, secret=request.data['secret'])
|
|
if not invite.exists():
|
|
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'})
|
|
identity, _ = KnownIdentity.objects.get_or_create(
|
|
username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key'])
|
|
group.members.add(identity)
|
|
invite.delete()
|
|
return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'})
|
|
|
|
|
|
urlpatterns = [
|
|
path('groups/', Groups.as_view(), name='groups'),
|
|
path('groups/<str:handle>/', GroupDetail.as_view(), name='group_detail'),
|
|
path('groups/<str:handle>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
|
|
path('groups/<str:handle>/invites/', createGroupInvite, name='create_group_invite'),
|
|
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
|
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
|
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
|
|
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
|
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
|
|
]
|