diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 1e3f008..32ab921 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,18 +1,34 @@ +import datetime as dt +import secrets + from collections.abc import Sequence from typing import Annotated from uuid import UUID -from fastapi import APIRouter, HTTPException, Query, Response, status -from pydantic import EmailStr, NonNegativeInt +from fastapi import APIRouter, Cookie, HTTPException, Query, Response, status +from pwdlib import PasswordHash +from pydantic import EmailStr, NonNegativeInt, SecretStr from sqlalchemy.dialects.postgresql import insert from sqlmodel import select -from app.dependencies import AuthDep, SessionDep +from app.dependencies import AuthDep, SessionDep, UserSessionDep from app.enums import Category -from app.models import Cart, CartItem, NewCart, NewCartItem, NewUser, Product, User +from app.models import ( + Cart, + CartItem, + NewCart, + NewCartItem, + NewUser, + NewUserSession, + Product, + User, + UserSession, +) router = APIRouter() +hasher = PasswordHash.recommended() + @router.get("/api/products") async def get_products( @@ -40,15 +56,20 @@ async def get_product(id: str, session: SessionDep) -> Product: async def create_user( user: NewUser, session: SessionDep, response: Response, _: AuthDep ): + if session.get(User, user.username) is not None: + raise HTTPException(status_code=400, detail="User already exists") + + user.password = hasher.hash(str(user.password)) + session.add(User(**user.model_dump())) session.commit() - response.headers["Location"] = f"/users/{user.id}" + response.headers["Location"] = f"/users/account" -@router.get("/api/users/{id}") -async def get_user(id: str, session: SessionDep) -> User: - user = session.get(User, id) +@router.get("/api/users/{username}") +async def get_user(username: str, session: SessionDep, _: AuthDep) -> User: + user = session.get(User, username) if user is None: raise HTTPException(status_code=404, detail="User not found") @@ -56,9 +77,11 @@ async def get_user(id: str, session: SessionDep) -> User: return user -@router.patch("/api/users/{id}") -async def update_user_email(id: str, email: EmailStr, session: SessionDep, _: AuthDep): - user = session.get(User, id) +@router.patch("/api/users/{username}") +async def update_user_email( + username: str, email: EmailStr, session: SessionDep, _: AuthDep +): + user = session.get(User, username) if user is None: raise HTTPException(status_code=404, detail="User not found") @@ -68,9 +91,9 @@ async def update_user_email(id: str, email: EmailStr, session: SessionDep, _: Au session.commit() -@router.delete("/api/users/{id}/", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user(id: str, session: SessionDep, _: AuthDep): - user = session.get(User, id) +@router.delete("/api/users/{username}/", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(username: str, session: SessionDep, _: AuthDep): + user = session.get(User, username) if user is None: raise HTTPException(status_code=404, detail="Cart not found") @@ -79,10 +102,56 @@ async def delete_user(id: str, session: SessionDep, _: AuthDep): session.commit() +@router.post("/api/login") +async def create_user_session( + username: str, password: SecretStr, session: SessionDep, response: Response +): + user = session.get(User, username) + + if user is None or hasher.verify(password.get_secret_value(), user.password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + user_session = NewUserSession( + id=secrets.token_urlsafe(), + username=username, + expires_at=dt.datetime.now() + dt.timedelta(days=1), + ) + + session.add(UserSession(**user_session.model_dump())) + session.commit() + + response.set_cookie( + key="session_id", + value=user_session.id, + httponly=True, + samesite="strict", + ) + + +@router.post("/api/logout", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_session( + session: SessionDep, + user_session_id: Annotated[str | None, Cookie()] = None, +): + if user_session_id is not None: + user_session = session.get(UserSession, user_session_id) + + if user_session is not None: + session.delete(user_session) + session.commit() + + @router.post("/api/carts", status_code=status.HTTP_201_CREATED) async def create_cart( - cart: NewCart, response: Response, session: SessionDep, _: AuthDep + id: UUID, + user_id: str | None, + response: Response, + user_session: UserSessionDep, + session: SessionDep, + _: AuthDep, ): + cart = NewCart(id=id, user_id=user_id, session_id=user_session.id) + session.add(Cart(**cart.model_dump())) session.commit() @@ -114,10 +183,16 @@ async def delete_cart(id: UUID, session: SessionDep, _: AuthDep): async def create_cart_item( cart_id: UUID, item: NewCartItem, + user_session: UserSessionDep, session: SessionDep, response: Response, _: AuthDep, ): + cart = session.get(Cart, user_session.id) + + if cart is None or cart.session_id != user_session.id: + raise HTTPException(status_code=404, detail="Cart not found") + query = ( insert(CartItem) .values(cart_id=cart_id, product_id=item.product_id, quantity=item.quantity) @@ -135,11 +210,12 @@ async def create_cart_item( @router.get("/api/carts/{id}/items") async def get_cart_items( - id: UUID, - session: SessionDep, + id: UUID, user_session: UserSessionDep, session: SessionDep, _: AuthDep ) -> Sequence[CartItem]: - if session.get(Cart, id) is None: + cart = session.get(Cart, user_session.id) + + if cart is None or cart.session_id != user_session.id: raise HTTPException(status_code=404, detail="Cart not found") query = select(CartItem).where(CartItem.cart_id == id) @@ -151,8 +227,16 @@ async def get_cart_items( async def get_cart_item( id: UUID, product_id: str, + user_session: UserSessionDep, session: SessionDep, + _: AuthDep, ) -> CartItem: + + cart = session.get(Cart, user_session.id) + + if cart is None or cart.session_id != user_session.id: + raise HTTPException(status_code=404, detail="Cart not found") + cart_item = session.get(CartItem, (id, product_id)) if cart_item is None: @@ -165,10 +249,17 @@ async def get_cart_item( async def update_cart_item_quantity( id: UUID, product_id: str, + user_session: UserSessionDep, quantity: NonNegativeInt, session: SessionDep, _: AuthDep, ): + + cart = session.get(Cart, user_session.id) + + if cart is None or cart.session_id != user_session.id: + raise HTTPException(status_code=404, detail="Cart not found") + cart_item = session.get(CartItem, (id, product_id)) if cart_item is None: @@ -182,7 +273,19 @@ async def update_cart_item_quantity( @router.delete( "/api/carts/{id}/items/{product_id}", status_code=status.HTTP_204_NO_CONTENT ) -async def delete_cart_item(id: UUID, product_id: str, session: SessionDep, _: AuthDep): +async def delete_cart_item( + id: UUID, + product_id: str, + user_session: UserSessionDep, + session: SessionDep, + _: AuthDep, +): + + cart = session.get(Cart, user_session.id) + + if cart is None or cart.session_id != user_session.id: + raise HTTPException(status_code=404, detail="Cart not found") + cart_item = session.get(CartItem, (id, product_id)) if cart_item is None: diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 4502f50..dfcea24 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,11 +1,14 @@ +import datetime as dt import os import secrets import sys from typing import Annotated -from fastapi import Depends, HTTPException +from fastapi import Cookie, Depends, HTTPException, Response from fastapi.security import APIKeyHeader -from sqlmodel import Session, create_engine +from sqlmodel import Session, create_engine, select + +from app.models import User, UserSession if (POSTGRES_URL := os.getenv("POSTGRES_URL")) is None: sys.exit("The POSTGRES_URL env variable must be set.") @@ -28,3 +31,43 @@ def auth_api_key(api_key: Annotated[str, Depends(APIKeyHeader(name="api-key"))]) SessionDep = Annotated[Session, Depends(get_session)] AuthDep = Annotated[str, Depends(auth_api_key)] + + +def get_current_session( + session: SessionDep, + response: Response, + user_session_id: Annotated[str | None, Cookie()] = None, +) -> UserSession: + if user_session_id is None: + user_session = UserSession( + id=secrets.token_urlsafe(), + username=None, + expires_at=dt.datetime.now() + dt.timedelta(days=1), + ) + + session.add(user_session) + session.commit() + + response.set_cookie( + key="session_id", + value=user_session.id, + httponly=True, + samesite="strict", + ) + + return user_session + + user_session = session.get(UserSession, user_session_id) + + if user_session is None: + raise HTTPException(status_code=401, detail="Invalid session") + + if user_session.expires_at > dt.datetime.now(): + session.delete(user_session) + + raise HTTPException(status_code=401, detail="Session expired") + + return user_session + + +UserSessionDep = Annotated[UserSession, Depends(get_current_session)] diff --git a/backend/app/html/routes.py b/backend/app/html/routes.py index f8e5a02..0b00268 100644 --- a/backend/app/html/routes.py +++ b/backend/app/html/routes.py @@ -3,7 +3,7 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, Query, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from jinja2 import Environment, FileSystemLoader from pydantic import EmailStr, NonNegativeInt @@ -47,3 +47,8 @@ async def get_products( return templates.TemplateResponse( request, "products.html", context={"products": products} ) + + +@router.get("/html/login", response_class=HTMLResponse) +async def login(request: Request): + return templates.TemplateResponse(request, "login.html") diff --git a/backend/app/html/templates/header.html b/backend/app/html/templates/header.html index 994f63b..ae181b7 100644 --- a/backend/app/html/templates/header.html +++ b/backend/app/html/templates/header.html @@ -1,11 +1,14 @@ -
+