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:
187
specs/004-jwt-bearer-auth/data-model.md
Normal file
187
specs/004-jwt-bearer-auth/data-model.md
Normal 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]))`.
|
||||
Reference in New Issue
Block a user