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
+81 -4
View File
@@ -1,10 +1,69 @@
"""Application configuration via pydantic-settings."""
import os
from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[RSAPrivateKey, RSAPem]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git (see .gitignore).
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
__import__("cryptography.hazmat.primitives.serialization").hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class RSAPem:
"""Thin wrapper around raw PEM bytes."""
def __init__(self, data: bytes):
self._data = data
@property
def private_key(self) -> RSAPrivateKey:
return load_pem_private_key(self._data, password=None)
@property
def public_key(self) -> RSAPublicKey:
return load_pem_public_key(self._data)
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
@@ -18,11 +77,13 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "RS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# RS256 key storage
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
@@ -42,6 +103,22 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> RSAPem:
"""Return the private PEM key (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return RSAPem(private_pem)
@property
def public_pem(self) -> RSAPem:
"""Return the public PEM key (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return RSAPem(public_pem)
@lru_cache()
def get_settings() -> Settings:
+15
View File
@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -18,3 +19,17 @@ class Session(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token = Column(String(512), unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="refresh_tokens")
+1
View File
@@ -21,6 +21,7 @@ class User(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
+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)