Accounts, passwords and lockout

User administration, self-service and admin password changes, and a temporary lockout after repeated failed logins.

  • Part 9
  • advanced
  • about 75 minutes

You will build

admin user APIs, password change and reset that invalidate OAuth state, and failed-login tracking with temporary and manual locks

You will understand

why a password change should revoke refresh tokens, and why only bad-credential events should count towards a lockout

  • one migration, three services

An authorization server is an attractive authentication target. User management cannot end at registration.

We add three layers:

  1. ADMIN user management;
  2. password lifecycle operations;
  3. failed-login lockout.

Admin user API

Protect these with the same ADMIN bearer-token chain:

GET    /api/v1/users
GET    /api/v1/users/{id}
PUT    /api/v1/users/{id}/status
PUT    /api/v1/users/{id}/roles/{roleName}
DELETE /api/v1/users/{id}/roles/{roleName}

Return safe account state:

{
  "id": "...",
  "username": "alice",
  "email": "alice@example.com",
  "enabled": true,
  "accountNonExpired": true,
  "accountNonLocked": true,
  "credentialsNonExpired": true,
  "roles": ["USER"]
}

Do not return the password hash.

Admin status updates

Keep Spring’s four account flags explicit:

enabled
accountNonExpired
accountNonLocked
credentialsNonExpired

This is verbose but unambiguous, and maps directly to Spring Security.

Role assignment

When assigning a role:

  1. load user;
  2. load role by canonical name;
  3. add only if not already assigned;
  4. save transactionally.

When removing a role, reject a role that is not currently assigned instead of silently pretending a state change happened.

Password change versus admin reset

These are different operations.

User changes own password

POST /api/v1/account/password

Request:

{
  "currentPassword": "...",
  "newPassword": "..."
}

The service must verify the current password first.

Admin resets another user’s password

PUT /api/v1/users/{userId}/password

Request:

{
  "newPassword": "..."
}

The administrator does not know or provide the user’s old password.

Invalidate authorization state on password change

After a password change/reset, delete OAuth authorization state for that principal:

DELETE FROM oauth2_authorization
WHERE principal_name = ?;

This invalidates server-side refresh-token/authorization state.

RFC 9700 explicitly recognizes password change as an example security event where an authorization server may revoke refresh tokens.

Important nuance: a self-contained access JWT already issued can remain cryptographically valid until expiration unless the resource server also consults current server-side state. We address that in Part 11.

Login failure tracking

Add columns in V11__add_login_attempt_tracking.sql:

ALTER TABLE users
    ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
    ADD COLUMN locked_until TIMESTAMPTZ;

ALTER TABLE users
    ADD CONSTRAINT chk_users_failed_login_attempts
      CHECK (failed_login_attempts >= 0);

Configurable properties:

authorization-server:
  security:
    login:
      max-failed-attempts: ${LOGIN_MAX_FAILED_ATTEMPTS:5}
      lock-duration: ${LOGIN_LOCK_DURATION:15m}

Temporary versus manual lock

We deliberately encode them differently.

Temporary security lock:

account_non_locked = false
locked_until       = timestamp in future

Manual admin lock:

account_non_locked = false
locked_until       = null

That gives automatic expiration only to the temporary case.

stateDiagram-v2
    [*] --> Active
    Active --> Active: failed attempts < threshold
    Active --> TempLocked: failure reaches threshold
    TempLocked --> Active: locked_until expires + valid login
    Active --> ManualLocked: admin lock
    ManualLocked --> Active: admin unlock

Listen to Spring authentication events

Use AuthenticationFailureBadCredentialsEvent rather than treating every authentication failure identically.

Why?

If a user is already locked, Spring can fail authentication with a locked-account event. That event should not keep incrementing the bad-password counter.

On bad credentials:

find user by username/email
if already locked → do nothing
increment failed_login_attempts
if threshold reached:
    account_non_locked = false
    locked_until = now + duration

On successful username/password authentication:

failed_login_attempts = 0
locked_until = null

Auto-release an expired temporary lock

Before constructing UserDetails, check:

account_non_locked == false
locked_until != null
locked_until <= now

If true, reset:

account_non_locked = true
failed_login_attempts = 0
locked_until = null

If locked_until == null, treat the lock as administrative and never auto-release it.

Manual test

Register a test user:

curl -X POST http://localhost:9000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "locktest",
    "email": "locktest@example.com",
    "password": "CorrectPassword123!"
  }'

Submit a wrong password five times through /login.

Inspect:

SELECT username,
       failed_login_attempts,
       account_non_locked,
       locked_until
FROM users
WHERE username = 'locktest';

Expected:

failed_login_attempts = 5
account_non_locked    = false
locked_until          = future timestamp

For development, expire it manually:

UPDATE users
SET locked_until = NOW() - INTERVAL '1 minute'
WHERE username = 'locktest';

A subsequent valid login should automatically restore the account.

Tests worth keeping

unauthenticated admin endpoint → 401
ROLE_USER admin endpoint       → 403
ROLE_ADMIN                     → success
wrong current password         → 400
same new/current password      → 400
five failed logins             → temporary lock
correct password while locked  → rejected
expired temporary lock         → valid login succeeds
manual lock                    → does not auto-expire

The code

This part corresponds to these commits in the repository:

  • bc9dc77 feat: add admin user management
  • 22aea38 feat: add password management and reset
  • d19dbb5 feat: add temporary login lockout protection

Check out the last one to see the project exactly as this part leaves it:

git checkout d19dbb5

References

Next: complete the OpenID Connect identity layer by adding scope-aware ID Token claims and UserInfo.