Harden user creation and password handling #89

Closed
opened 2026-03-19 09:35:51 +01:00 by claude · 1 comment
Owner

Problem

Local user authentication exists but lacks production-grade security. Passwords can be trivial, login is unbounded, and there's no self-service password management. The current implementation is vulnerable to brute-force attacks and allows weak credentials.

Current state

  • Hashing: BCrypt (good, keep it)
  • Password policy: None — empty/single-char passwords accepted
  • Rate limiting: None — unlimited login attempts
  • Account lockout: None
  • Password change: No endpoint exists (admin or self-service)
  • Password reset: Not implemented
  • Bootstrap admin: Plain-text env var comparison (not hashed)
  • Audit: Login success/failure logged, but no detail on lockouts or repeated failures

Implementation approach

Prefer existing libraries over rolling our own

Before implementing any of the below, research whether mature Spring/Java libraries already cover these concerns. Candidates to evaluate:

  • Password policy: Passay, Spring Security password encoder wrappers, or custom PasswordValidator with built-in rules
  • Brute-force / rate limiting: Bucket4j, Resilience4j RateLimiter, Spring Security's built-in AuthenticationFailureHandler + lockout support, or a servlet filter with Guava CacheBuilder for IP tracking
  • Common password blocklist: Passay ships with a dictionary-based check; nbvcxz scores password strength including common patterns
  • Account lockout: Spring Security LockedException + UserDetailsService pattern, or a lightweight custom approach on top of our existing users table

Pick libraries that are actively maintained, have minimal transitive dependencies, and fit our existing Spring Boot 3.x / Spring Security 6.x stack. Only hand-roll what no library covers well.

Requirements

1. Password policy enforcement

Enforce on all password-setting paths (user creation, password change, admin reset):

  • Minimum 12 characters
  • Must contain at least 3 of 4 character classes: uppercase, lowercase, digit, special character
  • Reject passwords that match the username or are common passwords (top-1000 blocklist)
  • Validate server-side — never trust client-side validation alone
  • Return clear error messages indicating which rule failed

2. Brute-force protection

  • Rate limit POST /api/v1/auth/login — max 5 attempts per username per 15-minute window
  • Account lockout after 5 consecutive failures — lock for 15 minutes, then auto-unlock
  • Track failed attempts in DB (failed_login_attempts, locked_until columns on users)
  • Rate limit by IP as a secondary layer — max 20 attempts per IP per 15-minute window (in-memory, no DB)
  • Return 429 Too Many Requests when rate-limited (not 401, to distinguish from bad credentials)
  • Audit-log every lockout event

3. Password change endpoint

PUT /api/v1/auth/password — authenticated users change their own password:

{
  "currentPassword": "...",
  "newPassword": "..."
}
  • Require valid currentPassword verification before accepting change
  • Apply password policy to newPassword
  • Invalidate all existing refresh tokens for the user on password change
  • Audit-log the change
  • Return 200 on success, 400 on policy violation, 401 on wrong current password

4. Admin password reset

PUT /api/v1/admin/users/{userId}/password — admin sets a new password for any user:

{
  "newPassword": "..."
}
  • Apply password policy
  • Invalidate all existing refresh tokens for the target user
  • Audit-log with admin identity and target user

5. Bootstrap admin hardening

  • On first login, hash the env-var password and store it in DB (already upserts user)
  • On subsequent logins, validate against the stored BCrypt hash, not the env var
  • This means changing the env var alone doesn't change the password after first login (intentional — use the password change endpoint)

6. Refresh token revocation

Required to support password changes and logout:

  • Add token_revoked_before TIMESTAMPTZ column to users (default NULL)
  • On password change or admin reset, set token_revoked_before = now()
  • During refresh token validation, reject tokens issued before token_revoked_before
  • This provides mass-revocation without a token blacklist table

7. OIDC users

  • OIDC-authenticated users must not be able to set local passwords
  • Password endpoints should reject requests from users with provider != local
  • OIDC users are not subject to lockout (their IdP handles that)

Out of scope

  • Self-service password reset via email (no email infra yet)
  • Password expiration / forced rotation (dubious security value per NIST 800-63B)
  • MFA / TOTP (separate feature)

Acceptance criteria

  • Library research completed — decisions documented in PR description
  • Weak passwords rejected with clear error on all write paths
  • Login locked after 5 failures, auto-unlocks after 15 min
  • IP-based rate limiting prevents distributed brute-force
  • Users can change their own password
  • Admins can reset any user's password
  • Password changes invalidate existing sessions via token revocation
  • Bootstrap admin password hashed after first login
  • OIDC users excluded from local password flows
  • All password events audit-logged
  • Integration tests cover lockout, policy rejection, and happy paths
