Skip to content

Commit

Permalink
feat/created cookie auth
Browse files Browse the repository at this point in the history
  • Loading branch information
serikovlearning committed May 4, 2024
1 parent 2220c8e commit 8679b8e
Show file tree
Hide file tree
Showing 16 changed files with 309 additions and 6 deletions.
29 changes: 29 additions & 0 deletions backend/dao/shelter_dao.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 43 additions & 0 deletions backend/database/versions/fdae3120f6f8_added_shelter_table.py
Original file line number Diff line number Diff line change
@@ -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 ###
3 changes: 3 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -30,6 +31,8 @@ def make_app(settings: Settings):
bind_static(app)
add_pagination(app)

prepare_cors_middleware(app, settings)

return app


Expand Down
5 changes: 3 additions & 2 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
]
13 changes: 12 additions & 1 deletion backend/models/animal.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions backend/models/shelter.py
Original file line number Diff line number Diff line change
@@ -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)


19 changes: 18 additions & 1 deletion backend/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion backend/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
]
35 changes: 35 additions & 0 deletions backend/routers/shelter_router.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 10 additions & 0 deletions backend/schemas/shelter_schemas.py
Original file line number Diff line number Diff line change
@@ -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)
91 changes: 91 additions & 0 deletions backend/services/shelter_service.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion backend/settings/base_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/settings/cors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class CORSSettings:
TESTING = [r"http://localhost:5173"]
3 changes: 3 additions & 0 deletions backend/settings/local.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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):
Expand Down
36 changes: 36 additions & 0 deletions backend/utils/prepare_cors.py
Original file line number Diff line number Diff line change
@@ -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,
},
}

0 comments on commit 8679b8e

Please sign in to comment.