Feat: Implement JWT bearer token authentication

Protects image upload, delete, and tag-update endpoints behind
Bearer token auth. Public read endpoints remain open. Angular SPA
gains a login page, auth interceptor, and route guard for /upload.

- JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest)
- POST /api/v1/auth/token login endpoint
- require_auth FastAPI dependency on all write routes
- AuthService, LoginComponent, authInterceptor, authGuard
- Detail page hides write controls for unauthenticated visitors
- 43 unit tests passing; integration tests require Docker stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:12:38 +00:00
parent d91a65abe5
commit 5fbbc1e67f
36 changed files with 3998 additions and 42 deletions

View File

@@ -0,0 +1,51 @@
import secrets
from datetime import UTC, datetime, timedelta
import jwt
from fastapi import HTTPException
from app.auth.provider import AuthProvider, Identity
_UNAUTHORIZED = HTTPException(
status_code=401, detail={"detail": "Unauthorized", "code": "unauthorized"}
)
class JWTAuthProvider(AuthProvider):
def __init__(
self,
secret_key: str,
expiry_seconds: int,
owner_username: str,
owner_password: str,
) -> None:
self._secret_key = secret_key
self._expiry_seconds = expiry_seconds
self._owner_username = owner_username
self._owner_password = owner_password
def create_token(self) -> str:
now = datetime.now(tz=UTC)
payload = {
"sub": "owner",
"iat": now,
"exp": now + timedelta(seconds=self._expiry_seconds),
}
return jwt.encode(payload, self._secret_key, algorithm="HS256")
def verify_credentials(self, username: str, password: str) -> bool:
username_ok = secrets.compare_digest(username, self._owner_username)
password_ok = secrets.compare_digest(password, self._owner_password)
return username_ok and password_ok
async def get_identity(self, authorization: str | None) -> Identity:
if not authorization or not authorization.startswith("Bearer "):
raise _UNAUTHORIZED
token = authorization.removeprefix("Bearer ")
try:
jwt.decode(token, self._secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise _UNAUTHORIZED from None
except jwt.InvalidTokenError:
raise _UNAUTHORIZED from None
return Identity(id="owner", anonymous=False)

View File

@@ -4,5 +4,5 @@ _ANONYMOUS = Identity(id="anonymous", anonymous=True)
class NoOpAuthProvider(AuthProvider):
async def get_identity(self) -> Identity:
async def get_identity(self, authorization: str | None) -> Identity:
return _ANONYMOUS

View File

@@ -10,5 +10,5 @@ class Identity:
class AuthProvider(ABC):
@abstractmethod
async def get_identity(self) -> Identity:
"""Resolve the request identity."""
async def get_identity(self, authorization: str | None) -> Identity:
"""Resolve the request identity from the Authorization header value."""

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -13,6 +14,10 @@ class Settings(BaseSettings):
s3_region: str = "us-east-1"
api_base_url: str = "http://localhost:8000"
max_upload_bytes: int = 52_428_800 # 50 MiB
jwt_secret_key: str
jwt_expiry_seconds: int = 86400
owner_username: str
owner_password: str
@lru_cache

View File

@@ -1,10 +1,10 @@
from typing import AsyncGenerator
from collections.abc import AsyncGenerator
from fastapi import Depends
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.noop import NoOpAuthProvider
from app.auth.provider import AuthProvider
from app.auth.jwt_provider import JWTAuthProvider
from app.auth.provider import AuthProvider, Identity
from app.database import get_session_factory
from app.storage.backend import StorageBackend
from app.storage.s3_backend import S3StorageBackend
@@ -23,12 +23,38 @@ def get_storage() -> StorageBackend:
def get_auth() -> AuthProvider:
global _auth
if _auth is None:
_auth = NoOpAuthProvider()
from app.config import get_settings
s = get_settings()
_auth = JWTAuthProvider(
secret_key=s.jwt_secret_key,
expiry_seconds=s.jwt_expiry_seconds,
owner_username=s.owner_username,
owner_password=s.owner_password,
)
return _auth
def get_jwt_auth() -> JWTAuthProvider:
auth = get_auth()
assert isinstance(auth, JWTAuthProvider)
return auth
async def require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth),
) -> Identity:
identity = await auth.get_identity(authorization)
if identity.anonymous:
raise HTTPException(
status_code=401,
detail={"detail": "Authentication required", "code": "unauthorized"},
)
return identity
async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory()
async with factory() as session:
async with session.begin():
yield session
async with factory() as session, session.begin():
yield session

View File

@@ -5,12 +5,12 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from app.config import get_settings
from app.database import get_engine, get_session_factory, Base
from app.database import Base, get_engine
@asynccontextmanager
async def lifespan(application: FastAPI):
settings = get_settings()
get_settings()
# Verify DB connection and run migrations on startup
engine = get_engine()
async with engine.begin() as conn:
@@ -36,7 +36,8 @@ async def health():
# Routers registered after all modules are defined to avoid circular imports
from app.routers import images, tags # noqa: E402
from app.routers import auth, images, tags # noqa: E402
app.include_router(auth.router, prefix="/api/v1")
app.include_router(images.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")

33
api/app/routers/auth.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.auth.jwt_provider import JWTAuthProvider
from app.dependencies import get_jwt_auth
router = APIRouter(tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
@router.post("/auth/token", response_model=TokenResponse)
async def login(body: LoginRequest, auth: JWTAuthProvider = Depends(get_jwt_auth)):
if not auth.verify_credentials(body.username, body.password):
raise HTTPException(
status_code=401,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
token = auth.create_token()
return TokenResponse(
access_token=token,
token_type="bearer",
expires_in=auth._expiry_seconds,
)

View File

@@ -7,9 +7,9 @@ from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.provider import AuthProvider
from app.auth.provider import Identity
from app.config import get_settings
from app.dependencies import get_auth, get_db, get_storage
from app.dependencies import get_db, get_storage, require_auth
from app.models import Image
from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
@@ -109,7 +109,7 @@ async def upload_image(
tags: str | None = Form(None),
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
auth: AuthProvider = Depends(get_auth),
_: Identity = Depends(require_auth),
settings=Depends(get_settings),
):
data = await file.read()
@@ -163,7 +163,9 @@ async def upload_image(
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{hash_hex}-thumb"
except Exception:
logger.warning("Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex)
logger.warning(
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
)
image = await image_repo.create(
hash_hex=hash_hex,
@@ -285,6 +287,7 @@ async def update_image_tags(
image_id: uuid.UUID,
body: dict,
db: AsyncSession = Depends(get_db),
_: Identity = Depends(require_auth),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
@@ -314,6 +317,7 @@ async def delete_image(
image_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
_: Identity = Depends(require_auth),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)