diff --git a/backend/poetry.lock b/backend/poetry.lock index 69b5e17..a32caf2 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -158,6 +158,24 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "ecdsa" +version = "0.19.0" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.6" +files = [ + {file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"}, + {file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "fastapi" version = "0.104.1" @@ -426,6 +444,17 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "pyasn1" +version = "0.6.0" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.0-py2.py3-none-any.whl", hash = "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"}, + {file = "pyasn1-0.6.0.tar.gz", hash = "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c"}, +] + [[package]] name = "pydantic" version = "2.5.2" @@ -591,6 +620,27 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +optional = false +python-versions = "*" +files = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] + [[package]] name = "python-multipart" version = "0.0.6" @@ -605,6 +655,31 @@ files = [ [package.extras] dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==1.7.3)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.0" @@ -773,4 +848,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "6df0415773e463b961495e37ca21e33c9512e7ff38384fe411c7921d2cea7d65" +content-hash = "f81dbf746437e749efd8b8390d6f096bd779f60cf6e54fb3aac0d9d44ab43966" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6f86ffd..90245dd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,7 @@ asyncpg = "^0.29.0" python-multipart = "^0.0.6" jinja2 = "^3.1.2" passlib = "^1.7.4" +python-jose = "^3.3.0" [build-system] diff --git a/backend/routers/animal_router.py b/backend/routers/animal_router.py index 5acf76d..98bace9 100644 --- a/backend/routers/animal_router.py +++ b/backend/routers/animal_router.py @@ -8,9 +8,11 @@ AnimalFullResponseSchema ) from backend.schemas.base_schema import BaseOkResponse +from services.shelter_service import ShelterService from backend.services.animal_service import AnimalService from backend.services.animal_template_service import AnimalTemplateService + router = APIRouter( tags=["animals"], prefix="/animals" @@ -48,6 +50,7 @@ async def create_animal( schema: AnimalCreateSchema ) -> BaseOkResponse: async with request.app.state.db.get_master_session() as session: + await ShelterService(session).check_is_auth_active(request) await AnimalService(session).create_new_animal(schema) return BaseOkResponse() @@ -59,6 +62,7 @@ async def update_animal( schema: AnimalUpdateSchema ) -> BaseOkResponse: async with request.app.state.db.get_master_session() as session: + await ShelterService(session).check_is_auth_active(request) await AnimalService(session).update_animal(animal_id, schema) return BaseOkResponse() @@ -69,5 +73,6 @@ async def update_animal( animal_id: int ) -> BaseOkResponse: async with request.app.state.db.get_master_session() as session: + await ShelterService(session).check_is_auth_active(request) await AnimalService(session).delete_animal_by_id(animal_id) return BaseOkResponse() diff --git a/backend/routers/shelter_router.py b/backend/routers/shelter_router.py index dccf138..cd71f12 100644 --- a/backend/routers/shelter_router.py +++ b/backend/routers/shelter_router.py @@ -14,15 +14,14 @@ @router.get("/check") async def check_auth(request: Request, response: Response): async with request.app.state.db.get_master_session() as session: - await ShelterService(session).check_is_auth_active(request, response) + await ShelterService(session).check_is_auth_active(request) return BaseOkResponse() @router.post("/auth") async def auth(request: Request, body: BaseShelterSchema, response: Response): async with request.app.state.db.get_master_session() as session: - await ShelterService(session).authenticate_shelter(body, response) - return BaseOkResponse() + return await ShelterService(session).authenticate_shelter(body, response) @router.post("/") diff --git a/backend/schemas/animal_schemas.py b/backend/schemas/animal_schemas.py index ff0531e..4633c96 100644 --- a/backend/schemas/animal_schemas.py +++ b/backend/schemas/animal_schemas.py @@ -29,6 +29,7 @@ class AnimalCreateSchema(AnimalBaseSchema): sex: str age: str size: str + shelter_id: int class AnimalUpdateSchema(AnimalBaseSchema): diff --git a/backend/services/shelter_service.py b/backend/services/shelter_service.py index 848dd70..2a3bd7b 100644 --- a/backend/services/shelter_service.py +++ b/backend/services/shelter_service.py @@ -2,9 +2,10 @@ from sqlalchemy.ext.asyncio import AsyncSession as Session from passlib.context import CryptContext -from os import environ +from jose import jwt, JWTError +from jose.exceptions import ExpiredSignatureError + import time -import json from backend.models.shelter import Shelter from backend.schemas.shelter_schemas import ( @@ -30,34 +31,34 @@ async def authenticate_shelter(self, body: BaseShelterSchema, response: Response body.password, hashed_field=shelter.password ): return False - self.__prepare_cookie(shelter, response) - return shelter + + return self.__generate_session_token(shelter) + + def __generate_session_token(self, shelter: Shelter): + expires = time.time() + 3600 + token = jwt.encode({"username": shelter.username, "exp": expires}, "salt") + return token async def create_shelter(self, body: CreateShelterSchema) -> Shelter: body.username = body.username.strip().lower() body.password = self.__get_password_hash(body.password) return await self.dao.create_shelter(body) - async def check_is_auth_active(self, request: Request, response: Response): + async def check_is_auth_active(self, request: Request): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", ) - shelter_id = request.cookies.get("shelter-id") - auth_cookie = request.cookies.get("x-auth") - - if not shelter_id or not auth_cookie: - response.delete_cookie("shelter-id") - response.delete_cookie("x-auth") + token = request.headers.get("x-auth") + if not token: raise credentials_exception - shelter = await self.__get_shelter_from_db_by_id(int(shelter_id)) - secret_field = self.__prepare_secret_field(shelter) - - if not self.__verify_encoded_fields(secret_field, auth_cookie): - response.delete_cookie("shelter-id") - response.delete_cookie("x-auth") + try: + jwt.decode(token, "salt") + except ExpiredSignatureError: + raise credentials_exception + except JWTError: raise credentials_exception def __verify_encoded_fields(self, plain_field, hashed_field): @@ -71,21 +72,3 @@ async def __get_shelter_from_db(self, username: str): async def __get_shelter_from_db_by_id(self, id: int): return await self.dao.get_shelter_from_db_by_id(id) - - def __prepare_secret_field(self, shelter): - field_to_select = environ.get("HASH_FIELD", "username") - selected_value = getattr(shelter, field_to_select, field_to_select) - secret = json.dumps( - { - "id": shelter.id, - "secret": selected_value, - } - ) - - return secret - - def __prepare_cookie(self, shelter, response: Response): - secret_field = self.__prepare_secret_field(shelter) - auth_cookie = pwd_context.hash(secret_field) - response.set_cookie(key="x-auth", httponly=True, value=auth_cookie, samesite='none', secure=True) - response.set_cookie(key="shelter-id", httponly=True, value=shelter.id, samesite='none', secure=True)