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)

View File

@@ -16,6 +16,7 @@ dependencies = [
"pydantic-settings>=2.2",
"python-multipart>=0.0.9",
"pillow>=10.0",
"PyJWT>=2.8",
]
[project.optional-dependencies]
@@ -32,7 +33,10 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = []
ignore = [
"B008", # FastAPI Depends/File/Form in function signatures — intentional
"B904", # raise-without-from inside except — HTTPException re-raise pattern
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
@@ -43,3 +47,11 @@ testpaths = ["tests"]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[dependency-groups]
dev = [
"anyio>=4.13.0",
"httpx>=0.28.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]

View File

@@ -1,12 +1,26 @@
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
# Provide required settings for the test environment before any app imports resolve them
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.main import app
from app.config import get_settings
from app.database import Base
from app.dependencies import get_db, get_storage, get_auth
from app.auth.jwt_provider import JWTAuthProvider
# Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear()
_TEST_JWT_SECRET = os.environ["JWT_SECRET_KEY"]
_TEST_OWNER_USERNAME = os.environ["OWNER_USERNAME"]
_TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
@pytest_asyncio.fixture(scope="session", loop_scope="session")
@@ -57,3 +71,40 @@ async def client(db_session):
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def jwt_auth_provider() -> JWTAuthProvider:
return JWTAuthProvider(
secret_key=_TEST_JWT_SECRET,
expiry_seconds=3600,
owner_username=_TEST_OWNER_USERNAME,
owner_password=_TEST_OWNER_PASSWORD,
)
@pytest_asyncio.fixture
async def authed_client(db_session, jwt_auth_provider):
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend()
auth = jwt_auth_provider
async def override_db():
yield db_session
def override_storage():
return storage
def override_auth():
return auth
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_storage] = override_storage
app.dependency_overrides[get_auth] = override_auth
valid_token = auth.create_token()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c, valid_token
app.dependency_overrides.clear()

View File

@@ -0,0 +1,51 @@
import pytest
_VALID_CREDS = {"username": "testowner", "password": "testpassword"}
@pytest.mark.asyncio
async def test_login_success(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json=_VALID_CREDS)
assert response.status_code == 200
body = response.json()
assert isinstance(body.get("access_token"), str)
assert len(body["access_token"]) > 0
assert body.get("token_type") == "bearer"
assert body.get("expires_in", 0) > 0
@pytest.mark.asyncio
async def test_login_wrong_password(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "testowner", "password": "wrongpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_wrong_username(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "notowner", "password": "testpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_missing_password(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"username": "testowner"})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_missing_username(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"password": "testpassword"})
assert response.status_code == 422

View File

@@ -0,0 +1,95 @@
"""
Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import uuid
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x02"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_upload_without_token_returns_401(authed_client):
client, _ = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_upload_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (200, 201)
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/images/{fake_id}")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_delete_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.delete(
f"/api/v1/images/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/images/{fake_id}/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_patch_tags_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200

View File

@@ -0,0 +1,71 @@
"""
US3 regression tests: all read endpoints must remain accessible without a token
even after require_auth is applied to write endpoints.
"""
import io
import uuid
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x03"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_list_images_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/images")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_image_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_file_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_thumbnail_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_list_tags_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/tags")
assert response.status_code == 200

View File

@@ -2,14 +2,27 @@ import os
import pytest
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
"S3_BUCKET_NAME": "test-bucket",
"S3_ACCESS_KEY_ID": "key",
"S3_SECRET_ACCESS_KEY": "secret",
"S3_REGION": "us-east-1",
"API_BASE_URL": "http://localhost:8000",
"JWT_SECRET_KEY": "test-secret",
"OWNER_USERNAME": "admin",
"OWNER_PASSWORD": "password",
}
def _apply_env(monkeypatch, extra=None):
for k, v in {**_BASE_ENV, **(extra or {})}.items():
monkeypatch.setenv(k, v)
def test_settings_load_from_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
_apply_env(monkeypatch)
# Import inside test to pick up monkeypatched env
import importlib
@@ -20,17 +33,13 @@ def test_settings_load_from_env(monkeypatch):
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
assert s.s3_bucket_name == "test-bucket"
assert s.max_upload_bytes == 52428800 # default
assert s.jwt_secret_key == "test-secret"
assert s.jwt_expiry_seconds == 86400 # default
assert s.owner_username == "admin"
def test_settings_max_upload_bytes_override(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
import importlib
import app.config as config_module
@@ -38,3 +47,14 @@ def test_settings_max_upload_bytes_override(monkeypatch):
s = config_module.Settings()
assert s.max_upload_bytes == 10485760
def test_settings_jwt_expiry_override(monkeypatch):
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.jwt_expiry_seconds == 3600

View File

@@ -0,0 +1,97 @@
import time
import pytest
import jwt as pyjwt
from fastapi import HTTPException
from app.auth.jwt_provider import JWTAuthProvider
SECRET = "test-secret-key"
USERNAME = "owner"
PASSWORD = "hunter2"
def make_provider(**kwargs) -> JWTAuthProvider:
defaults = dict(
secret_key=SECRET,
expiry_seconds=3600,
owner_username=USERNAME,
owner_password=PASSWORD,
)
return JWTAuthProvider(**{**defaults, **kwargs})
def test_create_token_is_valid_jwt():
provider = make_provider()
token = provider.create_token()
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
assert payload["sub"] == "owner"
assert "iat" in payload
assert "exp" in payload
@pytest.mark.asyncio
async def test_get_identity_returns_owner():
provider = make_provider()
token = provider.create_token()
identity = await provider.get_identity(f"Bearer {token}")
assert identity.id == "owner"
assert identity.anonymous is False
@pytest.mark.asyncio
async def test_get_identity_raises_on_expired_token():
provider = make_provider(expiry_seconds=-1)
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_wrong_key():
provider = make_provider()
other = make_provider(secret_key="different-secret")
token = other.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_garbage():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity("Bearer not.a.real.token")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_header():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(None)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_bearer_prefix():
provider = make_provider()
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(token)
assert exc_info.value.status_code == 401
def test_verify_credentials_true():
provider = make_provider()
assert provider.verify_credentials(USERNAME, PASSWORD) is True
def test_verify_credentials_false_wrong_password():
provider = make_provider()
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
def test_verify_credentials_false_wrong_username():
provider = make_provider()
assert provider.verify_credentials("notowner", PASSWORD) is False

1594
api/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff