diff --git a/backend/app/config.py b/backend/app/config.py index 5fb98b9..a899e6e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,5 @@ """Application configuration via pydantic-settings.""" -import os from functools import lru_cache from pathlib import Path @@ -13,18 +12,19 @@ from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, + PublicFormat, 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]: +def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]: """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). + ``public.pem`` and are **not** committed to git. """ private_path = key_dir / "private.pem" public_path = key_dir / "public.pem" @@ -36,11 +36,13 @@ def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[RSAPrivateKey, RSAPem]: # 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() + 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, + PublicFormat.SubjectPublicKeyInfo, ) key_dir.mkdir(parents=True, exist_ok=True) private_path.write_bytes(private_pem) @@ -49,21 +51,6 @@ def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[RSAPrivateKey, RSAPem]: 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" @@ -77,12 +64,12 @@ class Settings(BaseSettings): REDIS_URL: str = "redis://localhost:6379/0" # JWT / Auth - SECRET_KEY: str = "change-me-to-a-secure-random-string" ALGORITHM: str = "RS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 - # RS256 key storage + # RS256 key storage. Defaults to a directory outside the repo so the + # generated keypair is never accidentally committed. RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys") # CORS @@ -108,16 +95,26 @@ class Settings(BaseSettings): return Path(self.RSA_KEY_DIR) @property - def private_pem(self) -> RSAPem: - """Return the private PEM key (loads/generates on first call).""" + def private_pem(self) -> bytes: + """Return the private key PEM bytes (loads/generates on first call).""" private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir) - return RSAPem(private_pem) + return private_pem @property - def public_pem(self) -> RSAPem: - """Return the public PEM key (loads/generates on first call).""" + def public_pem(self) -> bytes: + """Return the public key PEM bytes (loads/generates on first call).""" _, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir) - return RSAPem(public_pem) + return public_pem + + @property + def private_key(self) -> RSAPrivateKey: + """Return the deserialized RSA private key object.""" + return load_pem_private_key(self.private_pem, password=None) + + @property + def public_key(self) -> RSAPublicKey: + """Return the deserialized RSA public key object.""" + return load_pem_public_key(self.public_pem) @lru_cache() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9acf721..1c6ab0d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -70,7 +70,7 @@ def _create_access_token(data: dict) -> str: to_encode.update({"exp": expire}) return jwt.encode( to_encode, - settings.private_key, + settings.private_pem, algorithm=settings.ALGORITHM, ) @@ -84,7 +84,7 @@ def _create_refresh_token(data: dict) -> str: to_encode.update({"exp": expire}) return jwt.encode( to_encode, - settings.private_key, + settings.private_pem, algorithm=settings.ALGORITHM, ) @@ -119,6 +119,20 @@ def calculate_age(birth_date, today=None) -> int: return age +def _issue_refresh_token(user_id: int, db: AsyncSession) -> str: + """Create and persist a refresh token for the given user.""" + 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) + return refresh_token_value + + # --- Endpoints --- @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) @@ -166,15 +180,8 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): # 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) + # Create and persist refresh token + refresh_token_value = _issue_refresh_token(user.id, db) await db.commit() return TokenResponse( @@ -209,15 +216,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = # 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) + # Create and persist refresh token + refresh_token_value = _issue_refresh_token(user.id, db) await db.commit() return TokenResponse(