+29
@@ -41,3 +41,32 @@ out/
|
|||||||
|
|
||||||
### Environment Variables ###
|
### Environment Variables ###
|
||||||
.env
|
.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*
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ A reactive REST API built with **Kotlin**, **Spring Boot WebFlux**, and **MongoD
|
|||||||
- [Run with Docker](#run-with-docker)
|
- [Run with Docker](#run-with-docker)
|
||||||
- [API Reference](#-api-reference)
|
- [API Reference](#-api-reference)
|
||||||
- [Project Structure](#-project-structure)
|
- [Project Structure](#-project-structure)
|
||||||
|
- [Documentation](#-documentation)
|
||||||
- [Development](#-development)
|
- [Development](#-development)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,6 +87,17 @@ openssl rand -base64 32 | tr -d '\n'
|
|||||||
|
|
||||||
The API starts on **http://localhost:8080**.
|
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
|
### Run with Docker
|
||||||
|
|
||||||
```bash
|
```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
|
## 🧑💻 Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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": "<jwe>",
|
||||||
|
"refreshToken": "<jwe>",
|
||||||
|
"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 <accessToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
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": "<jwe>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <accessToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
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'
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user