Files
modbison/backend/app/routers/auth.py
T

234 lines
7.0 KiB
Python

"""Authentication router - login, register, token refresh."""
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
# --- Pydantic schemas ---
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
access_token_expires_minutes: int
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Helpers ---
def _create_access_token(data: dict) -> str:
"""Create a signed access token using RS256."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_key,
algorithm=settings.ALGORITHM,
)
def _create_refresh_token(data: dict) -> str:
"""Create a signed refresh token using RS256."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_key,
algorithm=settings.ALGORITHM,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def calculate_age(birth_date, today=None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already registered",
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == req.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
user = User(
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
)
db.add(user)
await db.commit()
await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
# Create refresh token and persist it
refresh_token_value = _create_refresh_token({"sub": str(user.id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return access + refresh tokens.
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
"""
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
# Create refresh token and persist it
refresh_token_value = _create_refresh_token({"sub": str(user.id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
@router.get("/me", response_model=UserResponse)
async def get_current_user(current_user: CurrentUser):
"""Get current user profile from the JWT token."""
return current_user