From 80a63fe2529edbb3a72609a94a6aaef3a1354c72 Mon Sep 17 00:00:00 2001 From: Alexander-D-Karpov Date: Sun, 5 Jun 2022 13:09:27 +0300 Subject: [PATCH] added jwt auth, base player logic --- chess_backend/settings.py | 6 +--- game/authentication.py | 25 ++++++++++++++++ game/models.py | 63 ++++++++++++++++++++++++++++++++------- game/serializers.py | 19 ++++++++++-- game/services/__init__.py | 0 game/services/jwt.py | 49 ++++++++++++++++++++++++++++++ game/urls.py | 5 ++-- game/views.py | 40 ++++++++++++++++++++++--- requirements.txt | 1 + 9 files changed, 184 insertions(+), 24 deletions(-) create mode 100644 game/authentication.py create mode 100644 game/services/__init__.py create mode 100644 game/services/jwt.py diff --git a/chess_backend/settings.py b/chess_backend/settings.py index 5a36996..d60022a 100644 --- a/chess_backend/settings.py +++ b/chess_backend/settings.py @@ -15,19 +15,18 @@ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = 'django-insecure-%_8sy196w4hzo9^cp9(@r=i+amh47r4mxfhq_(ok&=c(@%bhmk' +TOKEN_EXP = 2678400 # 31 day # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] - # Application definition INSTALLED_APPS = [ @@ -76,7 +75,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'chess_backend.wsgi.application' - # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases @@ -87,7 +85,6 @@ DATABASES = { } } - # Password validation # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators @@ -106,7 +103,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ diff --git a/game/authentication.py b/game/authentication.py new file mode 100644 index 0000000..89a043a --- /dev/null +++ b/game/authentication.py @@ -0,0 +1,25 @@ +from rest_framework import authentication +from rest_framework import exceptions +from .models import Player +from .services.jwt import read_jwt + + +class PlayerAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + token = request.headers["Authorization"] + if not token: + raise exceptions.AuthenticationFailed("No credentials provided.") + + t = read_jwt(token) + if not t: + raise exceptions.AuthenticationFailed("Token is incorrect of expired") + + if "id" not in t: + raise exceptions.AuthenticationFailed("No user data") + + try: + user = Player.objects.get(id=int(t["id"])) + except Player.DoesNotExist: + raise exceptions.AuthenticationFailed("No such user") + + return user, None diff --git a/game/models.py b/game/models.py index 7986514..3abe6b2 100644 --- a/game/models.py +++ b/game/models.py @@ -1,7 +1,15 @@ +import random import uuid -from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.files import File +from django.core.validators import ( + MinValueValidator, + MaxValueValidator, + MinLengthValidator, + MaxLengthValidator, +) from django.db import models +from django.conf import settings HER0_TYPES = [("WIZARD", "wizard"), ("ARCHER", "archer"), ("WARRIOR", "warrior")] @@ -9,17 +17,47 @@ HER0_TYPES = [("WIZARD", "wizard"), ("ARCHER", "archer"), ("WARRIOR", "warrior") class Player(models.Model): """base model to handle and store users""" - # TODO: connect real TON wallet - ton_wallet = models.CharField(max_length=50, verbose_name="TON wallet") + ton_wallet = models.CharField( + verbose_name="TON wallet", + validators=[MinLengthValidator(48), MaxLengthValidator(48)], + max_length=48, + unique=True, + ) name = models.CharField(max_length=100, blank=True) - added = models.DateTimeField(auto_now_add=True) + created = models.DateTimeField(auto_now_add=True) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + super(Player, self).save() + types = ( + ["ARCHER" for _ in range(4)] + + ["WARRIOR" for _ in range(6)] + + ["WIZARD" for _ in range(2)] + + [random.choice(HER0_TYPES)[0] for _ in range(4)] + ) + for t in types: + hero = Hero() + hero.player = self + hero.type = t + + with open("/home/sanspie/Projects/chess_rpg_backend/media/dummy.jpg", "rb+") as file: + hero.idle_img = File(file, name="dummy.jpg") + hero.attack_img = File(file, name="dummy.jpg") + hero.die_img = File(file, name="dummy.jpg") + + hero.health = random.randint(0, 10) + hero.attack = random.randint(0, 10) + hero.speed = random.randint(0, 10) + + hero.save() def __str__(self): return self.name class Meta: indexes = [models.Index(fields=["ton_wallet"])] - ordering = ["-added"] + ordering = ["-created"] verbose_name = "player" verbose_name_plural = "players" @@ -28,12 +66,12 @@ class Hero(models.Model): """Model to store heroes and their stats, connected to player""" uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - # player = models.ForeignKey( - # Player, - # on_delete=models.CASCADE, - # related_name="heroes", - # related_query_name="hero", - # ) + player = models.ForeignKey( + Player, + on_delete=models.CASCADE, + related_name="heroes", + related_query_name="hero", + ) added = models.DateTimeField(auto_now_add=True) type = models.CharField(blank=False, choices=HER0_TYPES, max_length=7) @@ -43,6 +81,9 @@ class Hero(models.Model): health = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(10)], blank=False ) + attack = models.IntegerField( + validators=[MinValueValidator(0), MaxValueValidator(10)], blank=False + ) speed = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(10)], blank=False ) diff --git a/game/serializers.py b/game/serializers.py index 790397a..1f629e4 100644 --- a/game/serializers.py +++ b/game/serializers.py @@ -1,12 +1,20 @@ from rest_framework import serializers -from game.models import Hero +from game.models import Hero, Player class CreateHeroSerializer(serializers.ModelSerializer): class Meta: model = Hero - fields = ("type", "idle_img", "attack_img", "die_img", "health", "speed") + fields = ( + "type", + "idle_img", + "attack_img", + "die_img", + "health", + "attack", + "speed", + ) class GetHeroSerializer(serializers.ModelSerializer): @@ -19,5 +27,12 @@ class GetHeroSerializer(serializers.ModelSerializer): "attack_img", "die_img", "health", + "attack", "speed", ) + + +class CreatePlayerView(serializers.ModelSerializer): + class Meta: + model = Player + fields = ("ton_wallet", "name") diff --git a/game/services/__init__.py b/game/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/services/jwt.py b/game/services/jwt.py new file mode 100644 index 0000000..ad8c462 --- /dev/null +++ b/game/services/jwt.py @@ -0,0 +1,49 @@ +import jwt +import pytz + +from datetime import datetime +from django.conf import settings +from jwt import ExpiredSignatureError, InvalidSignatureError + +TIMEZONE = pytz.timezone("Europe/Moscow") + + +def sign_jwt(data: dict, t_life: None | int = None) -> str: + """generate and sign jwt with iat and exp using data from settings""" + iat = int(datetime.now(tz=TIMEZONE).timestamp()) + exp = iat + settings.TOKEN_EXP if not t_life else iat + t_life + payload = {"iat": iat, "exp": exp} + for nm, el in data.items(): + if nm not in ["iat", "exp"]: + payload[nm] = el + + secret = settings.SECRET_KEY + token = jwt.encode(payload=payload, key=secret) + return token + + +def read_jwt(token: str) -> dict | bool: + """reads jwt, validates it and return payload if correct""" + header_data = jwt.get_unverified_header(token) + secret = settings.SECRET_KEY + try: + payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]]) + except ExpiredSignatureError as e: + return False + except InvalidSignatureError as e: + return False + + if "exp" not in payload: + return False + + if int(datetime.now(tz=TIMEZONE).timestamp()) > payload["exp"]: + return False + + payload.pop("iat", None) + payload.pop("exp", None) + + return payload + + +def generate_refresh_token(payload: dict) -> str: + return sign_jwt(payload) diff --git a/game/urls.py b/game/urls.py index 396dd7e..f868f61 100644 --- a/game/urls.py +++ b/game/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from game.views import CreateHeroView, RetrieveHeroView +from game.views import CreateHeroView, RetrieveHeroView, PlayerCreateView urlpatterns = [ path("hero/", CreateHeroView.as_view(), name="hero_api_create"), path("hero/", RetrieveHeroView.as_view(), name="hero_api_retrieve"), -] \ No newline at end of file + path("player/", PlayerCreateView.as_view(), name="player_create_api"), +] diff --git a/game/views.py b/game/views.py index 6a0679f..826b844 100644 --- a/game/views.py +++ b/game/views.py @@ -1,15 +1,17 @@ from rest_framework import status from rest_framework.generics import GenericAPIView, UpdateAPIView -from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, ListModelMixin from rest_framework.response import Response +from game.authentication import PlayerAuthentication from game.models import Hero -from game.serializers import CreateHeroSerializer, GetHeroSerializer +from game.serializers import CreateHeroSerializer, GetHeroSerializer, CreatePlayerView +from game.services.jwt import sign_jwt -class CreateHeroView(GenericAPIView, CreateModelMixin): - serializer_class = CreateHeroSerializer +class CreateHeroView(GenericAPIView, CreateModelMixin, ListModelMixin): + authentication_classes = (PlayerAuthentication,) def perform_create(self, serializer): return serializer.save() @@ -20,9 +22,22 @@ class CreateHeroView(GenericAPIView, CreateModelMixin): instance = self.perform_create(serializer) return Response({"uuid": instance.uuid}, status=status.HTTP_201_CREATED) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_serializer_class(self): + if self.request.method == "GET": + return GetHeroSerializer + else: + return CreateHeroSerializer + + def get_queryset(self): + return Hero.objects.filter(player_id=self.request.user.id) + class RetrieveHeroView(RetrieveModelMixin, UpdateAPIView, GenericAPIView): serializer_class = GetHeroSerializer + authentication_classes = (PlayerAuthentication,) lookup_field = "uuid" queryset = Hero.objects.all() @@ -34,3 +49,20 @@ class RetrieveHeroView(RetrieveModelMixin, UpdateAPIView, GenericAPIView): def patch(self, request, *args, **kwargs): return self.partial_update(request, *args, **kwargs) + + +class PlayerCreateView(GenericAPIView, CreateModelMixin): + serializer_class = CreatePlayerView + + def perform_create(self, serializer): + return serializer.save() + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance = self.perform_create(serializer) + + # TODO: add JTI to refresh token + access_jwt = sign_jwt({"id": instance.id, "type": "access"}, t_life=3600) + refresh_jwt = sign_jwt({"id": instance.id, "type": "refresh"}) + return Response({"access_token": access_jwt, "refresh_token": refresh_jwt}, status=status.HTTP_201_CREATED) diff --git a/requirements.txt b/requirements.txt index 21891e2..080ec94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ djangorestframework==3.13.1 Pillow==9.1.1 pytz==2022.1 sqlparse==0.4.2 +PyJWT==2.4.0 \ No newline at end of file