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

@@ -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)