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/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/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/urls.py b/src/accounts/api/urls.py index 55eb449..0cfa98d 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -2,10 +2,12 @@ from knox import views as knox_views from .views import ( + CheckSHA512ForAccountView, ConfirmAccountView, LoginWithCredentialsView, LoginWithTokenView, RegisterAccountView, + RegisterSHA512ForAccount, RequestPasswordResetView, RequestVerificationTokenView, ResendAccountConfirmationView, @@ -45,4 +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"), ] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 21aedc7..a59477c 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -1,16 +1,19 @@ import logging import secrets +from datetime import timedelta from urllib.parse import urljoin from uuid import uuid4 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.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 @@ -20,7 +23,7 @@ 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 +372,83 @@ 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): + 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. + Deletes the token after checking. + **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: + 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, + created_at__gte=valid_cutoff, + ).first() + + if matching_token: + matching_token.delete() + 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)" + + 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.") 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/accounts/models.py b/src/accounts/models.py index 3d7b3b2..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" ), ) @@ -133,3 +132,15 @@ 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() diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index b492d4a..a2c3692 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -2,11 +2,16 @@ from .views import ( CreateCharacterView, + CreateCharacterViewToken, DeleteCharacterView, + DeleteCharacterViewToken, + GenerateForkTokenView, GetAllCharactersByAccountView, GetCharacterByIdView, GetCompatibleCharacters, + GetCompatibleCharactersToken, UpdateCharacterView, + UpdateCharacterViewToken, ) app_name = "persistence" @@ -18,4 +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/GenForkToken", GenerateForkTokenView.as_view(), name="Gen-Fork-token"), ] diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 2093fed..9cdfa68 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -1,9 +1,16 @@ +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 ..models import Character from .serializers import ( CharacterSerializer, @@ -180,3 +187,295 @@ 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): + """ + 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) 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", )