Skip to content

Close five account-enumeration and token-rotation holes in auth - #111

Merged
rghvgrv merged 1 commit into
mainfrom
fix/auth-hardening
Aug 7, 2026
Merged

Close five account-enumeration and token-rotation holes in auth#111
rghvgrv merged 1 commit into
mainfrom
fix/auth-hardening

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes the three findings from the auth review, the two smaller notes, and two more that only showed up when I measured a running instance instead of reading the code.

1. Refresh rotation had a TOCTOU race

var existingToken = await ...SingleOrDefaultAsync(t => t.TokenHash == presentedHash);
if (existingToken.RevokedAt is not null) { /* theft */ }
...
existingToken.RevokedAt = DateTimeOffset.UtcNow;   // ← read and write are not atomic

Two callers presenting the same live token both read RevokedAt == null, both proceeded, both minted a pair — the exact concurrent-theft case rotation exists to catch, raising no signal at all.

Now a compare-and-swap; the database picks the winner:

var rotated = await _dbContext.RefreshTokens
    .Where(t => t.Id == existingToken.Id && t.RevokedAt == null)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, now), cancellationToken);
if (rotated == 0) { /* presented twice while valid → revoke the chain */ }

Known residual, commented in place: the winner can insert its replacement pair after a loser's revocation sweep has read the table, so one pair may survive. What is guaranteed is that at most one caller succeeds and the replayed token is dead. Closing the rest needs a per-user "sessions valid from" watermark checked during token validation — a schema change, deliberately not in this PR. I wrote a test asserting the stronger property first, watched it fail, and corrected the claim rather than the test.

2. Register and login leaked which emails are registered

Register answered 409 for a known address — a direct oracle. Login only ran BCrypt when the account existed, so a missing one answered measurably faster.

  • Register now answers 202 with no body either way, and emails the real owner instead.
  • Login verifies against a dummy hash when the address is unknown.

3 + 4. Two more, found by measuring

Fixing the above exposed problems reading the code would not have shown.

Sending mail inline put the oracle straight back through the clock. With no mail server reachable, forgot-password answered 200 in 15ms for an unknown address and 500 in 4.2s for a known one — enumeration by status code, and pre-existing. Register showed the same 4.1s tell.

Requests now enqueue; EmailDispatchBackgroundService does the SMTP round trip. No response time or status depends on whether an address exists.

Then register was inverted. With the queue in, a duplicate returned in 5ms versus 300ms for a new account — because it returned before hashing the password. The hash is now computed before the existence check, so both paths pay the same ~250ms.

Measured on a running instance

before after
register new 0.430s 0.296s
register duplicate 4.161s 0.286s
forgot-password unknown 200, 0.015s 200, 0.004s
forgot-password known 500, 4.192s 200, 0.025s
login unknown 0.807s 0.290s
login wrong password 0.299s 0.293s

Same status, same zero bytes, same duration on both branches of all three endpoints.

Residual: forgot-password still differs by ~20ms (the known path does a DB write). That is far below network jitter and bounded by the 10/min per-IP limit; equalising it fully would mean faking a write for unknown addresses, which I judged worse than the ~20ms.

5 + 6. The two smaller notes

  • Reset codes accumulated. AttemptCount is per row, so every new code handed out another five guesses at the same six-digit space — "5 attempts" only ever bounded one code. Superseded codes are now retired on issue.
  • Concurrent duplicate registration raced the unique index into a 500. Now caught on SQLSTATE 23505 and routed to the same silent path.
  • ResetPasswordAsync no longer relies on a revocation helper's SaveChangesAsync to persist the new password.

Contract change

POST /auth/register returns 202 with no body instead of 201 plus the created user.

Nothing consumed that body — the mobile client already logged in immediately afterwards, and that login is still what tells the two cases apart for whoever actually holds the password. Right password: signed in either way. Wrong one: sent to the login screen, where "forgot password" lives.

This is the one product-visible change here, and it is a one-line revert if you would rather keep the explicit "already registered" message.

Tests

Application.Tests      28 passed
Infrastructure.Tests   98 passed   (was 95)
Api.Tests              92 passed   (was 84)
Mobile.Tests          100 passed

New coverage: concurrent refresh lets at most one through and leaves the presented token dead; an expired token does not take the user's other sessions down; duplicate registration is byte-identical to a new one, emails the owner, and cannot change the existing password; concurrent identical registrations create exactly one account without a 500; the old reset code stops working once a new one is issued and only one stays live; login verifies a hash even for unknown addresses; and the email queue never blocks or throws at the caller, including when full.

One bug fixed in the tests themselves: RequestResetCodeAsync used SentEmails.Last(...) on a ConcurrentBag, which has no insertion order — it returned the oldest code, so asking twice handed back the same one. It now selects the new code by content.

Refresh rotation read RevokedAt and set it in two steps, so two callers
presenting the same live token both saw null and both minted a pair - the exact
concurrent-theft case rotation exists to catch, and it raised no signal at all.
Rotation is now a compare-and-swap and the database picks the winner. Known
residual, commented in place: the winner can insert its replacement after a
loser's revocation sweep has read the table, so one pair may survive. What is
guaranteed is that at most one caller succeeds and the replayed token is dead.
Closing the rest needs a per-user "sessions valid from" watermark checked during
token validation, which is a schema change and deliberately not done here.

Register answered 409 for a known address, which is a direct enumeration
oracle - and login only ran BCrypt when the account existed, so a missing one
answered measurably faster. Register now answers 202 with no body either way and
emails the real owner instead; login verifies against a dummy hash when the
address is unknown. Measured against a running instance: register 0.296s new
versus 0.286s duplicate, login 0.29s both.

Fixing those exposed two more, both found by measuring rather than reading:

Sending mail inline made the response wait on SMTP, which put the oracle
straight back through the clock. With no mail server reachable, forgot-password
answered 200 in 15ms for an unknown address and *500 in 4.2s* for a known one -
enumeration by status code, and pre-existing. Register showed the same 4.1s
tell. Requests now enqueue and a background service does the round trip, so no
response time or status depends on whether an address exists.

Register also hashed the password only on the non-duplicate path, which after
the queue fix left the oracle inverted: a duplicate returned in 5ms because it
skipped 250ms of BCrypt. The hash is now computed before the existence check.

Two smaller repairs alongside: forgot-password left every previous code live,
and AttemptCount is per row, so each request handed out another five guesses at
the same six-digit space - superseded codes are now retired. And a concurrent
duplicate registration raced the unique index into a 500 instead of the silent
path, which is now caught on SQLSTATE 23505.

ResetPasswordAsync no longer relies on a revocation helper's SaveChangesAsync to
persist the new password.

The API contract changes: POST /auth/register returns 202 with no body instead
of 201 plus the created user. Nothing consumed the body - the mobile client
already logged in immediately afterwards, and that login is still what
distinguishes the two cases for whoever actually holds the password.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv
rghvgrv merged commit d261682 into main Aug 7, 2026
2 checks passed
@rghvgrv
rghvgrv deleted the fix/auth-hardening branch August 7, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant