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,187 @@
# Data Model: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
---
## Database Changes
**None.** JWTs are stateless bearer tokens. The API validates them by
cryptographic signature and embedded expiry claim on each request. No token
storage, session table, or blocklist is introduced in Phase 2.
---
## Configuration Schema (new env vars)
Four new environment variables are added to `api/app/config.py` and
`.env.example`.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
| `JWT_SECRET_KEY` | `str` | Yes | — | HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent). |
| `JWT_EXPIRY_SECONDS` | `int` | No | `86400` | Token lifetime in seconds (24 h). |
| `OWNER_USERNAME` | `str` | Yes | — | Login username for the single owner account. |
| `OWNER_PASSWORD` | `str` | Yes | — | Login password for the single owner account. |
These values are loaded via `pydantic-settings` (`BaseSettings`) alongside the
existing database and S3 settings. `JWT_SECRET_KEY`, `OWNER_USERNAME`, and
`OWNER_PASSWORD` have no defaults and will raise a validation error at startup
if absent, providing a clear "misconfigured" failure rather than a silent
security hole.
---
## Token Structure
A JWT issued by the login endpoint carries the following claims.
| Claim | Type | Value |
|---|---|---|
| `sub` | string | `"owner"` — fixed identifier for the single owner |
| `iat` | integer | Unix epoch seconds at time of issuance |
| `exp` | integer | `iat + JWT_EXPIRY_SECONDS` |
Algorithm: `HS256` (HMAC-SHA256). Secret: `JWT_SECRET_KEY` setting.
The token is opaque to the client. The Angular SPA stores it in
`sessionStorage` and transmits it as `Authorization: Bearer <token>` on every
request.
---
## Module and Interface Changes
### `api/app/auth/provider.py` — updated interface
The `get_identity()` method gains an `authorization` parameter — the raw value
of the `Authorization` HTTP header (or `None` if the header is absent).
```
AuthProvider (abstract)
get_identity(authorization: str | None) -> Identity
Identity (dataclass)
id: str
anonymous: bool = True
```
### `api/app/auth/noop.py` — no behavioural change
`NoOpAuthProvider.get_identity()` continues to return the static anonymous
identity regardless of the `authorization` argument. The signature is updated
to match the new interface.
### `api/app/auth/jwt_provider.py` — new module
```
JWTAuthProvider (AuthProvider)
__init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
get_identity(authorization: str | None) -> Identity
- Parses "Bearer <token>" from authorization header
- Decodes and validates the JWT (signature + exp)
- Returns Identity(id="owner", anonymous=False) on success
- Raises HTTPException 401 with code "unauthorized" on any failure
create_token() -> str
- Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
- Returns the encoded token string
verify_credentials(username: str, password: str) -> bool
- Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
- Uses secrets.compare_digest to prevent timing attacks
- Returns True on match, False otherwise
```
### `api/app/dependencies.py` — new `require_auth` dependency
```
require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth)
) -> Identity
- Calls auth.get_identity(authorization)
- Raises HTTPException 401 if identity.anonymous is True
- Returns the Identity on success
```
Protected routes inject `identity: Identity = Depends(require_auth)` and do
not need to perform any additional auth checks — the dependency raises before
the route body executes if authentication fails.
### `api/app/routers/auth.py` — new router
```
POST /api/v1/auth/token
Request body: LoginRequest { username: str, password: str }
Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }
```
---
## Angular Module Changes
### `ui/src/app/auth/auth.service.ts` — new service
```
AuthService
TOKEN_KEY = 'auth_token' (sessionStorage key)
login(username: string, password: string): Observable<void>
- POST /api/v1/auth/token
- On success: stores access_token in sessionStorage, emits completion
- On 401: propagates error for the component to handle
logout(): void
- Removes token from sessionStorage
getToken(): string | null
- Returns stored token or null
isAuthenticated(): boolean
- Returns true if getToken() is non-null
```
### `ui/src/app/auth/auth.interceptor.ts` — new functional interceptor
Attaches `Authorization: Bearer <token>` to every outbound `HttpRequest` if
`AuthService.getToken()` returns a non-null value. Requests without a token
are passed through unmodified.
### `ui/src/app/auth/auth.guard.ts` — new route guard
Functional `CanActivateFn`. If `AuthService.isAuthenticated()` is `false`,
redirects to `/login?returnUrl=<current-url>`. Otherwise allows navigation.
### `ui/src/app/login/login.component.ts` — new component
Route: `/login`
```
LoginComponent
Fields: username (required), password (required)
On submit:
- Calls AuthService.login()
- On success: navigates to returnUrl query param, or '/' if absent
- On 401: displays inline error "Invalid username or password"
- On other error: displays generic error message
Shows loading state while request is in flight
```
### `ui/src/app/detail/detail.component.ts` — updated
Injects `AuthService`. Hides tag-edit input and delete button when
`auth.isAuthenticated()` is `false`. Shows them when authenticated.
Read-only view (image display, tag chips) is always visible.
### `ui/src/app/app.routes.ts` — updated
`/upload` route gains `canActivate: [authGuard]`. `/login` route is added
(unguarded). All other routes are unchanged.
### `ui/src/app/app.config.ts` — updated
`provideHttpClient()` becomes
`provideHttpClient(withInterceptors([authInterceptor]))`.