Accounts, passwords and lockout
User administration, self-service and admin password changes, and a temporary lockout after repeated failed logins.
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:
- ADMIN user management;
- password lifecycle operations;
- 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:
- load user;
- load role by canonical name;
- add only if not already assigned;
- 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:
bc9dc77feat: add admin user management22aea38feat: add password management and resetd19dbb5feat: add temporary login lockout protection
Check out the last one to see the project exactly as this part leaves it:
git checkout d19dbb5
References
- Spring Security authentication events: https://docs.spring.io/spring-security/reference/servlet/authentication/events.html
- OAuth Security BCP — RFC 9700: https://www.rfc-editor.org/rfc/rfc9700
- Spring Security password storage: https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
Next: complete the OpenID Connect identity layer by adding scope-aware ID Token claims and UserInfo.