From fbcb6506d0c663e2a5516bf8ea6b95f220982b41 Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sat, 2 Aug 2025 20:10:32 +0100 Subject: [PATCH 1/9] more stuff --- Dockerfile | 2 +- src/accounts/api/urls.py | 4 +- src/accounts/api/views.py | 95 ++++++++++++++++++++++++++++++++++++++- src/accounts/models.py | 14 +++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index c8daa3e..c28f3ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,4 +39,4 @@ RUN : \ RUN crontab -l | { cat; echo "* * * * * python /src/manage.py send_queued_mail >> /home/website/logs/send_mail.log 2>&1"; } | crontab - -ENTRYPOINT ["./entrypoint.sh"] +CMD ["./entrypoint.sh"] diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index 55eb449..0f5b63e 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -11,7 +11,7 @@ ResendAccountConfirmationView, ResetPasswordView, UpdateAccountView, - VerifyAccountView, + VerifyAccountView, CheckSHA512ForAccountView, RegisterSHA512ForAccount, ) app_name = "account" @@ -45,4 +45,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"), ] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 21aedc7..8c66922 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -3,7 +3,7 @@ from urllib.parse import urljoin from uuid import uuid4 - +from knox.auth import TokenAuthentication from django.conf import settings from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist, PermissionDenied @@ -16,11 +16,12 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.views import APIView +from rest_framework import serializers, status from commons.error_response import ErrorResponse from commons.mail_wrapper import send_email_with_template -from ..models import Account, AccountConfirmation, PasswordResetRequestModel +from ..models import Account, AccountConfirmation, PasswordResetRequestModel, SHA512Token from .serializers import ( ConfirmAccountSerializer, EmailSerializer, @@ -369,3 +370,93 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK) else: return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) + + + + +class RegisterSHA512ForAccount(APIView): + """ + Authenticates via Knox Token (like LoginWithTokenView) and registers a SHA512 token. + **Public endpoint, but token is required via header.** + """ + authentication_classes = [TokenAuthentication] + permission_classes = [AllowAny] # Anyone can hit it, but auth is required + + class InputSerializer(serializers.Serializer): + sha512_token = serializers.CharField(max_length=128) + + def post(self, request, *args, **kwargs): + 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( + "You must confirm your email before performing this action.", + status.HTTP_403_FORBIDDEN, + ) + + serializer = self.InputSerializer(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"] + ) + + return Response( + {"detail": "SHA512 token registered successfully."}, + status=status.HTTP_200_OK, + ) + +class CheckSHA512ForAccountView(APIView): + """ + Given an account unique_identifier and a SHA512 token, + checks if the token is associated with that account. + **Public endpoint** + """ + permission_classes = (AllowAny,) + + 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) + 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"] + + try: + account = Account.objects.get(unique_identifier=unique_id) + except Account.DoesNotExist: + # If account doesn't exist, token obviously not associated + return Response({"exists": False}, status=status.HTTP_200_OK) + + # Check if the token exists and is still valid (created within 3 minutes) + from django.utils import timezone + from datetime import timedelta + + valid_cutoff = timezone.now() - timedelta(minutes=3) + + exists = SHA512Token.objects.filter( + account=account, + token=token, + created_at__gte=valid_cutoff, + ).exists() + + return Response({"exists": exists}, 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.") \ No newline at end of file diff --git a/src/accounts/models.py b/src/accounts/models.py index 3d7b3b2..2329e90 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -13,7 +13,8 @@ from commons.mail_wrapper import send_email_with_template from .validators import AccountNameValidator - +from django.utils import timezone +from datetime import timedelta class Account(AbstractUser): email = models.EmailField( @@ -133,3 +134,14 @@ def is_token_valid(self): if self.created_at is None: return False 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) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"SHA512 token for {self.account} created at {self.created_at}" + + def is_valid(self): + return (self.created_at + timedelta(minutes=3)) > timezone.now() \ No newline at end of file From f87926ccfaec8941daf74691e5e387b41f39176b Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Tue, 5 Aug 2025 22:58:17 +0100 Subject: [PATCH 2/9] new flow --- api-collection/Auth/CreateAccount/Valid.bru | 4 +- .../Auth/LoginWithCreds/success.bru | 6 +- api-collection/Auth/SHAChecks/check token.bru | 18 ++ api-collection/Auth/SHAChecks/folder.bru | 3 + .../Auth/SHAChecks/register token.bru | 21 ++ api-collection/admin/folder.bru | 3 + src/accounts/api/views.py | 54 ++-- src/accounts/migrations/0005_sha512token.py | 24 ++ src/persistence/api/urls.py | 17 ++ src/persistence/api/views.py | 268 +++++++++++++++++- 10 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 api-collection/Auth/SHAChecks/check token.bru create mode 100644 api-collection/Auth/SHAChecks/folder.bru create mode 100644 api-collection/Auth/SHAChecks/register token.bru create mode 100644 api-collection/admin/folder.bru create mode 100644 src/accounts/migrations/0005_sha512token.py diff --git a/api-collection/Auth/CreateAccount/Valid.bru b/api-collection/Auth/CreateAccount/Valid.bru index fb0b4fa..a9a5e87 100644 --- a/api-collection/Auth/CreateAccount/Valid.bru +++ b/api-collection/Auth/CreateAccount/Valid.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{baseUrl}}/accounts/register + url: http://localhost:8000/accounts/register body: json auth: none } @@ -13,7 +13,7 @@ post { body:json { { "unique_identifier": "myname", - "username": "My Name", + "username": "Name", "password": "qweasd123", "email": "myname@email.com" } diff --git a/api-collection/Auth/LoginWithCreds/success.bru b/api-collection/Auth/LoginWithCreds/success.bru index 5c52b1a..1148a94 100644 --- a/api-collection/Auth/LoginWithCreds/success.bru +++ b/api-collection/Auth/LoginWithCreds/success.bru @@ -5,14 +5,14 @@ meta { } post { - url: {{baseUrl}}/accounts/login-credentials + url: http://127.0.0.1:8000/accounts/login-credentials body: json auth: none } body:json { { - "email": "admin@admin.com", - "password": "admin" + "email": "bob@bob.bob", + "password": "bob" } } diff --git a/api-collection/Auth/SHAChecks/check token.bru b/api-collection/Auth/SHAChecks/check token.bru new file mode 100644 index 0000000..b97f56a --- /dev/null +++ b/api-collection/Auth/SHAChecks/check token.bru @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..a531e7b --- /dev/null +++ b/api-collection/Auth/SHAChecks/folder.bru @@ -0,0 +1,3 @@ +meta { + name: SHAChecks +} diff --git a/api-collection/Auth/SHAChecks/register token.bru b/api-collection/Auth/SHAChecks/register token.bru new file mode 100644 index 0000000..fe9aeca --- /dev/null +++ b/api-collection/Auth/SHAChecks/register token.bru @@ -0,0 +1,21 @@ +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/admin/folder.bru b/api-collection/admin/folder.bru new file mode 100644 index 0000000..7693800 --- /dev/null +++ b/api-collection/admin/folder.bru @@ -0,0 +1,3 @@ +meta { + name: admin +} diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 8c66922..9fea249 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -33,6 +33,9 @@ VerifyAccountSerializer, ) +from django.utils import timezone +from datetime import timedelta + logger = logging.getLogger(__name__) @@ -372,22 +375,39 @@ def post(self, request, *args, **kwargs): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) +#class CreateCharacterView(GenericAPIView): +# """ +# Creates a new character.# +# +# **Requires Token Authentication.** +# """# +# +# def post(self, request): +# data_with_account = request.data.copy() +# data_with_account["account"] = request.user.pk# +# +# serializer = self.serializer_class(data=data_with_account) +# serializer.account = request.user # type: ignore +# try: +# serializer.is_valid(raise_exception=True) +# except ValidationError as e: +# data = {"error": str(e)} +# return Response(data, status=status.HTTP_400_BAD_REQUEST) +# except PermissionDenied: +# 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 RegisterSHA512ForAccount(APIView): - """ - Authenticates via Knox Token (like LoginWithTokenView) and registers a SHA512 token. - **Public endpoint, but token is required via header.** - """ - authentication_classes = [TokenAuthentication] - permission_classes = [AllowAny] # Anyone can hit it, but auth is required class InputSerializer(serializers.Serializer): sha512_token = serializers.CharField(max_length=128) def post(self, request, *args, **kwargs): user: Account = request.user - + data = self.request.user.pk if not request.auth: return ErrorResponse("Invalid or missing token.", status.HTTP_401_UNAUTHORIZED) @@ -411,10 +431,12 @@ def post(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) + class CheckSHA512ForAccountView(APIView): """ Given an account unique_identifier and a SHA512 token, checks if the token is associated with that account. + Deletes the token after checking. **Public endpoint** """ permission_classes = (AllowAny,) @@ -434,25 +456,23 @@ def post(self, request, *args, **kwargs): try: account = Account.objects.get(unique_identifier=unique_id) except Account.DoesNotExist: - # If account doesn't exist, token obviously not associated return Response({"exists": False}, status=status.HTTP_200_OK) - # Check if the token exists and is still valid (created within 3 minutes) - from django.utils import timezone - from datetime import timedelta - valid_cutoff = timezone.now() - timedelta(minutes=3) - exists = SHA512Token.objects.filter( + matching_token = SHA512Token.objects.filter( account=account, token=token, created_at__gte=valid_cutoff, - ).exists() + ).first() + + if matching_token: + matching_token.delete() + return Response({"exists": True}, status=status.HTTP_200_OK) + else: + return Response({"exists": False}, status=status.HTTP_200_OK) - return Response({"exists": exists}, status=status.HTTP_200_OK) -# -# #class Command(BaseCommand): # help = 'Delete expired SHA512 tokens (older than 3 minutes)' # diff --git a/src/accounts/migrations/0005_sha512token.py b/src/accounts/migrations/0005_sha512token.py new file mode 100644 index 0000000..f2cc5f1 --- /dev/null +++ b/src/accounts/migrations/0005_sha512token.py @@ -0,0 +1,24 @@ +# 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/persistence/api/urls.py b/src/persistence/api/urls.py index b492d4a..3d6bddf 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -7,6 +7,11 @@ GetCharacterByIdView, GetCompatibleCharacters, UpdateCharacterView, + GenerateForkTokenView, + CreateCharacterViewToken, + DeleteCharacterViewToken, + GetCompatibleCharactersToken, + UpdateCharacterViewToken, ) app_name = "persistence" @@ -18,4 +23,16 @@ 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 2093fed..fb57906 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -1,9 +1,15 @@ +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.exceptions import ValidationError, PermissionDenied from rest_framework.response import Response +from rest_framework import status +from rest_framework.generics import GenericAPIView +from accounts.models import Account from ..models import Character from .serializers import ( CharacterSerializer, @@ -128,6 +134,8 @@ def put(self, request, pk): return self.update_character(request, pk) + + class DeleteCharacterView(GenericAPIView): """ Deletes a character by its ID. The character must belong to the account of the user. @@ -180,3 +188,259 @@ def post(self, request): return Response(data, status=status.HTTP_403_FORBIDDEN) serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class GenerateForkTokenView(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 + + 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 + + # You can adjust this if your field is different + 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 = signing.dumps(token_data) + + 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 + + def generate_token(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(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: + parsed = signing.loads(token) + except signing.BadSignature: + return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + + 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 + + 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: + parsed = signing.loads(token) + except signing.BadSignature: + return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + + 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 + + def get_queryset(self): + token = self.request.headers.get("X-Character-Token") + if not token: + raise ValidationError({"token": "Missing X-Character-Token header"}) + + try: + parsed = signing.loads(token) + except signing.BadSignature: + raise ValidationError({"token": "Invalid or tampered token"}) + + 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"}) + + # Only expect character_sheet_version from the query + query_data = { + "character_sheet_version": self.request.query_params.get("character_sheet_version") + } + + query_serializer = CompatibleCharactersRequestSerializer(data=query_data) + if not query_serializer.is_valid(): + raise ValidationError(query_serializer.errors) + + character_sheet_version = query_serializer.validated_data["character_sheet_version"] + + queryset = Character.objects.filter( + account=account, + fork_compatibility=server_id, + character_sheet_version=character_sheet_version, + ) + + return queryset + +class UpdateCharacterViewToken(GenericAPIView): + """ + Updates a character by its ID using token-based authentication. + + **Requires 'X-Character-Token' header.** + """ + + serializer_class = UpdateCharacterSerializer + queryset = Character.objects.all() + + def update_character(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: + parsed = signing.loads(token) + except signing.BadSignature: + return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + + 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) + + # Look up 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 if the character belongs to the token-provided account + if character.account != account: + return Response({"error": "You do not have permission to edit this character!"}, + status=status.HTTP_403_FORBIDDEN) + + # Check fork match + 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) + + # Proceed with update + serializer = self.get_serializer(character, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, pk): + return self.update_character(request, pk) + + def put(self, request, pk): + return self.update_character(request, pk) \ No newline at end of file From 2acf6c8855494e06d3b41c3885bafca0ff322b03 Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 12:40:03 +0100 Subject: [PATCH 3/9] Update --- Dockerfile | 2 +- .../Characters/Get charracter token.bru | 21 +++ api-collection/Characters/GetAll.bru | 4 +- .../Characters/GetCompatible Token.bru | 19 +++ src/accounts/api/views.py | 37 +++-- src/persistence/api/views.py | 142 ++++++++++-------- 6 files changed, 148 insertions(+), 77 deletions(-) create mode 100644 api-collection/Characters/Get charracter token.bru create mode 100644 api-collection/Characters/GetCompatible Token.bru diff --git a/Dockerfile b/Dockerfile index c28f3ac..c8daa3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,4 +39,4 @@ RUN : \ RUN crontab -l | { cat; echo "* * * * * python /src/manage.py send_queued_mail >> /home/website/logs/send_mail.log 2>&1"; } | crontab - -CMD ["./entrypoint.sh"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/api-collection/Characters/Get charracter token.bru b/api-collection/Characters/Get charracter token.bru new file mode 100644 index 0000000..f8f9335 --- /dev/null +++ b/api-collection/Characters/Get charracter token.bru @@ -0,0 +1,21 @@ +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 index d0a8af7..cebbd46 100644 --- a/api-collection/Characters/GetAll.bru +++ b/api-collection/Characters/GetAll.bru @@ -5,11 +5,11 @@ meta { } get { - url: {{baseUrl}}/persistence/characters + url: http://127.0.0.1:8000/persistence/characters body: none auth: none } headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac + Authorization: Token a74030290fa0dbc6d85f2e8dd885bbb76d76d9dedfe7c82bc60d72e8bd09210c } diff --git a/api-collection/Characters/GetCompatible Token.bru b/api-collection/Characters/GetCompatible Token.bru new file mode 100644 index 0000000..93a529c --- /dev/null +++ b/api-collection/Characters/GetCompatible Token.bru @@ -0,0 +1,19 @@ +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/src/accounts/api/views.py b/src/accounts/api/views.py index 9fea249..ca463eb 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -1,22 +1,25 @@ import logging import secrets +from datetime import timedelta from urllib.parse import urljoin from uuid import uuid4 -from knox.auth import TokenAuthentication + from django.conf import settings 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.auth import TokenAuthentication from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView -from rest_framework import status +from rest_framework import serializers, status from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.views import APIView -from rest_framework import serializers, status from commons.error_response import ErrorResponse from commons.mail_wrapper import send_email_with_template @@ -33,9 +36,6 @@ VerifyAccountSerializer, ) -from django.utils import timezone -from datetime import timedelta - logger = logging.getLogger(__name__) @@ -459,7 +459,6 @@ def post(self, request, *args, **kwargs): return Response({"exists": False}, status=status.HTTP_200_OK) valid_cutoff = timezone.now() - timedelta(minutes=3) - matching_token = SHA512Token.objects.filter( account=account, token=token, @@ -468,15 +467,23 @@ def post(self, request, *args, **kwargs): if matching_token: matching_token.delete() - return Response({"exists": True}, status=status.HTTP_200_OK) + return Response( + { + "exists": True, + "account": PublicAccountDataSerializer( + 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)' -#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.") \ No newline at end of file + 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.") \ No newline at end of file diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index fb57906..75d5bdb 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.exceptions import ValidationError, PermissionDenied +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework import status from rest_framework.generics import GenericAPIView @@ -192,27 +193,25 @@ def post(self, request): class GenerateForkTokenView(GenericAPIView): """ - Creates a new character based on the token which embeds - the fork/server and account identifier. - + 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) + return Response( + {"error": "Missing 'fork_compatibility' in request body."}, + status=status.HTTP_400_BAD_REQUEST + ) user = request.user - - # You can adjust this if your field is different if not hasattr(user, "unique_identifier"): - return Response({"error": "Authenticated user lacks a unique identifier."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"error": "Authenticated user lacks a unique identifier."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) token_data = { "server_id": server_id, @@ -220,7 +219,9 @@ def post(self, request): "nonce": secrets.token_hex(8), } - token = signing.dumps(token_data) + # Token expires in 1 day + signer = signing.TimestampSigner() + token = signer.sign_object(token_data) # Signs + serializes with timestamp return Response({"token": token}) @@ -235,7 +236,7 @@ class CreateCharacterViewToken(GenericAPIView): """ serializer_class = CharacterSerializer - + permission_classes = (AllowAny,) def generate_token(server_id: str) -> str: data = {"server_id": server_id, "nonce": secrets.token_hex(8), "uuid": str(uuid.uuid4())} return signing.dumps(data) @@ -249,9 +250,14 @@ def post(self, request): return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) try: - parsed = signing.loads(token) + 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: - return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + # Token invalid + return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) server_id = parsed.get("server_id") account_uuid = parsed.get("uuid") @@ -292,16 +298,22 @@ class DeleteCharacterViewToken(GenericAPIView): """ 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: - parsed = signing.loads(token) + 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: - return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + # Token invalid + return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) + server_id = parsed.get("server_id") account_uuid = parsed.get("uuid") @@ -334,113 +346,125 @@ def delete(self, request, pk): 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"}) + raise ValidationError({"token": ["Missing X-Character-Token header"]}) try: - parsed = signing.loads(token) + 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: - raise ValidationError({"token": "Invalid or tampered token"}) + # 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"}) + 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"}) + raise ValidationError({"account": ["Account not found"]}) - # Only expect character_sheet_version from the query + # 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") + "character_sheet_version": self.request.query_params.get("character_sheet_version"), + "fork_compatibility": server_id } - query_serializer = CompatibleCharactersRequestSerializer(data=query_data) - if not query_serializer.is_valid(): - raise ValidationError(query_serializer.errors) + query_serializer.is_valid(raise_exception=True) character_sheet_version = query_serializer.validated_data["character_sheet_version"] - queryset = Character.objects.filter( + return Character.objects.filter( account=account, fork_compatibility=server_id, character_sheet_version=character_sheet_version, ) - - return queryset - 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_character(self, request, pk): + 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: - parsed = signing.loads(token) + 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: - return Response({"error": "Invalid or tampered token"}, status=status.HTTP_400_BAD_REQUEST) + # 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 + # 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) - # Look up character + # Try to get character, otherwise create a new one try: character = Character.objects.get(pk=pk) + is_new = False except Character.DoesNotExist: - return Response({"error": "No character with this ID could be found!"}, status=status.HTTP_404_NOT_FOUND) - - # Check if the character belongs to the token-provided account - if character.account != account: - return Response({"error": "You do not have permission to edit this character!"}, - status=status.HTTP_403_FORBIDDEN) - - # Check fork match - 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 = None + is_new = True + + # If updating, check ownership and fork compatibility + if not is_new: + 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) - # Proceed with update - serializer = self.get_serializer(character, data=request.data, partial=True) if serializer.is_valid(): serializer.save() - return Response(serializer.data) + 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_character(request, pk) + return self.update_or_create_character(request, pk) def put(self, request, pk): - return self.update_character(request, pk) \ No newline at end of file + return self.update_or_create_character(request, pk) \ No newline at end of file From 5f81d5f5c852c780b96cc0d15f54641f480edfd7 Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 16:23:16 +0100 Subject: [PATCH 4/9] Changes --- src/accounts/api/views.py | 4 +--- src/accounts/models.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index ca463eb..d5096f5 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -11,7 +11,6 @@ from django.core.management import BaseCommand from django.utils import timezone from drf_spectacular.utils import extend_schema -from knox.auth import TokenAuthentication from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView from rest_framework import serializers, status @@ -407,7 +406,6 @@ class InputSerializer(serializers.Serializer): def post(self, request, *args, **kwargs): user: Account = request.user - data = self.request.user.pk if not request.auth: return ErrorResponse("Invalid or missing token.", status.HTTP_401_UNAUTHORIZED) @@ -486,4 +484,4 @@ class Command(BaseCommand): 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.") \ No newline at end of file + self.stdout.write(f"Deleted {deleted} expired SHA512 tokens.") diff --git a/src/accounts/models.py b/src/accounts/models.py index 2329e90..7764091 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -144,4 +144,4 @@ def __str__(self): return f"SHA512 token for {self.account} created at {self.created_at}" def is_valid(self): - return (self.created_at + timedelta(minutes=3)) > timezone.now() \ No newline at end of file + return (self.created_at + timedelta(minutes=3)) > timezone.now() From 96cf76c1498beb9290c670fb01132242d78b7f6f Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 16:30:04 +0100 Subject: [PATCH 5/9] fixes --- src/persistence/api/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 75d5bdb..54feebe 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -3,14 +3,14 @@ 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.exceptions import ValidationError, PermissionDenied from rest_framework.permissions import AllowAny from rest_framework.response import Response -from rest_framework import status -from rest_framework.generics import GenericAPIView from accounts.models import Account + from ..models import Character from .serializers import ( CharacterSerializer, @@ -237,11 +237,11 @@ class CreateCharacterViewToken(GenericAPIView): serializer_class = CharacterSerializer permission_classes = (AllowAny,) - def generate_token(server_id: str) -> str: + 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(token: str) -> dict: + def parse_token(self,token: str) -> dict: return signing.loads(token) def post(self, request): From 3b216053943e75178a48057a885c4448927db91d Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 16:31:18 +0100 Subject: [PATCH 6/9] fox --- src/persistence/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 54feebe..fff1725 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -467,4 +467,4 @@ 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) \ No newline at end of file + return self.update_or_create_character(request, pk) From 89c449044bf74da0a835b9ac27f812dc30700fb4 Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 16:36:57 +0100 Subject: [PATCH 7/9] fix --- src/accounts/api/views.py | 26 -------------------------- src/persistence/api/views.py | 12 +++++++++--- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index d5096f5..632cdb5 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -373,32 +373,6 @@ def post(self, request, *args, **kwargs): else: return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - -#class CreateCharacterView(GenericAPIView): -# """ -# Creates a new character.# -# -# **Requires Token Authentication.** -# """# -# -# def post(self, request): -# data_with_account = request.data.copy() -# data_with_account["account"] = request.user.pk# -# -# serializer = self.serializer_class(data=data_with_account) -# serializer.account = request.user # type: ignore -# try: -# serializer.is_valid(raise_exception=True) -# except ValidationError as e: -# data = {"error": str(e)} -# return Response(data, status=status.HTTP_400_BAD_REQUEST) -# except PermissionDenied: -# 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 RegisterSHA512ForAccount(APIView): class InputSerializer(serializers.Serializer): diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index fff1725..b61faf8 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -440,12 +440,18 @@ def update_or_create_character(self, request, pk): character = None is_new = True + # If updating, check ownership and fork compatibility - if not is_new: + 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) + 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) + 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() From 4c0a8c15a9efc236a2238e601e2e7b1910a5ed2a Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 16:39:37 +0100 Subject: [PATCH 8/9] Dumdum --- src/accounts/api/urls.py | 4 +++- src/accounts/models.py | 3 +-- src/persistence/api/urls.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index 0f5b63e..0cfa98d 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -2,16 +2,18 @@ from knox import views as knox_views from .views import ( + CheckSHA512ForAccountView, ConfirmAccountView, LoginWithCredentialsView, LoginWithTokenView, RegisterAccountView, + RegisterSHA512ForAccount, RequestPasswordResetView, RequestVerificationTokenView, ResendAccountConfirmationView, ResetPasswordView, UpdateAccountView, - VerifyAccountView, CheckSHA512ForAccountView, RegisterSHA512ForAccount, + VerifyAccountView, ) app_name = "account" diff --git a/src/accounts/models.py b/src/accounts/models.py index 7764091..12b9449 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -13,8 +13,7 @@ from commons.mail_wrapper import send_email_with_template from .validators import AccountNameValidator -from django.utils import timezone -from datetime import timedelta + class Account(AbstractUser): email = models.EmailField( diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index 3d6bddf..9c26a77 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -2,15 +2,15 @@ from .views import ( CreateCharacterView, + CreateCharacterViewToken, DeleteCharacterView, + DeleteCharacterViewToken, + GenerateForkTokenView, GetAllCharactersByAccountView, GetCharacterByIdView, GetCompatibleCharacters, - UpdateCharacterView, - GenerateForkTokenView, - CreateCharacterViewToken, - DeleteCharacterViewToken, GetCompatibleCharactersToken, + UpdateCharacterView, UpdateCharacterViewToken, ) From b853163f2f3f1765190ec57e3e57adc7b0ae56df Mon Sep 17 00:00:00 2001 From: Bod9001 Date: Sun, 10 Aug 2025 17:02:29 +0100 Subject: [PATCH 9/9] ruff format --- src/accounts/api/views.py | 21 ++++++------------ src/accounts/models.py | 6 +++--- src/persistence/api/urls.py | 23 ++++++++++---------- src/persistence/api/views.py | 41 ++++++++++++++++++++---------------- src/persistence/models.py | 3 +-- 5 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 632cdb5..a59477c 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -373,8 +373,8 @@ def post(self, request, *args, **kwargs): else: return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) -class RegisterSHA512ForAccount(APIView): +class RegisterSHA512ForAccount(APIView): class InputSerializer(serializers.Serializer): sha512_token = serializers.CharField(max_length=128) @@ -393,10 +393,7 @@ def post(self, request, *args, **kwargs): 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"] - ) + SHA512Token.objects.create(account=user, token=serializer.validated_data["sha512_token"]) return Response( {"detail": "SHA512 token registered successfully."}, @@ -411,6 +408,7 @@ class CheckSHA512ForAccountView(APIView): Deletes the token after checking. **Public endpoint** """ + permission_classes = (AllowAny,) class InputSerializer(serializers.Serializer): @@ -440,20 +438,15 @@ def post(self, request, *args, **kwargs): if matching_token: matching_token.delete() return Response( - { - "exists": True, - "account": PublicAccountDataSerializer( - account, - context={"request": request} - ).data - }, - status=status.HTTP_200_OK + {"exists": True, "account": PublicAccountDataSerializer(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)' + help = "Delete expired SHA512 tokens (older than 3 minutes)" def handle(self, *args, **kwargs): cutoff = timezone.now() - timedelta(minutes=3) diff --git a/src/accounts/models.py b/src/accounts/models.py index 12b9449..1db210b 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -39,8 +39,7 @@ class Account(AbstractUser): unique=False, validators=[MinLengthValidator(3), UnicodeUsernameValidator()], help_text=( - "Public username is used to identify your account publicly and shows in " - "OOC. This can be changed at any time" + "Public username is used to identify your account publicly and shows in OOC. This can be changed at any time" ), ) @@ -134,9 +133,10 @@ def is_token_valid(self): return False 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) + account = models.ForeignKey(Account, related_name="sha512_tokens", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index 9c26a77..a2c3692 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -23,16 +23,17 @@ 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//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 b61faf8..9cdfa68 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -135,8 +135,6 @@ def put(self, request, pk): return self.update_character(request, pk) - - class DeleteCharacterView(GenericAPIView): """ Deletes a character by its ID. The character must belong to the account of the user. @@ -196,21 +194,20 @@ 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 + {"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 + {"error": "Authenticated user lacks a unique identifier."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) token_data = { @@ -226,7 +223,6 @@ def post(self, request): return Response({"token": token}) - class CreateCharacterViewToken(GenericAPIView): """ Creates a new character based on the token which embeds @@ -237,11 +233,12 @@ class CreateCharacterViewToken(GenericAPIView): 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: + def parse_token(self, token: str) -> dict: return signing.loads(token) def post(self, request): @@ -283,11 +280,14 @@ def post(self, request): 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) + 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: @@ -299,6 +299,7 @@ class DeleteCharacterViewToken(GenericAPIView): serializer_class = CharacterSerializer permission_classes = (AllowAny,) + def delete(self, request, pk): token = request.headers.get("X-Character-Token") if not token: @@ -314,7 +315,6 @@ def delete(self, request, pk): # Token invalid return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - server_id = parsed.get("server_id") account_uuid = parsed.get("uuid") @@ -335,23 +335,26 @@ def delete(self, request, pk): # 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) + 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) + 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,) @@ -383,7 +386,7 @@ def get_queryset(self): # 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 + "fork_compatibility": server_id, } query_serializer = CompatibleCharactersRequestSerializer(data=query_data) query_serializer.is_valid(raise_exception=True) @@ -395,12 +398,15 @@ def get_queryset(self): 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,) @@ -413,7 +419,7 @@ def update_or_create_character(self, request, pk): try: signer = signing.TimestampSigner() - parsed =signer.unsign_object(token, max_age=86400) # 1 day in seconds + 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) @@ -440,7 +446,6 @@ def update_or_create_character(self, request, pk): 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: diff --git a/src/persistence/models.py b/src/persistence/models.py index fc245b2..7e36b79 100644 --- a/src/persistence/models.py +++ b/src/persistence/models.py @@ -9,8 +9,7 @@ class Character(models.Model): fork_compatibility = models.CharField( max_length=25, - help_text='What fork is this character compatible with? This is a simple string, like "Unitystation" or ' - '"tg".', + help_text='What fork is this character compatible with? This is a simple string, like "Unitystation" or "tg".', default="Unitystation", )