Compare commits

..

3 Commits

Author SHA1 Message Date
Lino Mallevaey
70ae9654e0 feat: Addition of authentication and user management systems 2025-08-19 23:17:06 +02:00
Lino Mallevaey
954f73a0f5 Improved support for authentication cookies 2025-08-19 18:20:37 +02:00
Lino Mallevaey
cdbd58905d feat: Added SSL support for authentication cookies 2025-08-19 17:53:41 +02:00
12 changed files with 359 additions and 22 deletions

View File

@@ -28,7 +28,7 @@ DEBUG=True
APP_TITLE=MokPyo APP_TITLE=MokPyo
APP_VERSION=1.0.0 APP_VERSION=1.0.0
APP_DESCRIPTION=MokPyo APP_DESCRIPTION=MokPyo
APP_DOMAIN=localhost APP_DOMAIN=localhost # change in production with your domain (ex: mokpyo.com)
# ========================= # =========================
# SERVER CONFIGURATION # SERVER CONFIGURATION
@@ -49,6 +49,10 @@ ACCESS_TOKEN_EXPIRE_MINUTES=30
# Algorithme de chiffrement JWT # Algorithme de chiffrement JWT
ALGORITHM=HS256 ALGORITHM=HS256
# Use SSL (True/False)
# You need to set USE_SSL=True if you use HTTPS
USE_SSL=False
# ========================= # =========================
# EMAIL / VALIDATION # EMAIL / VALIDATION
# ========================= # =========================

View File

@@ -4,7 +4,6 @@ API v1 package for MokPyo application.
- Central router for version 1 of the API - Central router for version 1 of the API
""" """
from fastapi import APIRouter
from .endpoints import router as v1_router from .endpoints import router as v1_router
__version__ = "1.0.0" __version__ = "1.0.0"

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -39,6 +39,7 @@ class Settings(BaseSettings):
SECRET_KEY: str SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
USE_SSL: bool = False
# ========================= # =========================
# EMAIL (optionnel) # EMAIL (optionnel)
@@ -69,6 +70,10 @@ class Settings(BaseSettings):
def database_url(self) -> str: def database_url(self) -> str:
return f"mysql+<driver>://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}" return f"mysql+<driver>://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
@property
def app_url(self) -> str:
return f"{'https' if settings.USE_SSL else 'http'}://{settings.APP_DOMAIN}{':' + str(settings.PORT) if (settings.PORT != 80 and settings.ENV == 'dev') else ''}"
@property @property
def access_token_expire(self) -> timedelta: def access_token_expire(self) -> timedelta:
return timedelta(minutes=self.ACCESS_TOKEN_EXPIRE_MINUTES) return timedelta(minutes=self.ACCESS_TOKEN_EXPIRE_MINUTES)

View File

@@ -25,12 +25,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
# ========================= # =========================
# JWT TOKEN MANAGEMENT # 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: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token.""" """Create a JWT access token."""
to_encode = data.copy() 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}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt
@@ -46,7 +46,7 @@ def decode_access_token(token: str) -> dict:
# ========================= # =========================
# DEPENDENCIES FASTAPI # 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.""" """Get current user from JWT token."""
payload = decode_access_token(token) payload = decode_access_token(token)
user_id: Any = payload.get("sub") 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
return 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) # COOKIE MANAGEMENT (optionnel)
# ========================= # =========================
@@ -64,8 +70,8 @@ def set_auth_cookie(response: Response, token: str, max_age: int = 3600):
value=f"Bearer {token}", value=f"Bearer {token}",
httponly=True, httponly=True,
max_age=max_age, max_age=max_age,
samesite="lax", samesite="lax" if settings.ENV == "dev" else "strict",
secure=False, # True si HTTPS secure=settings.USE_SSL, # True si HTTPS
) )
def get_cookie_token(request: Request) -> Optional[str]: def get_cookie_token(request: Request) -> Optional[str]:

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import ORJSONResponse, JSONResponse, FileResponse from fastapi.responses import ORJSONResponse, JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
@@ -22,6 +23,14 @@ app = FastAPI(
redoc_url=None if settings.ENV == "prod" else "/redoc", redoc_url=None if settings.ENV == "prod" else "/redoc",
) )
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.app_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========================= # =========================
# Static files # Static files
# ========================= # =========================