diff --git a/backend/alembic/versions/0003_add_refresh_tokens_table.py b/backend/alembic/versions/0003_add_refresh_tokens_table.py new file mode 100644 index 0000000..e390dd6 --- /dev/null +++ b/backend/alembic/versions/0003_add_refresh_tokens_table.py @@ -0,0 +1,36 @@ +"""add refresh_tokens table + +Revision ID: 0003_add_refresh_tokens_table +Revises: 0002_add_date_of_birth_to_users +Create Date: 2025-07-25 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0003_add_refresh_tokens_table" +down_revision: Union[str, None] = "0002_add_date_of_birth_to_users" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the refresh_tokens table.""" + op.create_table( + "refresh_tokens", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token", sa.String(512), unique=True, index=True, nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + """Drop the refresh_tokens table.""" + op.drop_table("refresh_tokens") diff --git a/backend/app/config.py b/backend/app/config.py index 19d89e4..5fb98b9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 7a7d33a..4b8d3ea 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8ce7f88..e2197e7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 28f5848..9acf721 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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)