WIP checkpoint: Upgrade auth to RS256 signing, age-verification field, refresh-token sessions

This commit is contained in:
2026-08-19 13:46:06 +00:00
parent ffbc4849a7
commit 98bbe67406
5 changed files with 200 additions and 15 deletions
+67 -11
View File
@@ -1,20 +1,21 @@
"""Authentication router - login, register, token refresh."""
from datetime import date, datetime, timedelta, timezone
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
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
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -25,7 +26,9 @@ router = APIRouter()
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
access_token_expires_minutes: int
class RegisterRequest(BaseModel):
@@ -59,12 +62,31 @@ class UserResponse(BaseModel):
# --- 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.SECRET_KEY, algorithm=settings.ALGORITHM)
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:
@@ -77,7 +99,7 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def calculate_age(birth_date: date, today: date | None = None) -> int:
def calculate_age(birth_date, today=None) -> int:
"""Calculate age in years from a birth date.
Args:
@@ -101,7 +123,7 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
@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 an access token.
"""Register a new user and return access + refresh tokens.
Requires date_of_birth and rejects users under 18.
"""
@@ -141,13 +163,30 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await db.commit()
await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# 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 an access token.
"""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.
@@ -167,8 +206,25 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
detail="User account is inactive",
)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
# 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)