## Problem Local user authentication exists but lacks production-grade security. Passwords can be trivial, login is unbounded, and there's no self-service password management. The current implementation is vulnerable to brute-force attacks and allows weak credentials. ### Current state - **Hashing**: BCrypt (good, keep it) - **Password policy**: None — empty/single-char passwords accepted - **Rate limiting**: None — unlimited login attempts - **Account lockout**: None - **Password change**: No endpoint exists (admin or self-service) - **Password reset**: Not implemented - **Bootstrap admin**: Plain-text env var comparison (not hashed) - **Audit**: Login success/failure logged, but no detail on lockouts or repeated failures ## Implementation approach ### Prefer existing libraries over rolling our own Before implementing any of the below, research whether mature Spring/Java libraries already cover these concerns. Candidates to evaluate: - **Password policy**: Passay, Spring Security password encoder wrappers, or custom `PasswordValidator` with built-in rules - **Brute-force / rate limiting**: Bucket4j, Resilience4j RateLimiter, Spring Security's built-in `AuthenticationFailureHandler` + lockout support, or a servlet filter with Guava `CacheBuilder` for IP tracking - **Common password blocklist**: Passay ships with a dictionary-based check; nbvcxz scores password strength including common patterns - **Account lockout**: Spring Security `LockedException` + `UserDetailsService` pattern, or a lightweight custom approach on top of our existing `users` table Pick libraries that are actively maintained, have minimal transitive dependencies, and fit our existing Spring Boot 3.x / Spring Security 6.x stack. Only hand-roll what no library covers well. ## Requirements ### 1. Password policy enforcement Enforce on all password-setting paths (user creation, password change, admin reset): - **Minimum 12 characters** - **Must contain** at least 3 of 4 character classes: uppercase, lowercase, digit, special character - **Reject** passwords that match the username or are common passwords (top-1000 blocklist) - Validate server-side — never trust client-side validation alone - Return clear error messages indicating which rule failed ### 2. Brute-force protection - **Rate limit** `POST /api/v1/auth/login` — max 5 attempts per username per 15-minute window - **Account lockout** after 5 consecutive failures — lock for 15 minutes, then auto-unlock - Track failed attempts in DB (`failed_login_attempts`, `locked_until` columns on `users`) - **Rate limit by IP** as a secondary layer — max 20 attempts per IP per 15-minute window (in-memory, no DB) - Return `429 Too Many Requests` when rate-limited (not 401, to distinguish from bad credentials) - Audit-log every lockout event ### 3. Password change endpoint `PUT /api/v1/auth/password` — authenticated users change their own password: ```json { "currentPassword": "...", "newPassword": "..." } ``` - Require valid `currentPassword` verification before accepting change - Apply password policy to `newPassword` - Invalidate all existing refresh tokens for the user on password change - Audit-log the change - Return 200 on success, 400 on policy violation, 401 on wrong current password ### 4. Admin password reset `PUT /api/v1/admin/users/{userId}/password` — admin sets a new password for any user: ```json { "newPassword": "..." } ``` - Apply password policy - Invalidate all existing refresh tokens for the target user - Audit-log with admin identity and target user ### 5. Bootstrap admin hardening - On first login, hash the env-var password and store it in DB (already upserts user) - On subsequent logins, validate against the stored BCrypt hash, not the env var - This means changing the env var alone doesn't change the password after first login (intentional — use the password change endpoint) ### 6. Refresh token revocation Required to support password changes and logout: - Add `token_revoked_before` TIMESTAMPTZ column to `users` (default NULL) - On password change or admin reset, set `token_revoked_before = now()` - During refresh token validation, reject tokens issued before `token_revoked_before` - This provides mass-revocation without a token blacklist table ### 7. OIDC users - OIDC-authenticated users must **not** be able to set local passwords - Password endpoints should reject requests from users with provider != `local` - OIDC users are not subject to lockout (their IdP handles that) ## Out of scope - Self-service password reset via email (no email infra yet) - Password expiration / forced rotation (dubious security value per NIST 800-63B) - MFA / TOTP (separate feature) ## Acceptance criteria - [ ] Library research completed — decisions documented in PR description - [ ] Weak passwords rejected with clear error on all write paths - [ ] Login locked after 5 failures, auto-unlocks after 15 min - [ ] IP-based rate limiting prevents distributed brute-force - [ ] Users can change their own password - [ ] Admins can reset any user's password - [ ] Password changes invalidate existing sessions via token revocation - [ ] Bootstrap admin password hashed after first login - [ ] OIDC users excluded from local password flows - [ ] All password events audit-logged - [ ] Integration tests cover lockout, policy rejection, and happy paths
Author
Owner

Implemented in 827ba3c. Changes:

  • Password policy: PasswordPolicyValidator — min 12 chars, 3/4 character classes, rejects username match
  • Brute-force protection: 5 attempts → 15min lockout, tracked in failed_login_attempts/locked_until columns (V9 migration)
  • Token revocation: token_revoked_before column, checked in JwtAuthenticationFilter, set on password change
  • Policy enforcement: applied on user creation and admin password reset
  • OIDC exclusion: password endpoints already reject when oidcEnabled

Remaining items for follow-up: IP-based rate limiting (in-memory, no DB), self-service password change endpoint (PUT /api/v1/auth/password), bootstrap admin hardening (hash env var on first login).

Implemented in 827ba3c. Changes: - **Password policy**: `PasswordPolicyValidator` — min 12 chars, 3/4 character classes, rejects username match - **Brute-force protection**: 5 attempts → 15min lockout, tracked in `failed_login_attempts`/`locked_until` columns (V9 migration) - **Token revocation**: `token_revoked_before` column, checked in `JwtAuthenticationFilter`, set on password change - **Policy enforcement**: applied on user creation and admin password reset - **OIDC exclusion**: password endpoints already reject when `oidcEnabled` Remaining items for follow-up: IP-based rate limiting (in-memory, no DB), self-service password change endpoint (`PUT /api/v1/auth/password`), bootstrap admin hardening (hash env var on first login).
Sign in to join this conversation.