11 — The login path, hardened five ways
Read this first: this lesson walks one request — POST /api/auth/login — from the socket to the
token pair, through five independent defenses. Each has its own status code and a comment in the
source that says why it exists. By the end you can name all five in chain order, explain refresh
rotation, and say precisely what family reuse-detection catches.
Time: about 45 minutes. Assumes lesson 10.
The chain, end to end
Lesson 10 gave you the filter chain; this lesson watches a login run the gauntlet:
socket
→ LoginRateLimitFilter too many attempts from this IP? → 429
→ AltchaVerificationFilter no valid proof-of-work? → 428
→ AuthController.login
→ pre-auth lockout check account currently locked? → 423
→ Argon2id comparison wrong password / unknown user? → 401
→ AuthTokenIssuer success → access JWT + refresh token
Five defenses, four ways to be turned away, one way through. They are independent: disabling any one leaves the other four standing, and each is written as if the others might not be there.
Defense 1 — the rate limit (429)
LoginRateLimitFilter
is a Bucket4j token bucket keyed by client IP plus path — ten credential attempts per minute,
with greedy refill so a burst cannot straddle a window boundary. The class javadoc answers the first
question you should ask of any in-memory limiter:
* This deployment runs one backend instance per client (see docs/deployment/vps-guide.md), so in-memory
* buckets are sufficient -- there's no second instance for state to be inconsistent with.
Not every throttled path is a credential path, and the filter refuses to pretend otherwise. From the
comment on SIGNUP_LIMIT (six per hour):
Signing up is not a credential attempt and must not be priced like one. […] it does send mail to an address the caller chose, which is an amplification vector with our sender reputation attached. Six per hour covers a human who fixes a typo and presses resend twice.
Even eviction is defensive: an idle bucket is only dropped once it would have refilled anyway, because "dropping a bucket hands back a full one" — evicting the hourly signup bucket every ten minutes would multiply its limit by six.
The IP the limiter keys on
The bucket key comes from
RequestUtils.resolveClientIp,
and which end of X-Forwarded-For you read is the whole game. Each proxy hop appends the peer it
observed, so:
* the LAST entry is always the address the closest hop (nginx) actually observed on its own
* socket -- the one entry a remote client cannot forge by sending a crafted header. Trusting the
* FIRST entry instead (the previous behavior) let any caller spoof the logged IP outright.
The same javadoc is honest about scope: this is not itself a security boundary — "it only affects what gets written down" — and it assumes the app is only reachable through the proxy chain.
Defense 2 — proof of work (428)
AltchaVerificationFilter
demands a solved ALTCHA challenge in the X-Altcha-Payload header — work a browser does invisibly
and a credential-stuffing script has to pay for on every attempt. The class javadoc fixes both its
position in the chain and its one deliberate gap:
* Runs after LoginRateLimitFilter (cheapest check first) and before
* JwtAuthenticationFilter. /api/auth/refresh is deliberately not guarded: it's a programmatic
* call whose refresh token is already a strong single-use credential.
* <p>
* Rejections are anonymous (no user resolved yet), so they can't be user_log audit rows --
* user_log.user_id is a NOT NULL FK. Coverage comes from the altcha verification counters
* (AuthMetrics, via AltchaService) plus the WARN log with client IP here.
The same strong-credential reasoning splits the signup pair. /api/public/signup is guarded;
/api/public/signup/verify is not:
// deliberately NOT here: the emailed six-digit code is itself a strong single-use
// credential -- the same reasoning that leaves /api/auth/refresh unguarded -- and
// demanding a second solved challenge minutes after the first would punish the human
// the first one already identified.
A solved challenge is spent on use:
AltchaService
keeps a replay registry — "every accepted solution's signature goes into an in-memory registry and a
second submission of the same payload is rejected as a replay" — because ALTCHA's guidance makes
single-use enforcement the integrator's job.
Defense 3 — the lockout (423)
LoginAttemptService
tracks failures per account, where the rate limit tracked per IP. Its javadoc is the spec:
* failedLoginAttempts is only reset on a successful login, so its value encodes the
* consecutive-failure history: the 5th failure locks for 15 minutes, and every further
* failure after a lockout expires doubles the duration (30m, 1h, 2h, ...) up to a 24h cap.
* Attempts made while locked don't reach here -- the provider's pre-auth check rejects
* them with LockedException before credentials are compared.
Read the last sentence again: an attempt against a locked account never reaches the password
comparison. The controller counts a LockedException for metrics only — it is not a new failure —
and GlobalControllerAdvice (lesson 05) maps it to 423. The case with no account to lock is written
down rather than left to worry about:
// Unknown usernames aren't tracked per-account -- there's no account to lock -- but they're
// still covered by LoginRateLimitFilter's IP-based throttle.
The defenses cover each other's gaps, and say so by name.
Defense 4 — the credential check itself
AuthController.login
finally compares credentials — and its first comment is about what a failure must not reveal:
} catch (BadCredentialsException ex) {
// Wrong password for a known account, or an unknown username -- DaoAuthenticationProvider
// deliberately surfaces both as BadCredentialsException so this branch can't be used to
// enumerate valid usernames.
loginAttemptService.registerFailure(request.getUsername(), clientIp);
throw ex;
Same status, same message, whether the username exists or not. Then, the moment the account is known, the request stops being anonymous — and the scope narrows:
// Up to here the request has been running in global scope -- it had to, because working out
// who is logging in means reading the users table before anyone knows whose it is. Now that
// the account is known, narrow to its tenant so the audit trail this login writes lands in
// that company's records rather than nowhere. (Global scope is a read scope; a write made
// in it has no owning tenant, and the foreign key on tenant_id says so.)
That comment is the doorway into lesson 12 — hold onto it.
Argon2id, and the hash that upgrades itself
What does the comparison actually compare?
PasswordEncoderConfiguration:
/**
* Argon2id is the encoder for all NEW hashes (OWASP parameters: 19 MiB memory, 2 iterations,
* parallelism 1); bcrypt remains registered so existing hashes keep matching. Legacy rows
* store raw {@code $2a$...} bcrypt with no {@code {id}} prefix, hence the
* default-for-matches fallback -- without it, matching an unprefixed hash throws.
* upgradeEncoding() is true for those, so hashes migrate to {@code {argon2}} transparently
* on the next successful login (see UserDetailsServiceImpl.updatePassword and
* PortalAuthServiceImpl.login).
*/
Nobody runs a migration script over password hashes — you cannot, because you do not have the
passwords. Instead, the one moment the plaintext legitimately exists (a successful login) is used to
re-hash it under Argon2id. For ERP logins the machinery is Spring's: lesson 10 wired
setUserDetailsPasswordService, and DaoAuthenticationProvider calls it whenever
upgradeEncoding() says the stored hash is stale. The portal never passes through that provider, so
PortalAuthServiceImpl
does it by hand:
// Portal logins never pass through DaoAuthenticationProvider, so the transparent hash
// upgrade that UserDetailsServiceImpl.updatePassword provides for ERP users happens
// explicitly here instead.
if (passwordEncoder.upgradeEncoding(user.getPasswordHash())) {
Two paths, one invariant: every successful login leaves the strongest hash the system can write.
Defense 5 — what success hands back
Success flows through
AuthTokenIssuer,
extracted for one stated reason:
* <p>Extracted so that logging in and signing up cannot drift apart. They are different doors into
* the same building -- the access-token lifetime, the refresh-token issuance and the DTO the client
* persists have to be identical, and the way that goes wrong is someone changing one call site and
* not noticing the other.
The pair it issues is asymmetric on purpose. The access token is a short-lived JWT — short because
it cannot be revoked before it expires. The refresh token is deliberately not a JWT.
RefreshTokenService:
* Issues and rotates opaque (non-JWT) refresh tokens for the internal ERP login. Opaque tokens
* are used -- rather than a second JWT -- because revocation requires a database check on every
* refresh regardless, so a self-contained token buys nothing here.
Only a SHA-256 hash of the token is stored, so a leaked database does not leak usable tokens.
Rotation, and the family that dies together
Every POST /api/auth/refresh is a rotation: the presented token is revoked, a new one is issued in
the same family (a UUID stamped at login), and the old row records which hash replaced it. Present
a token that was already rotated, and:
if (existing.getRevokedAt() != null) {
Integer userId = existing.getUser().getUserId();
revokeFamily(existing.getFamilyId());
auditService.log(userId, "Auth.refreshTokenReuseDetected", clientIp);
throw new BadCredentialsException("Refresh token has already been used");
}
This is reuse detection. A rotated token has one legitimate state: spent. If it shows up again, either an attacker replayed a stolen token after the real client rotated it, or the real client is replaying because the attacker rotated first — indistinguishable, and in both cases someone unauthorized holds a family member. So the whole family dies, and the theft becomes an audit row.
Predict: an attacker replays an already-rotated refresh token. The code above revokes the family
and then throws. What would happen to that revocation if rotate() ran inside one
@Transactional request transaction — and why is that the wrong default here?
The service answers, in the best security comment in the repository:
// Deliberately NOT @Transactional at the method level: each save below already commits via
// Spring Data's own per-call transactionality, and the reuse-detection revocation in rotate()
// must survive even though that same call path goes on to throw -- wrapping this method in one
// outer transaction would roll that revocation back the moment the exception propagates.
The exception is the response — the attacker must be refused — but a method-level transaction treats an escaping exception as a reason to undo everything, including the family revocation. The security write and the error response must not share a transaction, or the defense un-happens the moment it fires.
The coda: the flag that means something
One more filter runs after authentication succeeds.
PasswordChangeRequiredFilter
holds accounts still carrying a provisioning-time temporary password to exactly four paths:
* <p>The frontend also routes such a user to the change-password screen, but that is a courtesy.
* This filter is what makes the flag mean something: without it, anyone who skips the screen and
* calls the API directly is simply logged in.
ALLOWED_PATHS is change-password (the way out), /me (the frontend needs it to render the
screen), logout — and refresh, "so that working through the screen cannot be interrupted by an
expiry". Even the plumbing has a written reason: the filter builds its own ObjectMapper because
"the security configuration is assembled before Jackson's auto-configuration has contributed a bean,
so injecting one fails the whole context at startup".
Fail fast, count what you cannot log
Two last pieces make the rest honest.
JwtProperties
runs a @PostConstruct check that refuses to start the application if JWT_SECRET is unset, still
the shipped placeholder, or shorter than 32 characters — each message names the remedy
(openssl rand -hex 64). A signing-secret problem is a boot failure, never a runtime surprise.
And AuthMetrics
explains why defenses 1 and 2 report to Prometheus counters instead of the audit table:
* Anonymous events (rate-limit and ALTCHA rejections happen before any user is resolved) can't
* be written to user_log -- its user_id column is a NOT NULL FK to users -- so these counters
* are the only durable record of them.
The schema forbids anonymous audit rows, so the observability design routes around it — and says so.
Where this shows up in MotorPH
- The other half of this handshake is the client: role gating in Frontend 101 lesson 03, and the single-flight refresh — one rotation no matter how many 401s land at once — in Frontend 101 lesson 04.
- The reference writeup of this flow is
docs/security/authentication.md; the deployment-level defenses around it are indocs/security/hardening.md. - Each defense has a decision record: ADR 0003 (JWT + rotating refresh), ADR 0004 (Argon2id + transparent rehash), ADR 0005 (self-hosted ALTCHA), and ADR 0006 (Bucket4j in-memory rate limiting).
- The counters feed the Grafana auth overlay;
motorph.auth.login.attemptsby outcome is the fastest way to spot a credential-stuffing run that never made it past defense 1.
Recap
- Five independent defenses, in cost order: rate limit (429), proof of work (428), lockout (423), the Argon2id comparison (401), then token issuance — each written to assume the others might be absent, and each covering a named gap in another.
- Failures must not leak: unknown username and wrong password are the same 401, locked attempts never reach the comparison, and the logged IP is the one XFF entry a client cannot forge.
- Hashes upgrade themselves:
upgradeEncoding()plus the one moment plaintext exists — a successful login — migrates bcrypt to Argon2id with no migration script, via two paths. - Refresh tokens are opaque, rotated, and familial: revocation needs a database check anyway, so a JWT buys nothing; reuse of a spent token kills the whole family.
- A security write must not share the attacker's transaction: the family revocation commits
before the rejection throws, because an outer
@Transactionalwould roll the defense back.