Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

IAM Core — Enterprise Identity & Access Management System

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.

Stack

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.

Getting Started

Prerequisites

  • 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.

Option A — run everything in Docker (recommended)

git clone <this-repo>
cd iam-javaspringboot
docker compose up -d --build

This 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/health

Option B — run the app on the host, dependencies in Docker

Useful for local development (hot reload, debugger attached).

docker compose up -d postgres redis kafka
mvn spring-boot:run

Kafka 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.

Configuration

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.

How to Use

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 the authorization_code browser flow → Phase 3
  • Turn on MFA for an account → MFA (TOTP)
  • View/revoke active sessions, or query the audit trail → Phase 4

Phase 1 — Foundation & Core Authentication

Endpoints

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

JWT signing keys

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.pem

Testing

mvn clean verify

Runs unit tests plus the *IT integration tests, which spin up Postgres, Redis, and Kafka via Testcontainers (requires Docker running).

Security notes

  • 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 jti in 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.

Phase 2 — RBAC & Fine-Grained Access Control

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.

Bootstrap admin

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.

Endpoints

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

Default role-permission matrix (seeded via Flyway)

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.

Immediate effect, no re-login required

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.

Audit log

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;

Known Phase 2 limitations (by design)

  • user_roles.tenant_id exists (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).

Phase 3 — Federation, SSO & Machine-to-Machine

Adds a real OAuth2/OIDC provider (Spring Authorization Server) alongside the existing custom JWT API, plus TOTP-based MFA and login brute-force protection.

Security filter chains

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.

OAuth2 client registration

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.

Testing client_credentials (M2M) — no browser needed

curl -u <client_id>:<client_secret> \
  -d grant_type=client_credentials -d scope=api.read \
  http://localhost:8080/oauth2/token

Testing authorization_code — needs a browser

  1. Register a client with grantTypes: ["authorization_code", "refresh_token"] and a real redirectUris entry (e.g. Postman's https://oauth.pstmn.io/v1/callback if using Postman's OAuth2 helper).
  2. Open http://localhost:8080/oauth2/authorize?response_type=code&client_id=<client_id>&scope=<scopes>&redirect_uri=<redirect_uri> in a browser.
  3. You'll be redirected to Spring's default /login page — sign in with any registered user's username/password (this session-based login does not enforce MFA, see limitation below).
  4. Approve the consent screen → redirected to your redirect_uri with a code parameter.
  5. Exchange it: POST /oauth2/token with grant_type=authorization_code, code, redirect_uri, client_id/client_secret (+ code_verifier if PKCE was used, which Postman's OAuth2 helper handles automatically).

Discovery: GET /.well-known/openid-configuration.

MFA (TOTP)

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/login returns {mfaRequired: true, mfaChallengeToken} instead of tokens; follow up with /auth/mfa/verify.
  • ROLE_ADMIN but MFA not yet configured (the bootstrap admin) → login still succeeds so they're not locked out, but the token carries a mfaPending claim that makes JwtAuthenticationFilter strip all RBAC authorities from it — every @PreAuthorize-guarded endpoint returns 403 until they complete /auth/mfa/setup + /auth/mfa/enable and call /auth/refresh (or log in again) for an unrestricted token. AuthResponse.mfaSetupRequired flags this state.
  • Everyone else: unchanged from Phase 1/2.

Brute-force lockout

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.

Known Phase 3 limitations (by design)

  • 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 /login form used by the authorization_code flow — only the JSON /auth/login API 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.

Phase 4 — Enterprise Hardening, Observability & Compliance

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.

Run everything, including the new infra

docker compose up -d --build

Now 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).

Audit log streaming (Kafka) + query API

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

Session management dashboard

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

Anomaly detection

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.

Observability

  • 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-instrumented http_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 on docker compose up.

Security hardening

  • Rate limiting: RateLimitingFilter (Redis fixed-window, iam.security.rate-limit.*) throttles /auth/login, /auth/register, /auth/refresh, /auth/mfa/verify, /oauth2/token per source IP — layered on top of (not replacing) Phase 3's per-account brute-force lockout. 429 vs 423: 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).

Load testing (k6)

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

Known Phase 4 limitations (by design)

  • 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.

About

Enterprise Identity & Access Management System

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages