Skip to content

fix(security): invalidate sessions on disable, password change and 2FA reset - #131

Open
fjaeckel wants to merge 2 commits into
mainfrom
security/session-invalidation
Open

fix(security): invalidate sessions on disable, password change and 2FA reset#131
fjaeckel wants to merge 2 commits into
mainfrom
security/session-invalidation

Conversation

@fjaeckel

Copy link
Copy Markdown
Owner

Summary

Fixes a Medium severity session-lifecycle gap: nothing could revoke an access token early.

AuthMiddleware only checked a token's signature and expiry. It never verified the user still existed, was enabled, or that the token predated a security event. Deleting refresh tokens — the only revocation that existed — merely stops a session being extended; the outstanding 15-minute access token kept working.

Verified against a running instance

Account disable does not cut access. After an admin disabled a user (disabled=t, refresh tokens deleted), the victim's access token still returned:

  • GET /users/me200
  • GET /flights200
  • POST /flights201 (created a flight)

Password change does not cut existing sessions. After change-password (204), the old access token still returned 200/201. A user rotating their password because they suspect compromise did not evict the attacker.

Changes

  • Migration 000050 adds users.tokens_valid_after — a session epoch. Access tokens issued at or before that instant are rejected. IssuedAt has second resolution, so a token minted in the same second as the event is also rejected rather than slipping through.
  • AuthMiddlewareWithState additionally rejects tokens for disabled (403) or deleted (401) users. AuthMiddleware keeps its old signature and delegates with a nil state function, so existing callers and tests are unaffected.
  • The epoch is bumped on password change, admin account disable, and admin 2FA reset. The latter two now also delete refresh tokens.

⚠️ Tradeoff worth reviewing

The state callback performs an indexed primary-key lookup per authenticated request. The admin path already did one per request, and the connection pool is bounded — but this is a real change to the hot path.

If it shows up in profiling, a short-TTL (a few seconds) cache keyed by user ID is the natural next step. I deliberately left it out to keep this change reviewable; happy to add it if you'd prefer it upfront.

Tests

  • Disabled user's token → 403; enabled user → 200
  • Token issued before the epoch → 401; issued after200
  • Deleted/unknown user → 401
  • nil state preserves prior behaviour (so AuthMiddleware callers are unchanged)

All existing user-repo mocks gained the new interface method; sqlmock column lists updated for the new column.

Full suite: 24 packages passing, gofmt clean.

Related

Pairs with the logout PR (#130): that revokes the refresh token, this one makes the access token die with it. Together they give a user a reliable way to end a session.

🤖 Generated with Claude Code


Generated by Claude Code

claude and others added 2 commits July 26, 2026 15:48
…A reset

AuthMiddleware only checked an access token's signature and expiry. It never
verified the user still existed, was enabled, or that the token predated a
security event. Deleting refresh tokens -- the only revocation that existed --
merely stops a session being EXTENDED; the outstanding 15-minute access token
kept working.

Verified against a running instance:
  - After an admin disabled a user (disabled=t, refresh tokens deleted), the
    victim's access token still returned 200 from GET /users/me and GET
    /flights, and created a flight (POST /flights -> 201).
  - After a password change (204), the OLD access token still returned 200/201.
    A user rotating their password because they suspect compromise did not
    evict the attacker.

Adds a session epoch: users.tokens_valid_after (migration 000050). Access
tokens issued at or before that instant are rejected. IssuedAt has second
resolution, so a token minted in the same second as the event is also rejected
rather than slipping through.

AuthMiddlewareWithState additionally rejects tokens for disabled or deleted
users. AuthMiddleware keeps its old signature and delegates with a nil state
function, so existing callers and tests are unaffected.

The epoch is bumped on password change (AuthService.ChangePassword), admin
account disable, and admin 2FA reset. Admin disable and 2FA reset now also
delete refresh tokens.

Tradeoff: the state callback performs an indexed primary-key lookup per
authenticated request. The admin path already did one per request. If this
shows up in profiling, a short-TTL cache keyed by user ID is the natural next
step -- deliberately left out here to keep the change reviewable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukWfyJMY28qv2CJjxFvKF
…nvalidation

# Conflicts:
#	internal/api/handlers/admin_users.go
#	internal/api/handlers/handlers_test.go
#	internal/repository/interfaces.go
#	internal/repository/postgres/user.go
#	internal/service/auth_test.go
#	internal/service/flight_signature_test.go
#	internal/service/notification_custom_test.go
#	internal/service/twofactor_encryption_test.go
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.

2 participants