From 70ae9654e0f27acfe315ff1d1f6f883a09083b2e Mon Sep 17 00:00:00 2001 From: Lino Mallevaey Date: Tue, 19 Aug 2025 23:17:06 +0200 Subject: [PATCH] feat: Addition of authentication and user management systems --- app/api/v1/__init__.py | 1 - app/api/v1/endpoints.py | 15 --- app/api/v1/endpoints/__init__.py | 11 ++ app/api/v1/endpoints/auth.py | 68 +++++++++++ app/api/v1/endpoints/user.py | 194 +++++++++++++++++++++++++++++++ app/api/v1/schemas/__init__.py | 8 ++ app/api/v1/schemas/token.py | 21 ++++ app/api/v1/schemas/user.py | 27 +++++ app/core/security.py | 16 ++- 9 files changed, 340 insertions(+), 21 deletions(-) delete mode 100644 app/api/v1/endpoints.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/api/v1/endpoints/user.py create mode 100644 app/api/v1/schemas/__init__.py create mode 100644 app/api/v1/schemas/token.py create mode 100644 app/api/v1/schemas/user.py diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index 483bd36..3ff186d 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -4,7 +4,6 @@ API v1 package for MokPyo application. - Central router for version 1 of the API """ -from fastapi import APIRouter from .endpoints import router as v1_router __version__ = "1.0.0" \ No newline at end of file diff --git a/app/api/v1/endpoints.py b/app/api/v1/endpoints.py deleted file mode 100644 index 98fe8b9..0000000 --- a/app/api/v1/endpoints.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session -from app.core import settings, get_current_user, hash_password, verify_password -from app.db import get_db -from pathlib import Path - -router = APIRouter() - - -# ========================= -# TEST / PING -# ========================= -@router.get("/ping", tags=["Test"]) -def ping(): - return {"message": "pong"} diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..68ccd01 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +router = APIRouter() + +from .user import router as user_router +from .auth import router as auth_router + +router.include_router(user_router, prefix="/users") +router.include_router(auth_router, prefix="/auth") + + \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..b5bbf0e --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Response + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core import settings +from app.core.security import ( + verify_password, + create_access_token, + set_auth_cookie, +) + +from app.db import get_async_db +from app.db.models import User + +from app.api.v1.schemas import TokenRequest, TokenResponse + + +router = APIRouter() + + +# ------------------------ +# Authentication +# ------------------------ + +@router.post( + "/login", + response_model=TokenResponse, + status_code=status.HTTP_200_OK, + tags=["Authentication"] +) +async def get_token(payload: TokenRequest, response: Response, db: Session = Depends(get_async_db)): + result = await db.execute(select(User).where(User.username == payload.username)) + user = result.scalar_one_or_none() + if not user or not verify_password(payload.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid crendentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token( + data = {"sub": str(user.id)} + ) + + set_auth_cookie( + response, + token=access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return {"access_token": access_token} + + +@router.post( + "/logout", + status_code=status.HTTP_200_OK, + tags=["Authentication"], +) +def logout(response: Response) -> dict: + response.delete_cookie( + key="access_token", + path="/", # même chemin que lors du set_cookie + httponly=True, + samesite="lax" if settings.ENV == "dev" else "strict", + secure=settings.USE_SSL, # mettre True en prod sur HTTPS + ) + return {"detail": "Logged out successfully"} \ No newline at end of file diff --git a/app/api/v1/endpoints/user.py b/app/api/v1/endpoints/user.py new file mode 100644 index 0000000..efa9d4b --- /dev/null +++ b/app/api/v1/endpoints/user.py @@ -0,0 +1,194 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Response, Request + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core import settings +from app.core.security import ( + hash_password, + get_token_payload, + get_current_user, + get_cookie_token, + verify_password, + create_access_token, + set_auth_cookie, +) + +from app.db import get_async_db +from app.db.models import User + +from app.api.v1.schemas import UserCreate, UserRead + + +router = APIRouter() + + +# ------------------------ +# User +# ------------------------ + +@router.post( + "/new", + response_model=UserRead, + status_code=status.HTTP_201_CREATED, + tags=["Users"] +) +async def register_user(user_in: UserCreate, response: Response, db: Session = Depends(get_async_db)): + + result = await db.execute(select(User).where(User.email == user_in.email)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + result = await db.execute(select(User).where(User.username == user_in.username)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + user = User( + username=user_in.username, + email=user_in.email, + hashed_password=hash_password(user_in.password), + + stats = "{}" + ) + db.add(user) + await db.commit() + await db.refresh(user) + + access_token = create_access_token( + data = {"sub": str(user.id)} + ) + + set_auth_cookie( + response, + token=access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return UserRead.model_validate(user) + + +@router.get( + "/me", + response_model=UserRead, + status_code=status.HTTP_200_OK, + tags=["Users"] +) +async def get_user(payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)): + user_id = payload.get('sub') + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserRead.model_validate(user) + + +@router.put( + "/me", + response_model=UserRead, + status_code=status.HTTP_200_OK, + tags=["Users"] +) +async def update_user(user_in: UserCreate, payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)): + user_id = payload.get('sub') + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if user_in.email != user.email: + result = await db.execute(select(User).where( + (User.email == user_in.email) + )) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email or username already registered" + ) + + if user_in.username != user.username: + result = await db.execute(select(User).where( + (User.username == user_in.username) + )) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email or username already registered" + ) + + user.username = user_in.username + user.email = user_in.email + user.hashed_password = hash_password(user_in.password) + + await db.commit() + await db.refresh(user) + + return UserRead.model_validate(user) + + +@router.delete( + "/me", + status_code=status.HTTP_200_OK, + tags=['Users'] +) +async def delete_user(payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)): + user_id = payload.get('sub') + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + await db.delete(user) + await db.commit() + + response = Response() + response.delete_cookie( + key="access_token", + path="/", # mesmo chemin que lors du set_cookie + httponly=True, + samesite="lax" if settings.ENV == "dev" else "strict", + secure=settings.USE_SSL, # mettre True en prod sur HTTPS + ) + + return response + + + + + + diff --git a/app/api/v1/schemas/__init__.py b/app/api/v1/schemas/__init__.py new file mode 100644 index 0000000..072436d --- /dev/null +++ b/app/api/v1/schemas/__init__.py @@ -0,0 +1,8 @@ +""" +API v1 schemas package for MokPyo application. + +- Central access point for v1 API schemas +""" + +from .user import UserCreate, UserRead +from .token import TokenRequest, TokenResponse \ No newline at end of file diff --git a/app/api/v1/schemas/token.py b/app/api/v1/schemas/token.py new file mode 100644 index 0000000..1ea3cf2 --- /dev/null +++ b/app/api/v1/schemas/token.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field, field_validator + + +# ---------------------------------------------------------------------- +# Token +# ---------------------------------------------------------------------- +class TokenRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=30) + password: str + + @field_validator("username") + def all_allowed_chars(cls, v): + for v_char in v: + if not v_char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_": + raise ValueError("username can only contain a-z, A-Z, 0-9 and _") + return v + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" \ No newline at end of file diff --git a/app/api/v1/schemas/user.py b/app/api/v1/schemas/user.py new file mode 100644 index 0000000..d9aa882 --- /dev/null +++ b/app/api/v1/schemas/user.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field, EmailStr, field_validator + + +# ---------------------------------------------------------------------- +# User +# ---------------------------------------------------------------------- +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=30) + email: EmailStr + password: str = Field(..., min_length=8) + + @field_validator("username") + def all_allowed_chars(cls, v): + for v_char in v: + if not v_char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_": + raise ValueError("username can only contain a-z, A-Z, 0-9 and _") + return v + + +class UserRead(BaseModel): + id: int + username: str + email: EmailStr + stats: str + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py index 5e7d953..6eed993 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -25,12 +25,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: # ========================= # JWT TOKEN MANAGEMENT # ========================= -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: """Create a JWT access token.""" to_encode = data.copy() - expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt @@ -46,7 +46,7 @@ def decode_access_token(token: str) -> dict: # ========================= # DEPENDENCIES FASTAPI # ========================= -async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: +def get_current_user(token: str) -> dict: """Get current user from JWT token.""" payload = decode_access_token(token) user_id: Any = payload.get("sub") @@ -54,6 +54,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") return payload +async def get_token_payload(request: Request) -> dict: + token = get_cookie_token(request) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return get_current_user(token) + # ========================= # COOKIE MANAGEMENT (optionnel) # ========================= @@ -64,8 +70,8 @@ def set_auth_cookie(response: Response, token: str, max_age: int = 3600): value=f"Bearer {token}", httponly=True, max_age=max_age, - samesite="lax", - secure=False, # True si HTTPS + samesite="lax" if settings.ENV == "dev" else "strict", + secure=settings.USE_SSL, # True si HTTPS ) def get_cookie_token(request: Request) -> Optional[str]: