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>
188 lines
6.1 KiB
Markdown
188 lines
6.1 KiB
Markdown
# 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]))`.
|