diff --git a/api-collection/Auth/CreateAccount/Invalid identifier.bru b/api-collection/Auth/CreateAccount/Invalid identifier.bru deleted file mode 100644 index f43f97c..0000000 --- a/api-collection/Auth/CreateAccount/Invalid identifier.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Invalid identifier - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/register - body: json - auth: none -} - -body:json { - { - "unique_identifier": "' 'DROP TABLE USERS;", - "username": " ", - "password": "wasd123", - "email": "mynameemail.com" - } -} diff --git a/api-collection/Auth/CreateAccount/Valid.bru b/api-collection/Auth/CreateAccount/Valid.bru deleted file mode 100644 index a9a5e87..0000000 --- a/api-collection/Auth/CreateAccount/Valid.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Valid - type: http - seq: 1 -} - -post { - url: http://localhost:8000/accounts/register - body: json - auth: none -} - -body:json { - { - "unique_identifier": "myname", - "username": "Name", - "password": "qweasd123", - "email": "myname@email.com" - } -} diff --git a/api-collection/Auth/LoginWithCreds/non existent account.bru b/api-collection/Auth/LoginWithCreds/non existent account.bru deleted file mode 100644 index 86d2d3f..0000000 --- a/api-collection/Auth/LoginWithCreds/non existent account.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: non existent account - type: http - seq: 3 -} - -post { - url: {{baseUrl}}/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "doesnot@exists.com", - "password": "qweasd123" - } -} diff --git a/api-collection/Auth/LoginWithCreds/not verified.bru b/api-collection/Auth/LoginWithCreds/not verified.bru deleted file mode 100644 index 112698f..0000000 --- a/api-collection/Auth/LoginWithCreds/not verified.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: not verified - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "myname@email.com", - "password": "qweasd123" - } -} diff --git a/api-collection/Auth/LoginWithCreds/success.bru b/api-collection/Auth/LoginWithCreds/success.bru deleted file mode 100644 index 1148a94..0000000 --- a/api-collection/Auth/LoginWithCreds/success.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: success - type: http - seq: 1 -} - -post { - url: http://127.0.0.1:8000/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "bob@bob.bob", - "password": "bob" - } -} diff --git a/api-collection/Auth/LoginWithToken/invalid token.bru b/api-collection/Auth/LoginWithToken/invalid token.bru deleted file mode 100644 index 279309f..0000000 --- a/api-collection/Auth/LoginWithToken/invalid token.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: invalid token - type: http - seq: 3 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -headers { - Authorization: Token 6dc6178ad72f5beA0581b6b49024cdbb41d85ffdd1fbbf40991cce24a69a327b -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/LoginWithToken/missing token.bru b/api-collection/Auth/LoginWithToken/missing token.bru deleted file mode 100644 index 98d5a4a..0000000 --- a/api-collection/Auth/LoginWithToken/missing token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: missing token - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/LoginWithToken/success.bru b/api-collection/Auth/LoginWithToken/success.bru deleted file mode 100644 index ea76d4c..0000000 --- a/api-collection/Auth/LoginWithToken/success.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: success - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -headers { - Authorization: Token 6dc6178ad72f5bed0581b6b49024cdbb41d85ffdd1fbbf40991cce24a69a327b -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/Mail confirmation/ConfirmAccount.bru b/api-collection/Auth/Mail confirmation/ConfirmAccount.bru deleted file mode 100644 index 2be10dc..0000000 --- a/api-collection/Auth/Mail confirmation/ConfirmAccount.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: ConfirmAccount - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/confirm-account - body: json - auth: none -} - -body:json { - { - "token": "asdasdasdasdasd" - } -} diff --git a/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru b/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru deleted file mode 100644 index c89db03..0000000 --- a/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Resend mail confirmation - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/resend-account-confirmation - body: json - auth: none -} - -body:json { - { - "email": "mail@mail.com" - } -} diff --git a/api-collection/Auth/ResendConfirmationMail/Valid.bru b/api-collection/Auth/ResendConfirmationMail/Valid.bru deleted file mode 100644 index b330560..0000000 --- a/api-collection/Auth/ResendConfirmationMail/Valid.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Valid - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/resend-account-confirmation - body: json - auth: none -} - -body:json { - { - "email": "mail@mail.com" - } -} diff --git a/api-collection/Auth/ResetPassword/confirm the reset.bru b/api-collection/Auth/ResetPassword/confirm the reset.bru deleted file mode 100644 index 083d350..0000000 --- a/api-collection/Auth/ResetPassword/confirm the reset.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: confirm the reset - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/reset-password/ - body: json - auth: none -} - -body:json { - { - "password": "admin" - } -} diff --git a/api-collection/Auth/ResetPassword/request a reset.bru b/api-collection/Auth/ResetPassword/request a reset.bru deleted file mode 100644 index ba36d97..0000000 --- a/api-collection/Auth/ResetPassword/request a reset.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: request a reset - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/reset-password/ - body: json - auth: none -} - -body:json { - { - "email": "admin@admin.com" - } -} diff --git a/api-collection/Auth/SHAChecks/check token.bru b/api-collection/Auth/SHAChecks/check token.bru deleted file mode 100644 index b97f56a..0000000 --- a/api-collection/Auth/SHAChecks/check token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: check token - type: http - seq: 2 -} - -post { - url: http://127.0.0.1:8000/accounts/check-SHA512-for-account/ - body: json - auth: none -} - -body:json { - { - "sha512_token" : "AA", - "unique_identifier" : "bob" - } -} diff --git a/api-collection/Auth/SHAChecks/folder.bru b/api-collection/Auth/SHAChecks/folder.bru deleted file mode 100644 index a531e7b..0000000 --- a/api-collection/Auth/SHAChecks/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: SHAChecks -} diff --git a/api-collection/Auth/SHAChecks/register token.bru b/api-collection/Auth/SHAChecks/register token.bru deleted file mode 100644 index fe9aeca..0000000 --- a/api-collection/Auth/SHAChecks/register token.bru +++ /dev/null @@ -1,21 +0,0 @@ -meta { - name: register token - type: http - seq: 1 -} - -post { - url: http://127.0.0.1:8000/accounts/register-SHA512-for-account/ - body: json - auth: inherit -} - -headers { - Authorization: Token 894bf0bfd6ef6c05feb6a3447dfc2f3a2fb0147cd7da498d2843021794297cf0 -} - -body:json { - { - "sha512_token" : "AA" - } -} diff --git a/api-collection/Characters/Create.bru b/api-collection/Characters/Create.bru deleted file mode 100644 index 6fdf5b6..0000000 --- a/api-collection/Characters/Create.bru +++ /dev/null @@ -1,26 +0,0 @@ -meta { - name: Create - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/persistence/characters/create - body: json - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} - -body:json { - { - "character_sheet_version": "1.0.0", - "fork_compatibility": "Not compatible", - "data": { - "name": "My Name", - "age": 31 - } - } -} diff --git a/api-collection/Characters/Delete.bru b/api-collection/Characters/Delete.bru deleted file mode 100644 index a4d03d2..0000000 --- a/api-collection/Characters/Delete.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: Delete - type: http - seq: 5 -} - -delete { - url: {{baseUrl}}/persistence/characters/7/delete - body: none - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/Get charracter token.bru b/api-collection/Characters/Get charracter token.bru deleted file mode 100644 index f8f9335..0000000 --- a/api-collection/Characters/Get charracter token.bru +++ /dev/null @@ -1,21 +0,0 @@ -meta { - name: Get charracter token - type: http - seq: 7 -} - -post { - url: http://127.0.0.1:8000/persistence/characters/GenForkToken - body: json - auth: none -} - -headers { - Authorization: Token 7cac756254c5574dbdd69e2129394337158b7929446576ede3fb2a43e179540c -} - -body:json { - { - "fork_compatibility": "UnityStationDevelop" - } -} diff --git a/api-collection/Characters/GetAll.bru b/api-collection/Characters/GetAll.bru deleted file mode 100644 index cebbd46..0000000 --- a/api-collection/Characters/GetAll.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: GetAll - type: http - seq: 3 -} - -get { - url: http://127.0.0.1:8000/persistence/characters - body: none - auth: none -} - -headers { - Authorization: Token a74030290fa0dbc6d85f2e8dd885bbb76d76d9dedfe7c82bc60d72e8bd09210c -} diff --git a/api-collection/Characters/GetCharacter.bru b/api-collection/Characters/GetCharacter.bru deleted file mode 100644 index 5f62c64..0000000 --- a/api-collection/Characters/GetCharacter.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: GetCharacter - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/persistence/characters/8 - body: none - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/GetCompatible Token.bru b/api-collection/Characters/GetCompatible Token.bru deleted file mode 100644 index 93a529c..0000000 --- a/api-collection/Characters/GetCompatible Token.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: GetCompatible Token - type: http - seq: 8 -} - -get { - url: http://localhost:8000/persistence/characters/compatibleToken?character_sheet_version=1.0.0 - body: none - auth: none -} - -params:query { - character_sheet_version: 1.0.0 -} - -headers { - X-Character-Token: eyJzZXJ2ZXJfaWQiOiJVbml0eVN0YXRpb25EZXZlbG9wIiwidXVpZCI6ImJvYiIsIm5vbmNlIjoiMTFkMTI3NzdhNTZlNWViZCJ9:1ukm1R:-l14SCkeDVJHIx9_J8fAUeQDIoHCcDavviBmqtpqcBo -} diff --git a/api-collection/Characters/GetCompatible.bru b/api-collection/Characters/GetCompatible.bru deleted file mode 100644 index a5e067e..0000000 --- a/api-collection/Characters/GetCompatible.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: GetCompatible - type: http - seq: 3 -} - -get { - url: {{baseUrl}}/persistence/characters/compatible?fork_compatibility=Unitystation&character_sheet_version=1.0.0 - body: none - auth: none -} - -query { - fork_compatibility: Unitystation - character_sheet_version: 1.0.0 -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/Update.bru b/api-collection/Characters/Update.bru deleted file mode 100644 index 4ec818f..0000000 --- a/api-collection/Characters/Update.bru +++ /dev/null @@ -1,27 +0,0 @@ -meta { - name: Update - type: http - seq: 6 -} - -patch { - url: {{baseUrl}}/persistence/characters/8/update - body: json - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} - -body:json { - { - "id": 20, - "account": "AnotherAccount", - "fork_compatibility": "Not compatible", - "data": { - "age": 31, - "name": "Another name" - } - } -} diff --git a/api-collection/admin/folder.bru b/api-collection/admin/folder.bru deleted file mode 100644 index 7693800..0000000 --- a/api-collection/admin/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: admin -} diff --git a/api-collection/bruno.json b/api-collection/bruno.json deleted file mode 100644 index 37d4e05..0000000 --- a/api-collection/bruno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "1", - "name": "central-command", - "type": "collection" -} \ No newline at end of file diff --git a/api-collection/environments/development.bru b/api-collection/environments/development.bru deleted file mode 100644 index 54e0104..0000000 --- a/api-collection/environments/development.bru +++ /dev/null @@ -1,3 +0,0 @@ -vars { - baseUrl: https://dev-api.unitystation.org -} diff --git a/api-collection/environments/local.bru b/api-collection/environments/local.bru deleted file mode 100644 index 2de9da6..0000000 --- a/api-collection/environments/local.bru +++ /dev/null @@ -1,3 +0,0 @@ -vars { - baseUrl: http://localhost:8000 -} diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index eba6557..67aec33 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -98,3 +98,10 @@ def validate(self, data): class EmailSerializer(serializers.Serializer): email = serializers.EmailField() + +class ConnectionChallengeSerializer(serializers.Serializer): + connection_challenge = serializers.CharField(min_length=128, max_length=128) + +class AuthRequestSerializer(ConnectionChallengeSerializer): + #previously used to be allow_blank but blank seems to get interpreted as not being present, which causes + fork_compatibility = serializers.CharField(max_length=64) \ No newline at end of file diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index 0cfa98d..718abc1 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -2,12 +2,12 @@ from knox import views as knox_views from .views import ( - CheckSHA512ForAccountView, + RedeemSessionView, ConfirmAccountView, LoginWithCredentialsView, LoginWithTokenView, RegisterAccountView, - RegisterSHA512ForAccount, + AuthRequestView, RequestPasswordResetView, RequestVerificationTokenView, ResendAccountConfirmationView, @@ -47,6 +47,6 @@ name="reset-password-token", ), path("reset-password/", RequestPasswordResetView.as_view(), name="reset-password"), - path("register-SHA512-for-account/", RegisterSHA512ForAccount.as_view(), name="register-SHA512-for-account"), - path("check-SHA512-for-account/", CheckSHA512ForAccountView.as_view(), name="check-SHA512-for-account"), + path("auth-request/", AuthRequestView.as_view(), name="auth-request"), + path("redeem-session/", RedeemSessionView.as_view(), name="redeem-session"), ] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index a59477c..7a6d99e 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -6,14 +6,14 @@ from uuid import uuid4 from django.conf import settings +from django.core import signing from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.core.management import BaseCommand from django.utils import timezone from drf_spectacular.utils import extend_schema from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView -from rest_framework import serializers, status +from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -23,8 +23,10 @@ from commons.error_response import ErrorResponse from commons.mail_wrapper import send_email_with_template -from ..models import Account, AccountConfirmation, PasswordResetRequestModel, SHA512Token +from ..models import Account, AccountConfirmation, PasswordResetRequestModel, ConnectionChallenge from .serializers import ( + AuthRequestSerializer, + ConnectionChallengeSerializer, ConfirmAccountSerializer, EmailSerializer, LoginWithCredentialsSerializer, @@ -347,7 +349,7 @@ class ResendAccountConfirmationView(GenericAPIView): permission_classes = (AllowAny,) serializer_class = EmailSerializer - def post(self, request, *args, **kwargs): + def post(self, request): serializer: EmailSerializer = self.serializer_class(data=request.data) if serializer.is_valid(): @@ -374,14 +376,18 @@ def post(self, request, *args, **kwargs): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) -class RegisterSHA512ForAccount(APIView): - class InputSerializer(serializers.Serializer): - sha512_token = serializers.CharField(max_length=128) +class AuthRequestView(APIView): + """ + Given a connection challenge and fork compatibility, registers a connection challenge + for the authenticated user and returns a signed scope token. - def post(self, request, *args, **kwargs): + **Requires Token authentication** + """ + + serializerClass = AuthRequestSerializer + + def post(self, request): user: Account = request.user - if not request.auth: - return ErrorResponse("Invalid or missing token.", status.HTTP_401_UNAUTHORIZED) if not user.is_confirmed: return ErrorResponse( @@ -389,66 +395,69 @@ def post(self, request, *args, **kwargs): status.HTTP_403_FORBIDDEN, ) - serializer = self.InputSerializer(data=request.data) + serializer: AuthRequestSerializer = self.serializerClass(data=request.data) if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - SHA512Token.objects.create(account=user, token=serializer.validated_data["sha512_token"]) + if ConnectionChallenge.objects.filter(connection_challenge=serializer.validated_data["connection_challenge"]).count() > 0: + return ErrorResponse( + "Connection challenge reuse is prohibited.", + status.HTTP_400_BAD_REQUEST, + ) + + ConnectionChallenge.objects.create( + account=user, + connection_challenge=serializer.validated_data["connection_challenge"], + ) + + scope_token_data = { + "unique_identifier": user.unique_identifier, + "fork_compatibility": serializer.validated_data["fork_compatibility"], + } + + signer = signing.TimestampSigner() + scope_token = signer.sign_object(scope_token_data) # Signs + serializes with timestamp return Response( - {"detail": "SHA512 token registered successfully."}, + {"scopeToken": scope_token}, status=status.HTTP_200_OK, ) -class CheckSHA512ForAccountView(APIView): +class RedeemSessionView(APIView): """ - Given an account unique_identifier and a SHA512 token, - checks if the token is associated with that account. + Given an account unique_identifier and a connection challenge, + checks if the token is associated with that account, returning it if so. Deletes the token after checking. + **Public endpoint** """ permission_classes = (AllowAny,) + serializerClass = ConnectionChallengeSerializer - class InputSerializer(serializers.Serializer): - unique_identifier = serializers.CharField(max_length=28) - sha512_token = serializers.CharField(max_length=128) - - def post(self, request, *args, **kwargs): - serializer = self.InputSerializer(data=request.data) + def post(self, request): + serializer: ConnectionChallengeSerializer = self.serializerClass(data=request.data) if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - unique_id = serializer.validated_data["unique_identifier"] - token = serializer.validated_data["sha512_token"] + connection_challenge = serializer.validated_data["connection_challenge"] - try: - account = Account.objects.get(unique_identifier=unique_id) - except Account.DoesNotExist: - return Response({"exists": False}, status=status.HTTP_200_OK) + challenge_object = ConnectionChallenge.objects.filter(connection_challenge=connection_challenge).first() + + if challenge_object: + valid_cutoff = timezone.now() - timedelta(minutes=3) + + if challenge_object.created_at < valid_cutoff: + # Token is older than 3 minutes, delete it + challenge_object.delete() + return ErrorResponse("Token is expired.", status.HTTP_401_UNAUTHORIZED) - valid_cutoff = timezone.now() - timedelta(minutes=3) - matching_token = SHA512Token.objects.filter( - account=account, - token=token, - created_at__gte=valid_cutoff, - ).first() + challenge_object.delete() - if matching_token: - matching_token.delete() return Response( - {"exists": True, "account": PublicAccountDataSerializer(account, context={"request": request}).data}, + {"account": PublicAccountDataSerializer(challenge_object.account, context={"request": request}).data}, status=status.HTTP_200_OK, ) else: - return Response({"exists": False}, status=status.HTTP_200_OK) - - -class Command(BaseCommand): - help = "Delete expired SHA512 tokens (older than 3 minutes)" - - def handle(self, *args, **kwargs): - cutoff = timezone.now() - timedelta(minutes=3) - deleted, _ = SHA512Token.objects.filter(created_at__lt=cutoff).delete() - self.stdout.write(f"Deleted {deleted} expired SHA512 tokens.") + return Response("Token is invalid.", status=status.HTTP_401_UNAUTHORIZED) diff --git a/src/accounts/management/__init__.py b/src/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/management/commands/__init__.py b/src/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/management/commands/clear_expired_challenges.py b/src/accounts/management/commands/clear_expired_challenges.py new file mode 100644 index 0000000..858c6d6 --- /dev/null +++ b/src/accounts/management/commands/clear_expired_challenges.py @@ -0,0 +1,18 @@ +import logging + +from datetime import timedelta +from django.utils import timezone + +from django.core.management.base import BaseCommand + +from accounts.models import ConnectionChallenge + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = "Delete expired connection challenges (older than 3 minutes)" + + def handle(self, *args, **kwargs): + cutoff = timezone.now() - timedelta(minutes=3) + deleted, _ = ConnectionChallenge.objects.filter(created_at__lt=cutoff).delete() + logger.info(f"Deleted {deleted} expired connection challenges.") \ No newline at end of file diff --git a/src/accounts/migrations/0005_connectionchallenge.py b/src/accounts/migrations/0005_connectionchallenge.py new file mode 100644 index 0000000..447880a --- /dev/null +++ b/src/accounts/migrations/0005_connectionchallenge.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-08-11 02:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_alter_account_username'), + ] + + operations = [ + migrations.CreateModel( + name='ConnectionChallenge', + fields=[ + ('connection_challenge', models.CharField(help_text="A random challenge string that is independently calculated by both the client and server.\n Traditionally, this is calculated as SHA-512 of a the Server's public key + a random shared secret generated by the client.", max_length=128, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connection_challenge', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/accounts/migrations/0005_sha512token.py b/src/accounts/migrations/0005_sha512token.py deleted file mode 100644 index f2cc5f1..0000000 --- a/src/accounts/migrations/0005_sha512token.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.2.25 on 2025-08-03 16:41 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_alter_account_username'), - ] - - operations = [ - migrations.CreateModel( - name='SHA512Token', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=128)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sha512_tokens', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/accounts/models.py b/src/accounts/models.py index 1db210b..e550c16 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -134,13 +134,18 @@ def is_token_valid(self): return (self.created_at + timedelta(minutes=settings.PASS_RESET_TOKEN_TTL)) > timezone.now() -class SHA512Token(models.Model): - token = models.CharField(max_length=128) - account = models.ForeignKey(Account, related_name="sha512_tokens", on_delete=models.CASCADE) +class ConnectionChallenge(models.Model): + connection_challenge = models.CharField( + primary_key=True, + max_length=128, + help_text="""A random challenge string that is independently calculated by both the client and server. + Traditionally, this is calculated as SHA-512 of the Server's public key + a random shared secret generated by the client.""" + ) + account = models.ForeignKey(Account, related_name="connection_challenge", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"SHA512 token for {self.account} created at {self.created_at}" + return f"Connection challenge for {self.account} created at {self.created_at}" def is_valid(self): return (self.created_at + timedelta(minutes=3)) > timezone.now() diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 8fe78b7..08dce88 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -258,3 +258,6 @@ ACCOUNT_CONFIRMATION_URL_SUFFIX = os.environ["ACCOUNT_CONFIRMATION_URL_SUFFIX"] ACCOUNT_CONFIRMATION_URL = urljoin(WEBSITE_URL, ACCOUNT_CONFIRMATION_URL_SUFFIX) ACCOUNT_CONFIRMATION_TOKEN_TTL = 24 # hours + +# Scope token settings +SCOPE_TOKEN_TTL = 60 * 60 * 24 * 1 # 1 day in seconds \ No newline at end of file diff --git a/src/persistence/api/permissions.py b/src/persistence/api/permissions.py new file mode 100644 index 0000000..ce0c07e --- /dev/null +++ b/src/persistence/api/permissions.py @@ -0,0 +1,32 @@ +from django.core import signing +from rest_framework.permissions import IsAuthenticated + +from accounts.models import Account +from central_command.settings import SCOPE_TOKEN_TTL + +class TokenOrAccount(IsAuthenticated): + """ + Permission class that checks if the request is using a Character Token, falling back to standard account authentication if not present or invalid. + """ + + def has_permission(self, request, view): + auth_header = request.headers.get("Authorization") + + if not auth_header: + return False + + try: + signer = signing.TimestampSigner() + parsed = signer.unsign_object(auth_header, max_age=SCOPE_TOKEN_TTL) # 1 day in seconds + + request.fork_compatibility = parsed.get("fork_compatibility", None) + + if isinstance(parsed, dict) and "unique_identifier" in parsed: + # If the token is valid, we can assume the user is authenticated + request.user = Account.objects.get(unique_identifier=parsed["unique_identifier"]) + return True + except (signing.SignatureExpired, signing.BadSignature): + pass # ignore it to fall back + + # Fallback to standard account authentication + return super().has_permission(request, view) \ No newline at end of file diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index a2c3692..b492d4a 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -2,16 +2,11 @@ from .views import ( CreateCharacterView, - CreateCharacterViewToken, DeleteCharacterView, - DeleteCharacterViewToken, - GenerateForkTokenView, GetAllCharactersByAccountView, GetCharacterByIdView, GetCompatibleCharacters, - GetCompatibleCharactersToken, UpdateCharacterView, - UpdateCharacterViewToken, ) app_name = "persistence" @@ -23,17 +18,4 @@ path("characters/compatible", GetCompatibleCharacters.as_view(), name="characters-compatible"), path("characters//update", UpdateCharacterView.as_view(), name="characters-patch"), path("characters//delete", DeleteCharacterView.as_view(), name="characters-delete"), - path( - "characters//updateToken", UpdateCharacterViewToken.as_view(), name="characters-patch-token" - ), # PutAccountsCharacterByIDByCharactersToken - path( - "characters/createToken", CreateCharacterViewToken.as_view(), name="characters-create-token" - ), # PostMakeAccountsCharacterByCharactersToken - path( - "characters/compatibleToken", GetCompatibleCharactersToken.as_view(), name="characters-compatible-token" - ), # GetCharactersByCharacterSheetToken - path( - "characters//deleteToken", DeleteCharacterViewToken.as_view(), name="characters-delete-token" - ), # DeleteAccountsCharacterByIDByCharactersToken - path("characters/GenForkToken", GenerateForkTokenView.as_view(), name="Gen-Fork-token"), ] diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 9cdfa68..cb5440f 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -1,15 +1,10 @@ -import secrets -import uuid - -from django.core import signing from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.generics import GenericAPIView, ListAPIView -from rest_framework.permissions import AllowAny from rest_framework.response import Response -from accounts.models import Account +from persistence.api.permissions import TokenOrAccount from ..models import Character from .serializers import ( @@ -23,17 +18,26 @@ class GetCharacterByIdView(GenericAPIView): """ Retrieves a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def get_queryset(self): return Character.objects.filter(account__unique_identifier=self.request.user.unique_identifier) # type: ignore def get(self, request, pk): try: - character = Character.objects.get(pk=pk) + # should compatibility be checked here? + query = { + "pk": pk, + } + + if hasattr(request, "fork_compatibility"): + query["fork_compatibility"] = request.fork_compatibility + + character = Character.objects.get(**query) except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} return Response(data, status=status.HTTP_404_NOT_FOUND) @@ -48,10 +52,12 @@ class GetCompatibleCharacters(ListAPIView): """ Retrieves a list of compatible characters for the user's account. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + # ScopeToken auth will provide a fork_compatibility of its own, but i see no reason to use it over the query string. + permission_classes = (TokenOrAccount,) def get_queryset(self): """ @@ -62,7 +68,7 @@ def get_queryset(self): - character_sheet_version: The version string of the character sheet. Example usage: - /api/characters/fork_compatibility=Unitystation&character_sheet_version=1.0.0 + /api/characters/?fork_compatibility=Unitystation&character_sheet_version=1.0.0 """ query_serializer = CompatibleCharactersRequestSerializer(data=self.request.query_params) if not query_serializer.is_valid(): @@ -84,10 +90,11 @@ class GetAllCharactersByAccountView(ListAPIView): """ Retrieves a list of all characters of an account, disregarding compatibility. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def get_queryset(self): """ @@ -102,10 +109,11 @@ class UpdateCharacterView(GenericAPIView): """ Updates a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = UpdateCharacterSerializer + permission_classes = (TokenOrAccount,) queryset = Character.objects.all() def update_character(self, request, pk): @@ -113,6 +121,8 @@ def update_character(self, request, pk): character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied + if hasattr(request, "fork_compatibility") and character.fork_compatibility != request.fork_compatibility: + raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} @@ -139,16 +149,19 @@ class DeleteCharacterView(GenericAPIView): """ Deletes a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def delete(self, request, pk): try: character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied + if hasattr(request, "fork_compatibility") and character.fork_compatibility != request.fork_compatibility: + raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} @@ -166,14 +179,17 @@ class CreateCharacterView(GenericAPIView): """ Creates a new character. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def post(self, request): data_with_account = request.data.copy() data_with_account["account"] = request.user.pk + if hasattr(request, "fork_compatibility"): + data_with_account["fork_compatibility"] = request.fork_compatibility # Enforce fork from token serializer = self.serializer_class(data=data_with_account) serializer.account = request.user # type: ignore @@ -186,296 +202,4 @@ def post(self, request): data = {"error": "You do not have permission to write this data!"} return Response(data, status=status.HTTP_403_FORBIDDEN) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class GenerateForkTokenView(GenericAPIView): - """ - Generates a token for the fork/server and account identifier. - **Requires token in 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - - def post(self, request): - server_id = request.data.get("fork_compatibility") - if not server_id: - return Response( - {"error": "Missing 'fork_compatibility' in request body."}, status=status.HTTP_400_BAD_REQUEST - ) - - user = request.user - if not hasattr(user, "unique_identifier"): - return Response( - {"error": "Authenticated user lacks a unique identifier."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - token_data = { - "server_id": server_id, - "uuid": str(user.unique_identifier), - "nonce": secrets.token_hex(8), - } - - # Token expires in 1 day - signer = signing.TimestampSigner() - token = signer.sign_object(token_data) # Signs + serializes with timestamp - - return Response({"token": token}) - - -class CreateCharacterViewToken(GenericAPIView): - """ - Creates a new character based on the token which embeds - the fork/server and account identifier. - - **Requires token in 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def generate_token(self, server_id: str) -> str: - data = {"server_id": server_id, "nonce": secrets.token_hex(8), "uuid": str(uuid.uuid4())} - return signing.dumps(data) - - def parse_token(self, token: str) -> dict: - return signing.loads(token) - - def post(self, request): - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Look up account by UUID - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - data_with_extras = request.data.copy() - data_with_extras["account"] = account.pk - data_with_extras["fork_compatibility"] = server_id # Enforce fork from token - - serializer = self.serializer_class(data=data_with_extras) - serializer.account = account # type: ignore - - try: - serializer.is_valid(raise_exception=True) - except ValidationError as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - except PermissionDenied: - return Response( - {"error": "You do not have permission to write this data!"}, status=status.HTTP_403_FORBIDDEN - ) - - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class DeleteCharacterViewToken(GenericAPIView): - """ - Deletes a character by its ID. The character must: - - Belong to the account identified by the token's UUID. - - Match the fork/server ID in the token. - - **Requires 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def delete(self, request, pk): - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Look up account - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - # Attempt to get character - try: - character = Character.objects.get(pk=pk) - except Character.DoesNotExist: - return Response({"error": "No character with this ID could be found!"}, status=status.HTTP_404_NOT_FOUND) - - # Check ownership and fork - if character.account != account: - return Response( - {"error": "You do not have permission to delete this character!"}, status=status.HTTP_403_FORBIDDEN - ) - - if character.fork_compatibility != server_id: - return Response( - {"error": "This character does not match the server/fork in the token!"}, - status=status.HTTP_403_FORBIDDEN, - ) - - character.delete() - return Response({"success": "Character deleted successfully!"}, status=status.HTTP_200_OK) - - -class GetCompatibleCharactersToken(ListAPIView): - """ - Retrieves a list of compatible characters based on the token-provided fork and account. - **Requires 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def get_queryset(self): - token = self.request.headers.get("X-Character-Token") - if not token: - raise ValidationError({"token": ["Missing X-Character-Token header"]}) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - if not server_id or not account_uuid: - raise ValidationError({"token": ["Token missing required fields"]}) - - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - raise ValidationError({"account": ["Account not found"]}) - - # Add fork_compatibility from the token and character_sheet_version from query - query_data = { - "character_sheet_version": self.request.query_params.get("character_sheet_version"), - "fork_compatibility": server_id, - } - query_serializer = CompatibleCharactersRequestSerializer(data=query_data) - query_serializer.is_valid(raise_exception=True) - - character_sheet_version = query_serializer.validated_data["character_sheet_version"] - - return Character.objects.filter( - account=account, - fork_compatibility=server_id, - character_sheet_version=character_sheet_version, - ) - - -class UpdateCharacterViewToken(GenericAPIView): - """ - Updates a character by its ID using token-based authentication. - If it does not exist, creates it. - **Requires 'X-Character-Token' header.** - """ - - serializer_class = UpdateCharacterSerializer - queryset = Character.objects.all() - permission_classes = (AllowAny,) - - def update_or_create_character(self, request, pk): - # Get token - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Get account - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - # Try to get character, otherwise create a new one - try: - character = Character.objects.get(pk=pk) - is_new = False - except Character.DoesNotExist: - character = None - is_new = True - - # If updating, check ownership and fork compatibility - if not is_new and character is not None: - if character.account != account: - return Response( - {"error": "You do not have permission to edit this character!"}, status=status.HTTP_403_FORBIDDEN - ) - if character.fork_compatibility != server_id: - return Response( - {"error": "This character does not match the server/fork in the token!"}, - status=status.HTTP_403_FORBIDDEN, - ) - - # Force account and fork_compatibility from token (both on create and update) - incoming_data = request.data.copy() - incoming_data["account"] = account.pk - incoming_data["fork_compatibility"] = server_id - - if is_new: - serializer = self.get_serializer(data=incoming_data) - else: - serializer = self.get_serializer(character, data=incoming_data, partial=True) - - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED if is_new else status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, pk): - return self.update_or_create_character(request, pk) - - def put(self, request, pk): - return self.update_or_create_character(request, pk) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file