diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d8ed1a..7658aef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,12 @@ repos: - id: detect-wallet-private-key types: [file] exclude: .json -- repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - files: "\\.(py)$" - args: [--settings-path=pyproject.toml] +# - repo: https://github.com/pycqa/isort +# rev: 5.12.0 +# hooks: +# - id: isort +# files: "\\.(py)$" +# args: [--settings-path=pyproject.toml] - repo: https://github.com/psf/black rev: 23.3.0 hooks: diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000..1847b2a --- /dev/null +++ b/core/exceptions.py @@ -0,0 +1,13 @@ +from rest_framework.exceptions import APIException + + +class LoginValidationFailed(APIException): + status_code = 400 + default_code = "login_validation_failed" + default_detail = "Cannot Login or SignUp" + + +class InvalidSignature(APIException): + status_code = 400 + default_code = "invalid_signature" + default_detail = "The signature is invalid" diff --git a/core/serializers.py b/core/serializers.py index 9a9ff87..8f66415 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,6 +1,8 @@ +from rest_framework import exceptions as rest_exceptions from rest_framework import serializers -from . import models +from . import exceptions, models +from .service import CoreService class AcceptedNFTSerializer(serializers.ModelSerializer): @@ -49,3 +51,69 @@ def get_listings_count(self, obj: models.AcceptedNFT) -> int: return models.Listing.objects.filter( token_contract_address=obj.contract_address ).count() + + +class UserSerializer(serializers.ModelSerializer): + """ + Convert the user model class to dict-like data for json serialization. + """ + + class Meta: + model = models.User + fields = ["id", "public_key", "email"] + + +class SignInSerializer(serializers.Serializer): + """ + Serializer class for the sign in request call. + It validates the signstures to ensure it is the right user that is + making the signin request. + This is for signing in with the wallet (account). + + Data: + signatures: A list of strings representing the signatures of + the signed login message + public_key: The public key of the signer + """ + + signatures = serializers.ListField(child=serializers.CharField()) + public_key = serializers.CharField() + + def validate(self, attrs: dict) -> dict: + """ + Validate the request data + """ + # The signature list length must be greater than or equals to 5 + # according to the starknet signature format. + if len(attrs["signatures"]) < 5: + raise exceptions.InvalidSignature + + # validate the signature + check = CoreService.validate_login_request( + attrs["signatures"], attrs["public_key"] + ) + if not check: + raise exceptions.LoginValidationFailed + + # prevent login if account is not active + if models.User.objects.filter( + public_key=attrs["public_key"], is_active=False + ).exists(): + raise rest_exceptions.AuthenticationFailed + + return attrs + + def save(self) -> dict: + """ + Create or get the user and generate an auth token data + for requests authentications + + Returns: + dict: Data of the user + """ + public_key = self.validated_data["public_key"] + user, is_new = CoreService.login_or_register_user(public_key) + data = UserSerializer(user).data + data["is_new"] = is_new + token_info = CoreService.generate_auth_token_data(user) + return {**data, **token_info} diff --git a/core/service.py b/core/service.py new file mode 100644 index 0000000..4b28c6f --- /dev/null +++ b/core/service.py @@ -0,0 +1,57 @@ +from rest_framework_simplejwt.tokens import RefreshToken +from starknet_py.utils.typed_data import TypedData + +from core import models + +from .utils import SignatureUtils + + +class CoreService: + @classmethod + def generate_auth_token_data(user: models.User) -> dict: + """ + Create the login token for validating protected requests. + Args: + user(models.User): the user model + """ + token_data_obj = RefreshToken.for_user(user) + expiry = token_data_obj.access_token["exp"] + token_data = { + "access": str(token_data_obj.access_token), + "refresh": str(token_data_obj), + "expiry": expiry, + } + return token_data + + @classmethod + def validate_login_request( + cls, signatures: list[str], public_key: list[str] + ) -> bool: + """ + Validate the Signed data that verifies the login of the user. + The data is signed by the user's wallet and it's components + are sent for verifiction + Args: + signatures(list[str]): the signatures of the message + public_key: The public key of the signer. + + Returns: + bool: a bool representing whether the signature is valid or not. + """ + typed_data_dict = SignatureUtils.login_typed_data_format() + typed_data = TypedData(**typed_data_dict) + return SignatureUtils.verify_signatures(typed_data, signatures, public_key) + + @classmethod + def login_or_register_user(cls, public_key: str) -> tuple[models.User, bool]: + """ + Create or get the existing user model + Args: + public_key: The public key of the user + + Returns: + tuple[User, bool]: The User model and a bool indicating + whether is a new model or not + """ + user, created = models.User.objects.get_or_create(public_key=public_key) + return user, created diff --git a/core/urls.py b/core/urls.py index 8ffb363..9e737a1 100644 --- a/core/urls.py +++ b/core/urls.py @@ -13,4 +13,9 @@ views.AcceptedTokenListAPIView.as_view(), name="accepted-tokens-list-view", ), + path( + "sigin/", + views.SignInAPIView.as_view(), + name="signin", + ), ] diff --git a/core/utils.py b/core/utils.py index 326bfd5..8cb12e6 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1 +1,85 @@ -# file of all the utility functions and variables +# file of all the utility functions, variables and classes +from django.conf import settings +from starknet_py.hash.utils import verify_message_signature +from starknet_py.utils.typed_data import Domain, Parameter, TypedData + +DOMAIN_NAME = settings.SIG_DOMAIN_NAME +CHAIN_ID = settings.SIG_CHAIN_ID +VERSION = settings.SIG_VERSION + + +class SignatureUtils: + @classmethod + def login_typed_data_format(cls) -> dict: + """ + This represents the signature request of the login operation. + Read on starknet signatures to understand more + + Returns: + dict: The signature request structure of the login functionality. + """ + data = { + "domain": Domain( + **{ + "name": DOMAIN_NAME, + "chain_id": CHAIN_ID, + "version": VERSION, + } + ), + "types": { + "StarknetDomain": [ + Parameter(**{"name": "name", "type": "felt"}), + Parameter(**{"name": "chainId", "type": "felt"}), + Parameter(**{"name": "version", "type": "felt"}), + ], + "Message": [ + Parameter(**{"name": "name", "type": "felt"}), + Parameter(**{"name": "age", "type": "felt"}), + Parameter(**{"name": "address", "type": "felt"}), + ], + }, + "primary_type": "Message", + "message": {}, + } + return data.copy() + + @classmethod + def generate_signature_typed_data(cls, data: dict, type_format: dict) -> TypedData: + """ + Integrates the data from a signing format into a signature request. + This data is based on the request structure and the signature message type. + It generates a TypedData from the typed data format. + + Args: + data(dict): the data that contains the essential details of the signature + that is integrated into the typed_data format (the signature request). + type_format(dict): The typed data format that contains the meta data + of the signature request. + Returns: + TypedData: returns that typed data that is used for generating a + message hash for signature verification. + """ + login_signature_request_format = type_format + # add the data into the message section of the dict + login_signature_request_format["message"] = data + return TypedData(**login_signature_request_format) + + @classmethod + def verify_signatures( + cls, typed_data: TypedData, signatures: list[str], public_key: str + ) -> bool: + """ + Verify the signature with the typed data, signature list and the public key + Args: + typed_data(TypedData): This is used for generating a message hash + for signature verification. + signatures(list[str]): This is a list of the signatures that represent + the message that is signed. + public_key(str): The public key of the signer. + """ + int_signatures = list(map(lambda x: int(x), signatures)) + int_public_key = int(public_key, 16) + message_hash = typed_data.message_hash(int_public_key) + return verify_message_signature( + message_hash, [int_signatures[3], int_signatures[4]], public_key + ) diff --git a/core/views.py b/core/views.py index c50b84b..ef11055 100644 --- a/core/views.py +++ b/core/views.py @@ -1,5 +1,6 @@ # Create your views here. -from rest_framework.generics import ListAPIView +from rest_framework.generics import GenericAPIView, ListAPIView +from rest_framework.response import Response from . import models, serializers @@ -12,3 +13,14 @@ class AcceptedNFTListAPIView(ListAPIView): class AcceptedTokenListAPIView(ListAPIView): queryset = models.AcceptedToken.objects.all().order_by("name") serializer_class = serializers.AcceptedTokenSerializer + + +class SignInAPIView(GenericAPIView): + serializer_class = serializers.SignInSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.save() + + return Response(data) diff --git a/poetry.lock b/poetry.lock index 7f72692..6ddd954 100644 --- a/poetry.lock +++ b/poetry.lock @@ -614,6 +614,30 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.0" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.9" +files = [ + {file = "djangorestframework_simplejwt-5.5.0-py3-none-any.whl", hash = "sha256:4ef6b38af20cdde4a4a51d1fd8e063cbbabb7b45f149cc885d38d905c5a62edb"}, + {file = "djangorestframework_simplejwt-5.5.0.tar.gz", hash = "sha256:474a1b737067e6462b3609627a392d13a4da8a08b1f0574104ac6d7b1406f90e"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = ">=3.14" +pyjwt = ">=1.7.1,<2.10.0" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "ecdsa" version = "0.18.0" @@ -1544,6 +1568,23 @@ files = [ {file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"}, ] +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pytest" version = "7.4.4" @@ -1960,4 +2001,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = ">=3.12,<3.13" -content-hash = "1c8893ea5fbe5ffc975b9a00bfcbae00bfee7084e17a97218fce74c9051c2016" +content-hash = "d1cbeffce2330158b0c37b11d2fb20fc552ffa916e117d08b9947fd2d9731b0e" diff --git a/pyproject.toml b/pyproject.toml index d11f23c..e9d4ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dj-database-url = "^2.3.0" django-cors-headers = "^4.7.0" psycopg2-binary = "^2.9.10" factory-boy = "3.3.0" +djangorestframework-simplejwt = "^5.5.0" [tool.poetry.group.dev.dependencies] black = "^23.9.1" diff --git a/trajectfi/settings.py b/trajectfi/settings.py index e6e5c58..a84de14 100644 --- a/trajectfi/settings.py +++ b/trajectfi/settings.py @@ -11,6 +11,7 @@ """ import os +from datetime import timedelta from pathlib import Path import dj_database_url @@ -159,5 +160,19 @@ "TIME_FORMAT": "%H:%M:%S", } +# JWT settings +SIMPLE_JWT = { + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_CLAIM": "user_id", + "ACCESS_TOKEN_LIFETIME": timedelta(days=3), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), +} + +# settings for generating signature request format +SIG_DOMAIN_NAME = "TRAJECTFI" +SIG_CHAIN_ID = "SN_SEPOLIA" +SIG_VERSION = "0.1.0" + + # Custom settings AUTH_USER_MODEL = "core.User"