From 3e932e38f9586ec8edbe263414ae8c95ff39b386 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Fri, 5 Jun 2026 07:58:28 -0400 Subject: [PATCH] chore: doc updates Signed-off-by: Alan Brault --- .gitignore | 29 ++++ README.md | 18 +++ docs/authentication-and-security.md | 202 ++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 docs/authentication-and-security.md diff --git a/.gitignore b/.gitignore index 9940179..7298c41 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,32 @@ out/ ### Environment Variables ### .env + +### macOS ### +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +### Windows ### +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +Desktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +### Linux ### +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* diff --git a/README.md b/README.md index 5628dbd..a91907e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A reactive REST API built with **Kotlin**, **Spring Boot WebFlux**, and **MongoD - [Run with Docker](#run-with-docker) - [API Reference](#-api-reference) - [Project Structure](#-project-structure) +- [Documentation](#-documentation) - [Development](#-development) --- @@ -86,6 +87,17 @@ openssl rand -base64 32 | tr -d '\n' The API starts on **http://localhost:8080**. +#### Seed Users + +On startup, two users are automatically created if they don't already exist: + +| Email | Password | Role | +|---|---|---| +| `user@example.com` | `user12345` | `ROLE_USER` | +| `admin@example.com` | `admin12345` | `ROLE_ADMIN` | + +Use these credentials with `POST /v1/auth/login` to get started quickly. + ### Run with Docker ```bash @@ -148,6 +160,12 @@ src/main/kotlin/io/visus/demos/kotlinapi/ --- +## 📚 Documentation + +- [Authentication & Security](docs/authentication-and-security.md) — JWE tokens, refresh rotation, theft detection, RBAC, and configuration + +--- + ## 🧑‍💻 Development ```bash diff --git a/docs/authentication-and-security.md b/docs/authentication-and-security.md new file mode 100644 index 0000000..3717b24 --- /dev/null +++ b/docs/authentication-and-security.md @@ -0,0 +1,202 @@ +# Authentication & Security + +This document covers how the API handles authentication, token lifecycle, and access control. + +--- + +## Overview + +The API uses **JWE (JSON Web Encryption)** tokens — AES-256-GCM encrypted — rather than plain signed JWTs. Clients cannot inspect token claims. The flow is stateless: no server-side sessions. All state is carried in tokens or stored in MongoDB. + +Two token types are in play: + +| Token | Lifetime | Purpose | +|---|---|---| +| Access token | 1 hour | Authenticate API requests via `Authorization: Bearer` | +| Refresh token | 30 days | Exchange for a new access token without re-logging in | + +--- + +## Token Structure + +Both token types share a base structure but carry a `type` claim (`"access"` or `"refresh"`) that prevents cross-use — the API rejects a refresh token presented as a Bearer token and vice versa. + +**Access token claims:** + +| Claim | Value | +|---|---| +| `sub` | User email | +| `type` | `"access"` | +| `authorities` | Array of roles, e.g. `["ROLE_USER"]` | +| `jti` | UUIDv7 — unique token ID used for revocation lookup | +| `iat` / `exp` / `iss` | Standard JWT timestamps and issuer | + +**Refresh token claims:** `sub`, `type`, `jti`, `iat`, `exp`, `iss` (no `authorities`). + +**Encryption:** `JWE` with `dir` key management and `A256GCM` content encryption, keyed from `JWT_ENCRYPTION_SECRET` (Base64-encoded 32-byte AES key). + +--- + +## Authentication Flow + +### Login + +``` +POST /v1/auth/login +Content-Type: application/json + +{ "email": "user@example.com", "password": "..." } +``` + +1. User is looked up by email in MongoDB. +2. Password is verified via Argon2 (memory-hard, GPU-resistant). +3. A new access token and a refresh token family are created. +4. Both tokens are returned. + +```json +{ + "accessToken": "", + "refreshToken": "", + "tokenType": "Bearer", + "expiresIn": 3600 +} +``` + +Authentication errors always return `401` regardless of whether the email exists, to prevent account enumeration. + +### Using the Access Token + +Pass the access token in every request to a protected endpoint: + +``` +Authorization: Bearer +``` + +The `JwtAuthenticationFilter` runs on every request: +1. Extracts and decrypts the Bearer token. +2. Checks the token's `jti` against the MongoDB revocation blocklist. +3. Loads the user and sets the security context. +4. Unauthenticated requests pass through; endpoint authorization rules handle the rejection. + +### Token Refresh + +``` +POST /v1/auth/refresh +Content-Type: application/json + +{ "refreshToken": "" } +``` + +Returns a new access token and a new refresh token. The old refresh token is immediately invalidated. See [Refresh Token Rotation & Theft Detection](#refresh-token-rotation--theft-detection) below. + +### Logout + +``` +POST /v1/auth/logout +Authorization: Bearer +``` + +1. The current access token's `jti` is added to the revocation blocklist. +2. All refresh tokens for the user are revoked. + +The blocklist entry lives until the access token's own `exp`, then MongoDB TTL removes it automatically — the blocklist never grows unboundedly. + +--- + +## Refresh Token Rotation & Theft Detection + +Every login starts a **token family** (tracked by `familyId`). Each refresh produces a new token in the same family; the previous token is marked revoked. + +**Theft detection:** If a refresh token is presented and it's already marked revoked, but its family still has an active sibling, the system infers a stolen token was reused: + +1. The entire token family is revoked (all active sessions from that login). +2. A security event is logged with the attacker's IP. +3. Both the legitimate owner and the attacker are forced to re-authenticate. + +If a revoked token's family has no active siblings, it's treated as a simple re-consumption (returns `401` without the family wipe). + +**Session limits:** Each user may have at most 5 active refresh token families (concurrent sessions). When the limit is reached, the oldest session is automatically revoked. + +--- + +## Roles & Access Control + +Two roles are defined: + +| Role | Description | +|---|---| +| `ROLE_USER` | Default for all registered accounts | +| `ROLE_ADMIN` | Required for user management operations | + +Authorization is enforced at two levels: +- **Route level** — `SecurityConfig` permits public routes and rejects unauthenticated requests to protected ones. +- **Method level** — `@PreAuthorize("hasRole('ROLE_ADMIN')")` on admin controllers. + +**Endpoint authorization summary:** + +| Endpoint | Auth | Role | +|---|---|---| +| `POST /v1/auth/login` | — | — | +| `POST /v1/auth/refresh` | — | — | +| `POST /v1/auth/logout` | Bearer | Any | +| `GET /v1/users/me` | Bearer | Any | +| `GET /v1/users` | Bearer | `ROLE_ADMIN` | +| `POST /v1/admin/users` | Bearer | `ROLE_ADMIN` | +| `GET /v1/health` | — | — | + +--- + +## Password Storage + +Passwords are hashed with **Argon2** via the `password4j` library. Only the hash is stored; plaintext is never persisted. Comparison uses constant-time evaluation. + +--- + +## MongoDB Collections + +| Collection | Purpose | TTL | +|---|---|---| +| `users` | User accounts (email unique-indexed) | — | +| `refresh_tokens` | Active and revoked refresh tokens (hash-stored, not plaintext) | `expiresAt` | +| `revoked_access_tokens` | Access token blocklist by `jti` | `expiresAt` | + +TTL indexes on `expiresAt` in both token collections ensure automatic cleanup with no manual housekeeping. + +--- + +## Error Responses + +All errors return a consistent structure: + +```json +{ + "status": 401, + "message": "Invalid credentials", + "path": "/v1/auth/login" +} +``` + +| Scenario | Status | +|---|---| +| Bad credentials / unknown email | `401` | +| Invalid or expired token | `401` | +| Token reuse / security breach | `401` | +| Authenticated but insufficient role | `403` | +| Validation error | `400` | + +--- + +## Configuration + +| Variable | Description | +|---|---| +| `JWT_ENCRYPTION_SECRET` | Base64-encoded 32-byte AES key (required) | +| `jwt.access-token-expiration` | Access token TTL in ms (default: `3600000` — 1 hour) | +| `jwt.refresh-token-expiration` | Refresh token TTL in ms (default: `2592000000` — 30 days) | +| `jwt.issuer` | Issuer claim value (default: `kotlin-api`) | + +Generate a secret: + +```bash +openssl rand -base64 32 | tr -d '\n' +```