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:
51
api/app/auth/jwt_provider.py
Normal file
51
api/app/auth/jwt_provider.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
33
api/app/routers/auth.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user