Centralized authentication and authorization for multi-service environments: registration, login, JWT (RS256) access tokens + rotating opaque refresh tokens, RBAC with fine-grained permissions, OAuth2/OIDC federation (Spring Authorization Server), TOTP MFA, Kafka-streamed audit logging, a session management dashboard, anomaly detection, and a full metrics/tracing stack. Built in four phases, documented in order below — see Phase 1, Phase 2, Phase 3, and Phase 4 for the detail behind each capability.
Java 21, Spring Boot 3.3, Spring Security 6 (+ OAuth2 Authorization Server), PostgreSQL 16, Redis 7, Kafka, Flyway, JJWT, springdoc-openapi, Micrometer/Prometheus, OpenTelemetry/Zipkin, Grafana.
- Docker + Docker Compose — runs every dependency (Postgres, Redis, Kafka, Zipkin, Prometheus, Grafana) and, optionally, the app itself.
- JDK 21 and Maven 3.9+ — only needed if you want to run the app on the host instead of in Docker.
- k6 (optional) — only needed for the load tests.
git clone <this-repo>
cd iam-javaspringboot
docker compose up -d --buildThis builds the app image and starts it alongside every dependency: Postgres (5432), Redis (6379),
Kafka (9092, single-node KRaft broker), Zipkin (http://localhost:9411), Prometheus
(http://localhost:9090), and Grafana (http://localhost:3000, login admin/admin, pre-provisioned
with the "IAM Core - Overview" dashboard). Flyway migrations run automatically on app startup.
Check it's up:
curl http://localhost:8080/actuator/healthUseful for local development (hot reload, debugger attached).
docker compose up -d postgres redis kafka
mvn spring-boot:runKafka isn't strictly required for the app to boot (the audit-log producer falls back to a direct DB write if it's unreachable — see ADR-0001), but without it, audit events won't stream anywhere.
Either way, the app listens on http://localhost:8080. Interactive API docs (try every endpoint
without leaving the browser): http://localhost:8080/swagger-ui.html.
All defaults live in src/main/resources/application.yml (and application-docker.yml for
container-specific overrides) and are safe for local dev out of the box — including a dev-only
JWT signing keypair and MFA encryption key, clearly marked as such (see
JWT signing keys below for how to generate your own). Every setting can be
overridden via the matching environment variable (Spring's standard relaxed binding), e.g.
SPRING_DATASOURCE_URL, IAM_SECURITY_RATE_LIMIT_MAX_REQUESTS.
A full walkthrough of the core auth flow, once the app is running (either option above):
1. Register a user. The very first account ever registered is automatically granted the ADMIN
role — see Bootstrap admin.
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","email":"alice@example.com","password":"S3curePass!"}'2. Log in to get an access token (15 min TTL) and a refresh token (7 day TTL).
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"S3curePass!"}'Response: {"data":{"accessToken":"...","refreshToken":"...","tokenType":"Bearer","expiresIn":900,...}}
(if MFA is enabled on the account, you'll get {"mfaRequired":true,"mfaChallengeToken":"..."}
instead — see MFA).
3. Call a protected endpoint with the access token as a Bearer credential.
curl http://localhost:8080/sessions \
-H "Authorization: Bearer <accessToken>"4. Refresh before the access token expires (this also rotates the refresh token — the old one is revoked and cannot be reused).
curl -X POST http://localhost:8080/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<refreshToken>"}'5. Log out to revoke the refresh token and blacklist the current access token.
curl -X POST http://localhost:8080/auth/logout \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<refreshToken>"}'From here:
- Manage roles/permissions and grant them to users → Phase 2
- Register a client and get a token via
client_credentials, or set up theauthorization_codebrowser flow → Phase 3 - Turn on MFA for an account → MFA (TOTP)
- View/revoke active sessions, or query the audit trail → Phase 4
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | /auth/register | No | Create a new user |
| POST | /auth/login | No | Authenticate, receive access+refresh |
| POST | /auth/refresh | No | Rotate refresh token, issue new access |
| POST | /auth/logout | Yes (Bearer) | Revoke refresh token + blacklist JWT |
src/main/resources/certs/dev-*.pem is an RSA keypair for local development only, checked in for convenience. Never reuse these keys in staging/production. Generate your own pair and point iam.jwt.private-key-path / iam.jwt.public-key-path (or the equivalent env vars) at externally-supplied files/secrets instead:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -pubout -in private.pem -out public.pemmvn clean verifyRuns unit tests plus the *IT integration tests, which spin up Postgres, Redis, and Kafka via
Testcontainers (requires Docker running).
- Passwords are hashed with BCrypt; plaintext passwords are never persisted or logged.
- Access tokens are short-lived (15 min default) JWTs signed with RS256.
- Refresh tokens are opaque random strings; only their SHA-256 hash is persisted. Reuse of an already-rotated refresh token revokes its entire token family (replay-attack protection).
- Logout blacklists the access token's
jtiin Redis until its natural expiry.
Out of scope for Phase 1 (see PRD): RBAC/ABAC, OAuth2/OIDC federation, MFA, rate limiting, audit log streaming, observability stack.
Adds role/permission management on top of Phase 1 authentication. Authorization decisions are based on permissions, not just roles: every protected admin operation is guarded by @PreAuthorize("hasAuthority('resource:action')") at the service layer.
There is no seeded admin password. The first user ever registered (POST /auth/register) is automatically granted the ADMIN role in addition to the default USER role every new user gets. From then on, that admin can create further roles/permissions and grant them to other users via the API below. Register your real first account immediately after standing up a fresh environment.
| Method | Path | Required permission |
|---|---|---|
| POST | /roles | role:create |
| GET | /roles, /roles/{id} | role:read |
| PUT | /roles/{id} | role:update |
| DELETE | /roles/{id} | role:delete |
| POST | /roles/{id}/permissions | role:update |
| DELETE | /roles/{id}/permissions/{permId} | role:update |
| POST | /permissions | permission:create |
| GET | /permissions, /permissions/{id} | permission:read |
| DELETE | /permissions/{id} | permission:delete |
| POST | /users/{userId}/roles | user_role:assign |
| GET | /users/{userId}/roles | user_role:read |
| DELETE | /users/{userId}/roles/{roleId} | user_role:revoke |
| Resource | Actions | ADMIN |
USER |
|---|---|---|---|
role |
create, read, update, delete | ✅ | — |
permission |
create, read, delete | ✅ | — |
user_role |
assign, revoke, read | ✅ | — |
USER is intentionally a permission-less base role — it just marks "an authenticated, registered user" and is the foundation for self-service features in later phases.
Permissions are never embedded in the JWT. On every request, JwtAuthenticationFilter re-resolves the caller's effective authorities live from the database (roles → role-permissions), cached in Redis for 5 minutes to avoid N+1 queries. Any role/permission mutation (assign/revoke role, attach/detach permission, delete role/permission) explicitly evicts the affected users' cache entries, so the change takes effect on their very next request — even with their current, still-valid access token.
Every role/permission mutation (create/update/delete role, create/delete permission, attach/detach permission, assign/revoke role) is written to the audit_logs table (actor, action, resource, IP, JSON metadata). There is no query API for it yet — that's part of the observability/compliance work in Phase 4. Inspect it directly for now:
SELECT * FROM audit_logs ORDER BY created_at DESC;user_roles.tenant_idexists (per the PRD's domain model) but is not actively filtered on — multi-tenant scoping is groundwork for a later phase, not enforced yet.- No admin UI/API to browse audit logs (write-only for now).
Adds a real OAuth2/OIDC provider (Spring Authorization Server) alongside the existing custom JWT API, plus TOTP-based MFA and login brute-force protection.
Three separate SecurityFilterChain beans now coexist, matched in this order:
| Order | Matches | Session | Purpose |
|---|---|---|---|
| 1 | /oauth2/**, /.well-known/**, /connect/** (Spring AS's own endpoint set) |
stateless | Authorization Server: /oauth2/authorize, /oauth2/token, /oauth2/jwks, /.well-known/openid-configuration |
| 2 | /auth/**, /roles/**, /permissions/**, /users/**, /clients/**, swagger paths |
stateless (unchanged from Phase 1/2) | Our own JWT-secured REST API |
| 3 | everything else (anyRequest) |
session (default) | Spring's built-in /login form — only ever hit mid-flow when an unauthenticated browser lands on /oauth2/authorize |
The Authorization Server signs its own tokens with a separate, in-memory RSA keypair (generated at startup, published at /oauth2/jwks) — deliberately not shared with the custom-JWT keypair from Phase 1, since they're different token issuers/audiences.
Admin API (@PreAuthorize-guarded like Roles/Permissions, permissions client:create/read/update/delete):
| Method | Path | Notes |
|---|---|---|
| POST | /clients |
clientSecret is returned once, in this response only |
| GET | /clients, /clients/{id} |
secret never included |
| PUT | /clients/{id} |
|
| DELETE | /clients/{id} |
Example request body for a machine-to-machine client:
{
"clientName": "billing-service",
"grantTypes": ["client_credentials"],
"scopes": ["api.read"]
}For an authorization_code web client, redirectUris is required (validated server-side); requireProofKey (PKCE) defaults to true.
curl -u <client_id>:<client_secret> \
-d grant_type=client_credentials -d scope=api.read \
http://localhost:8080/oauth2/token- Register a client with
grantTypes: ["authorization_code", "refresh_token"]and a realredirectUrisentry (e.g. Postman'shttps://oauth.pstmn.io/v1/callbackif using Postman's OAuth2 helper). - Open
http://localhost:8080/oauth2/authorize?response_type=code&client_id=<client_id>&scope=<scopes>&redirect_uri=<redirect_uri>in a browser. - You'll be redirected to Spring's default
/loginpage — sign in with any registered user's username/password (this session-based login does not enforce MFA, see limitation below). - Approve the consent screen → redirected to your
redirect_uriwith acodeparameter. - Exchange it:
POST /oauth2/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id/client_secret(+code_verifierif PKCE was used, which Postman's OAuth2 helper handles automatically).
Discovery: GET /.well-known/openid-configuration.
Hand-rolled RFC 6238 (no third-party TOTP/QR library — see rationale in code comments), secret encrypted at rest (AES-256-GCM, key from iam.security.mfa-encryption-key).
| Method | Path | Auth |
|---|---|---|
| POST | /auth/mfa/setup |
Bearer — returns {secret, otpauthUri}, secret held in Redis for 10 min pending confirmation |
| POST | /auth/mfa/enable |
Bearer, body {code} — confirms and activates MFA |
| POST | /auth/mfa/disable |
Bearer — blocked for ADMIN accounts (MFA is mandatory for admins) |
| POST | /auth/mfa/verify |
Public, body {challengeToken, code} — completes a login that returned mfaRequired: true |
Login flow changes (AuthResponse gained additive fields, old ones unchanged):
- MFA already enabled →
POST /auth/loginreturns{mfaRequired: true, mfaChallengeToken}instead of tokens; follow up with/auth/mfa/verify. ROLE_ADMINbut MFA not yet configured (the bootstrap admin) → login still succeeds so they're not locked out, but the token carries amfaPendingclaim that makesJwtAuthenticationFilterstrip all RBAC authorities from it — every@PreAuthorize-guarded endpoint returns 403 until they complete/auth/mfa/setup+/auth/mfa/enableand call/auth/refresh(or log in again) for an unrestricted token.AuthResponse.mfaSetupRequiredflags this state.- Everyone else: unchanged from Phase 1/2.
Per-username Redis counter (iam.security.login-lockout, default 5 attempts / 15 min). Locked accounts get 423 Locked from both /auth/login and /auth/mfa/verify; counter resets on success.
- Google/social login was skipped (PRD marks it optional) — no real Google OAuth2 credentials were available to configure or test against in this environment.
- MFA does not cover the browser
/loginform used by the authorization_code flow — only the JSON/auth/loginAPI enforces/challenges it. Closing this gap would need a custom step-up-MFA page for the form-login chain; deferred as a documented gap rather than silently ignored. - Confidential clients only — no public-client (no-secret, PKCE-only) support.
Moves the system from "functionally correct" to "production-grade": Kafka-streamed audit logging, a
session management dashboard, simple anomaly detection, a full metrics/tracing stack, and additional
security hardening. Architecture decisions for this phase are written up as ADRs in
docs/adr/; a full checklist against OWASP ASVS is in
docs/security-checklist.md.
docker compose up -d --buildNow also starts: Kafka (single-node KRaft broker, port 9092), Zipkin (http://localhost:9411),
Prometheus (http://localhost:9090), Grafana (http://localhost:3000, admin/admin,
pre-provisioned with an "IAM Core - Overview" dashboard). Running the app outside Docker
(mvn spring-boot:run) still needs at least docker compose up -d postgres redis kafka for the app to
start cleanly (Kafka isn't strictly required for the app to boot — the audit producer falls back to a
direct DB write, see ADR-0001 — but audit events won't stream anywhere useful without it).
AuditLogService.record(...) (used by every role/permission/client/session mutation since Phase 2)
now publishes to the iam.audit.events Kafka topic instead of writing to Postgres directly.
AuditEventConsumer materializes those events into the same audit_logs table. The Kafka publish is
deferred until the enclosing transaction commits, and falls back to a direct synchronous write if
Kafka is unreachable — see ADR-0001 for the full
rationale (no audit event is ever silently dropped).
| Method | Path | Required permission |
|---|---|---|
| GET | /admin/audit-logs?actorId=&action=&resource=&from=&to=&page=&size= |
audit:read |
A "session" is one active (non-revoked, non-expired) refresh token — see ADR-0002 for why no new entity was introduced.
| Method | Path | Auth |
|---|---|---|
| GET | /sessions |
Bearer — lists your own active sessions |
| DELETE | /sessions/{id} |
Bearer — revoke one of your own sessions (403 if it belongs to someone else) |
| GET | /admin/users/{userId}/sessions |
session:read |
| DELETE | /admin/users/{userId}/sessions |
session:revoke — force-logout a user from every device |
Every login checks whether the device (User-Agent) and IP have ever been seen before for that user
(skipped entirely on a brand-new account's first-ever login). An unseen device or IP each independently
write a SECURITY_ANOMALY_NEW_DEVICE / SECURITY_ANOMALY_NEW_LOCATION audit entry, log a WARN, and
increment the iam_security_anomaly_total{type=...} metric. See
ADR-0003 for the (deliberately simple) heuristic.
- Metrics:
GET /actuator/prometheus. Custom counters:iam_auth_login_total{result},iam_auth_token_issued_total,iam_auth_mfa_challenge_total,iam_security_anomaly_total{type}, plus Spring's auto-instrumentedhttp_server_requests_seconds_*histogram (per-endpoint latency, status codes). See ADR-0004. - Tracing: OpenTelemetry via Micrometer Tracing, exported to Zipkin. 100% sampled in dev
(
management.tracing.sampling.probability) — dial down before any real production traffic. - Dashboard:
observability/grafana/dashboards/iam-overview.json, auto-provisioned into Grafana ondocker compose up.
- Rate limiting:
RateLimitingFilter(Redis fixed-window,iam.security.rate-limit.*) throttles/auth/login,/auth/register,/auth/refresh,/auth/mfa/verify,/oauth2/tokenper source IP — layered on top of (not replacing) Phase 3's per-account brute-force lockout.429vs423: see ADR-0005. - CORS: tightened from Phase 1-3's wildcard
*to an explicit allow-list (iam.security.cors.allowed-origins). - Dependency scanning:
mvn org.owasp:dependency-check-maven:check(not bound to the normal build lifecycle — the NVD feed download is slow and needs network access).
k6 run loadtest/login-flow.js
k6 run -e VUS=50 -e DURATION=60s loadtest/token-validation.js # validates the PRD's p95<50ms NFR- Force-revoking a session stops it minting new access tokens immediately, but any access token already issued from it remains valid until its own short natural expiry — see ADR-0002.
- Anomaly detection is a first-seen device/IP heuristic, not geo-IP/ML-based risk scoring — see ADR-0003.
- No log aggregation stack (ELK/Loki); metrics + tracing cover this phase's acceptance criteria without it.
- TLS termination and browser security headers (CSP, etc.) are out of scope — expected to sit at a reverse proxy/ingress in front of this service in any real deployment.