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 fb0b4fa..0000000 --- a/api-collection/Auth/CreateAccount/Valid.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Valid - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/register - body: json - auth: none -} - -body:json { - { - "unique_identifier": "myname", - "username": "My 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 5c52b1a..0000000 --- a/api-collection/Auth/LoginWithCreds/success.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: success - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "admin@admin.com", - "password": "admin" - } -} 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/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/GetAll.bru b/api-collection/Characters/GetAll.bru deleted file mode 100644 index d0a8af7..0000000 --- a/api-collection/Characters/GetAll.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: GetAll - type: http - seq: 3 -} - -get { - url: {{baseUrl}}/persistence/characters - body: none - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} 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.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/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 ce42bd7..576549b 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 55eb449..718abc1 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 ( + RedeemSessionView, ConfirmAccountView, LoginWithCredentialsView, LoginWithTokenView, RegisterAccountView, + AuthRequestView, RequestPasswordResetView, RequestVerificationTokenView, ResendAccountConfirmationView, @@ -45,4 +47,6 @@ name="reset-password-token", ), path("reset-password/", RequestPasswordResetView.as_view(), name="reset-password"), + 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 e6ba4c3..03eb441 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -1,12 +1,15 @@ import logging import secrets +from datetime import timedelta from urllib.parse import urljoin 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.utils import timezone from drf_spectacular.utils import extend_schema from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView @@ -20,8 +23,10 @@ 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, ConnectionChallenge from .serializers import ( + AuthRequestSerializer, + ConnectionChallengeSerializer, ConfirmAccountSerializer, EmailSerializer, LoginWithCredentialsSerializer, @@ -344,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(): @@ -369,3 +374,90 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK) else: return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) + + +class AuthRequestView(APIView): + """ + Given a connection challenge and fork compatibility, registers a connection challenge + for the authenticated user and returns a signed scope token. + + **Requires Token authentication** + """ + + serializerClass = AuthRequestSerializer + + def post(self, request): + user: Account = request.user + + if not user.is_confirmed: + return ErrorResponse( + "You must confirm your email before performing this action.", + status.HTTP_403_FORBIDDEN, + ) + + serializer: AuthRequestSerializer = self.serializerClass(data=request.data) + if not serializer.is_valid(): + return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) + + 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( + {"scopeToken": scope_token}, + status=status.HTTP_200_OK, + ) + + +class RedeemSessionView(APIView): + """ + 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 + + 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) + + connection_challenge = serializer.validated_data["connection_challenge"] + + 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) + + challenge_object.delete() + + return Response( + {"account": PublicAccountDataSerializer(challenge_object.account, context={"request": request}).data}, + status=status.HTTP_200_OK, + ) + else: + 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/models.py b/src/accounts/models.py index 3d7b3b2..e550c16 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,20 @@ 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 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"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 903e900..895c34d 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -294,3 +294,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/views.py b/src/persistence/api/views.py index 2093fed..cb5440f 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -4,6 +4,8 @@ from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response +from persistence.api.permissions import TokenOrAccount + from ..models import Character from .serializers import ( CharacterSerializer, @@ -16,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) @@ -41,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): """ @@ -55,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(): @@ -77,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): """ @@ -95,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): @@ -106,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!"} @@ -132,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!"} @@ -159,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 @@ -179,4 +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) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file 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", )