The auth middleware trusted any unexpired, well-signed access token, so
logout, session termination and admin blocks had no effect until the
15-minute token expired. The middleware now validates that the token's
session is still active on every request (SessionRepo.GetByID), and
blocking a user deactivates all of their sessions, immediately revoking
their outstanding access tokens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three related auth weaknesses:
- Access and refresh tokens were structurally identical, so a 30-day
refresh token was accepted as a bearer access token. Tokens now carry a
"typ" claim; the access path rejects refresh tokens and /refresh rejects
access tokens.
- Login stored the hash of a throwaway refresh token (sid=0) but returned
a re-issued one, so the stored hash never matched and /refresh always
401'd. Tokens are no longer re-issued: the refresh token is located by
hash and carries no session id, while the access token embeds the real
session id. A random jti keeps tokens unique within the same second.
- Login skipped bcrypt for unknown users (a timing oracle) and returned
403 for blocked accounts before checking the password (leaking account
existence). It now always runs a bcrypt comparison and verifies the
password before disclosing blocked state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Login: bcrypt credential validation, session creation, JWT pair issuance.
Logout/TerminateSession: soft-delete session (is_active = false).
Refresh: token rotation — deactivate old session, issue new pair.
ListSessions: marks IsCurrent by comparing session IDs.
ParseAccessToken: for use by auth middleware.
Claims carry uid (int16), adm (bool), sid (int). Refresh tokens are
stored as SHA-256 hashes; raw tokens never reach the database.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>