Files
NixReaperandClaude Sonnet 5 7bee596a0e
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
Add user accounts: registration, login, and JWT auth
The first authenticated-user concept in Aurora Core -- everything else in
this codebase is public, read-only catalog data. Foundation for ratings
and a watchlist (not yet built): POST /auth/register, POST /auth/login,
GET /auth/me.

Security specifics worth knowing:
- Passwords hashed with argon2 (via argon2-cffi), never stored plain or
  logged.
- Login returns an identical 401 for a nonexistent email and a wrong
  password on a real one, including matching response timing
  (burn_time_like_a_password_check) -- otherwise either the status code
  or the timing alone lets a client enumerate registered emails.
- AURORA_SECRET_KEY now actually signs JWTs (previously defined but
  unused anywhere) -- .env.example's existing token_urlsafe(48) guidance
  already produces a long enough key, just flagged its new stakes.
- Email matched case-insensitively via explicit normalization in the
  service layer, not relied on from Pydantic's EmailStr (which does not
  lowercase the local part).

12 new integration tests (register/login/me over real HTTP via the
`client` fixture) plus 10 unit tests for the hashing/JWT logic itself,
following the same-day lesson that only unit tests actually execute
locally without Postgres -- the auth core is tested for real here rather
than only through integration tests that might not run before a deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 06:15:39 -05:00

70 lines
2.2 KiB
Python

"""Shared FastAPI dependencies and helpers for the v1 API."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import InvalidTokenError, decode_access_token
from app.core.database import get_db
from app.models.user import User
from app.repositories.user_repository import get_user_by_id
from app.services.cache import cache_get_json, cache_set_json
_bearer_scheme = HTTPBearer(auto_error=False)
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "not_authenticated", "message": "Missing or invalid access token."},
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
session: AsyncSession = Depends(get_db),
) -> User:
if credentials is None:
raise _unauthorized()
try:
user_id = decode_access_token(credentials.credentials)
except InvalidTokenError as exc:
raise _unauthorized() from exc
user = await get_user_by_id(session, user_id)
if user is None or not user.is_active:
raise _unauthorized()
return user
def not_found(entity_type: str, aurora_id_str: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": f"{entity_type}_not_found", "message": f"No such {entity_type}: {aurora_id_str}"},
)
async def cached_response[T: BaseModel](
redis: Redis,
key: str,
model: type[T],
loader: Callable[[], Awaitable[T]],
ttl: int | None = None,
) -> T:
"""Serve `model` from Redis if present, otherwise call `loader`, cache
the result, and return it. Fails open to `loader` on any cache error."""
cached = await cache_get_json(redis, key)
if cached is not None:
return model.model_validate(cached)
result = await loader()
await cache_set_json(redis, key, result.model_dump(mode="json"), ttl=ttl)
return result