diff --git a/backend/dao/shelter_dao.py b/backend/dao/shelter_dao.py new file mode 100644 index 0000000..81efaa1 --- /dev/null +++ b/backend/dao/shelter_dao.py @@ -0,0 +1,29 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession as Session + +from backend.models.shelter import Shelter +from backend.schemas.shelter_schemas import CreateShelterSchema +from backend.dao.base_dao import BaseDao + + +class ShelterDao(BaseDao): + def __init__(self, session: Session): + super().__init__(session, Shelter) + + async def create_shelter(self, body: CreateShelterSchema) -> Shelter: + shelter = Shelter( + username=body.username, password=body.password, slug=body.slug + ) + + self.session.add(shelter) + await self.session.flush() + + return shelter + + async def get_shelter_from_db(self, username: str): + query = select(Shelter).where(Shelter.username == username.lower()) + return await self.session.scalar(query) + + async def get_shelter_from_db_by_id(self, id: int): + query = select(Shelter).where(Shelter.id == id) + return await self.session.scalar(query) diff --git a/backend/database/versions/fdae3120f6f8_added_shelter_table.py b/backend/database/versions/fdae3120f6f8_added_shelter_table.py new file mode 100644 index 0000000..3133913 --- /dev/null +++ b/backend/database/versions/fdae3120f6f8_added_shelter_table.py @@ -0,0 +1,43 @@ +"""added_shelter_table + +Revision ID: fdae3120f6f8 +Revises: 3e3431180c42 +Create Date: 2024-05-04 18:18:21.454167 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fdae3120f6f8' +down_revision: Union[str, None] = '3e3431180c42' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('shelter', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('slug', sa.String(length=50), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('password', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk__shelter')), + sa.UniqueConstraint('id', name=op.f('uq__shelter__id')) + ) + op.add_column('animal', sa.Column('shelter_id', sa.Integer(), nullable=True)) + op.create_index(op.f('ix__animal__shelter_id'), 'animal', ['shelter_id'], unique=False) + op.create_foreign_key(op.f('fk__animal__shelter_id__shelter'), 'animal', 'shelter', ['shelter_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('fk__animal__shelter_id__shelter'), 'animal', type_='foreignkey') + op.drop_index(op.f('ix__animal__shelter_id'), table_name='animal') + op.drop_column('animal', 'shelter_id') + op.drop_table('shelter') + # ### end Alembic commands ### diff --git a/backend/main.py b/backend/main.py index 5aff989..e588c36 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from backend.settings import Settings from backend.utils import bind_routes, bind_events, bind_exceptions, bind_static +from backend.utils.prepare_cors import prepare_cors_middleware def make_app(settings: Settings): @@ -30,6 +31,8 @@ def make_app(settings: Settings): bind_static(app) add_pagination(app) + prepare_cors_middleware(app, settings) + return app diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 535114f..44a1b80 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,6 +1,7 @@ -from backend.models import animal, animal_photo +from backend.models import animal, animal_photo, shelter __all__ = [ "animal", - "animal_photo" + "animal_photo", + "shelter" ] diff --git a/backend/models/animal.py b/backend/models/animal.py index 46cba86..2dc799a 100644 --- a/backend/models/animal.py +++ b/backend/models/animal.py @@ -1,4 +1,4 @@ -from sqlalchemy import Integer, Column, String +from sqlalchemy import Integer, Column, String, ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property from backend.settings import Settings @@ -24,6 +24,17 @@ class Animal(DeclarativeBase): age = Column(String, nullable=False) size = Column(String, nullable=False) + shelter_id = Column( + ForeignKey("shelter.id", ondelete="CASCADE"), + index=True, + nullable=True, + ) + shelter = relationship( + "Shelter", + foreign_keys=[shelter_id], + lazy="noload", + ) + @hybrid_property def photo_urls(self): return [photo.full_url for photo in self.photos] diff --git a/backend/models/shelter.py b/backend/models/shelter.py new file mode 100644 index 0000000..e6daefe --- /dev/null +++ b/backend/models/shelter.py @@ -0,0 +1,15 @@ +from backend.database.metadata import DeclarativeBase +from sqlalchemy import Integer, Column, String + + + +class Shelter(DeclarativeBase): + __tablename__ = "shelter" + + id = Column(Integer, primary_key=True, autoincrement=True, unique=True) + slug = Column(String(50), nullable=False) + + username = Column(String(100), nullable=False) + password = Column(String(100), nullable=False) + + diff --git a/backend/poetry.lock b/backend/poetry.lock index ebe4906..69b5e17 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -409,6 +409,23 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "pydantic" version = "2.5.2" @@ -756,4 +773,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "fb27ba871e885abaf626f72138b648619dbd16a08526636938af418743f8943c" +content-hash = "6df0415773e463b961495e37ca21e33c9512e7ff38384fe411c7921d2cea7d65" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 659d213..6f86ffd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ fastapi-pagination = "^0.12.14" asyncpg = "^0.29.0" python-multipart = "^0.0.6" jinja2 = "^3.1.2" +passlib = "^1.7.4" [build-system] diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py index d218781..04c2a8c 100644 --- a/backend/routers/__init__.py +++ b/backend/routers/__init__.py @@ -1,7 +1,9 @@ from backend.routers.animal_router import router as animals_router from backend.routers.animal_photo_router import router as animals_photo_router +from backend.routers.shelter_router import router as shelter_router routes = [ animals_router, - animals_photo_router + animals_photo_router, + shelter_router ] diff --git a/backend/routers/shelter_router.py b/backend/routers/shelter_router.py new file mode 100644 index 0000000..dccf138 --- /dev/null +++ b/backend/routers/shelter_router.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Request, Response + +from backend.schemas.shelter_schemas import ( + CreateShelterSchema, + BaseShelterSchema, +) +from backend.schemas.base_schema import BaseOkResponse +from services.shelter_service import ShelterService + + +router = APIRouter(tags=["shelter"], prefix="/shelter") + + +@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) + 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() + + +@router.post("/") +async def create_shelter( + request: Request, schema: CreateShelterSchema +) -> BaseOkResponse: + print(request.cookies) + async with request.app.state.db.get_master_session() as session: + await ShelterService(session).create_shelter(schema) + return BaseOkResponse() diff --git a/backend/schemas/shelter_schemas.py b/backend/schemas/shelter_schemas.py new file mode 100644 index 0000000..02fe6aa --- /dev/null +++ b/backend/schemas/shelter_schemas.py @@ -0,0 +1,10 @@ +from backend.schemas.base_schema import BaseSchema + +from pydantic import Field + +class BaseShelterSchema(BaseSchema): + username: str = Field(max_length=100) + password: str = Field(max_length=100) + +class CreateShelterSchema(BaseShelterSchema): + slug: str = Field(max_length=50) diff --git a/backend/services/shelter_service.py b/backend/services/shelter_service.py new file mode 100644 index 0000000..848dd70 --- /dev/null +++ b/backend/services/shelter_service.py @@ -0,0 +1,91 @@ +from fastapi import Response, Request, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession as Session +from passlib.context import CryptContext + +from os import environ +import time +import json + +from backend.models.shelter import Shelter +from backend.schemas.shelter_schemas import ( + CreateShelterSchema, + BaseShelterSchema, +) +from backend.dao.shelter_dao import ShelterDao + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class ShelterService: + def __init__(self, session: Session): + self.session = session + self.dao = ShelterDao(session) + + async def authenticate_shelter(self, body: BaseShelterSchema, response: Response): + shelter = await self.__get_shelter_from_db(body.username) + if not shelter: + return False + if not self.__verify_encoded_fields( + body.password, hashed_field=shelter.password + ): + return False + self.__prepare_cookie(shelter, response) + return shelter + + 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): + 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") + 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") + raise credentials_exception + + def __verify_encoded_fields(self, plain_field, hashed_field): + return pwd_context.verify(plain_field, hashed_field) + + def __get_password_hash(self, password): + return pwd_context.hash(password) + + async def __get_shelter_from_db(self, username: str): + return await self.dao.get_shelter_from_db(username) + + 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) diff --git a/backend/settings/base_settings.py b/backend/settings/base_settings.py index 42120c9..9cd7c58 100644 --- a/backend/settings/base_settings.py +++ b/backend/settings/base_settings.py @@ -23,7 +23,11 @@ class BaseSettings(PydanticBaseSettings): PATH_TO_PHOTOS: str | None = "./animal_photos" URL_PHOTO_DOWNLOAD: str | None - # CORS: list[str] | None + CORS: list[str] | None + + @property + def cors_origins(self): + return self.CORS @property def db_settings(self): diff --git a/backend/settings/cors.py b/backend/settings/cors.py new file mode 100644 index 0000000..78b18ea --- /dev/null +++ b/backend/settings/cors.py @@ -0,0 +1,2 @@ +class CORSSettings: + TESTING = [r"http://localhost:5173"] diff --git a/backend/settings/local.py b/backend/settings/local.py index 56fec1b..bbc63ca 100644 --- a/backend/settings/local.py +++ b/backend/settings/local.py @@ -1,5 +1,7 @@ from backend.settings.base_settings import BaseSettings +from backend.settings.cors import CORSSettings + class Settings(BaseSettings): DB_HOST: str | None = "localhost" @@ -17,6 +19,7 @@ class Settings(BaseSettings): URL_PHOTO_DOWNLOAD: str = "http://localhost:8080/animal_photo/download" HTTP_PROTOCOL: str = "HTTP" + CORS: list[str] = CORSSettings.TESTING @property def database_url(self): diff --git a/backend/utils/prepare_cors.py b/backend/utils/prepare_cors.py new file mode 100644 index 0000000..9ca1ec9 --- /dev/null +++ b/backend/utils/prepare_cors.py @@ -0,0 +1,36 @@ +from operator import itemgetter + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from backend.settings import BaseSettings + + + +default_value = ["*"] + + +def prepare_cors_middleware(app: FastAPI, settings: BaseSettings): + middleware, params = itemgetter("middleware", "params")( + create_cors_middleware(origins=settings.cors_origins) + ) + + app.add_middleware( + middleware, + **params, + ) + + +def create_cors_middleware( + origins: list[str] | None = None, + methods: list[str] | None = None, + headers: list[str] | None = None, +): + return { + "middleware": CORSMiddleware, + "params": { + "allow_origins": origins or default_value, + "allow_credentials": True, + "allow_methods": methods or default_value, + "allow_headers": headers or default_value, + }, + }