From cc3636cea94468ec7645ad7e313acd35dd6fd84f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 16:00:43 +0100 Subject: [PATCH] fix: a device revoke cannot lose to a concurrent refresh The revoke now reads the device's jti with UPDATE ... RETURNING under the row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a session revoked meanwhile, so the refresh answers the blacklisted-token 401 without a token. A hand-over error fails the refresh closed instead of leaving the session on a stale jti. --- README.md | 6 +-- classes/device_auth.go | 42 +++++++++++++++--- controllers/api_controller.go | 53 +++++++++++++++------- controllers/devices.go | 49 ++++++++++++--------- device_auth_test.go | 82 +++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index ba4932e..dcca709 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported. - **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it. - **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter. -- **Device authorization.** A device without a keyboard (a TV, a desktop app) signs in by QR code or short code, PHP's flow with its paths and bodies. It calls `POST /devices/initiate` (no session; optional `device_name`, at most 255 characters) and gets `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`: a 64-character token, a short code `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, the page to show as a QR code (`golem15.user.device_auth.auth_url`, default `{frontend_url}/device/scan/{token}`) and the expiry (`golem15.user.device_auth.ttl_minutes`, default 5). A signed-in user opens that page, or types the short code, and the site calls `POST /devices/authorize` with the user's session token and `short_code` (any case, hyphen optional) or `token`; there is no second-factor check, the user already passed it at sign-in. The device polls `GET /devices/status/{token}`: `pending`, then on the first poll after the authorization `{"success":true,"data":{"status":"confirmed","user_id",…,"message":"Device authorized - complete login","token":,"user":}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. A signed-in user lists their devices with `GET /devices` (`{"success":true,"devices":[{"id","device_name","device_ip","authorized_at","last_activity","is_current"}]}`, confirmed and used sessions, most recent activity first; `device_name` is the stored name or one parsed from the user agent; `is_current` marks the session whose token the request carries) and revokes one with `DELETE /devices/{id}` (`{"success":true,"message":"Device revoked successfully"}`, 404 `{"error":"Device not found"}` for another user's session). A revoke marks the session `revoked` and blacklists the jti of the session token the device holds for the token's whole refresh window, so the device is signed out at once and cannot refresh. `POST /refresh` moves a used session's `jwt_jti` to the refreshed token (`classes.HandOverDeviceJTI`), so a device that refreshed before the revoke is signed out too. With `golem15.user.jwt.blacklist_grace` above 0, two concurrent refreshes of one token can fork a device's token lineage and only one fork is followed; a password change still ends both through `tokens_valid_after`. The device routes check the session inside the handler and answer PHP's 401 `{"error":"Unauthorized"}`. +- **Device authorization.** A device without a keyboard (a TV, a desktop app) signs in by QR code or short code, PHP's flow with its paths and bodies. It calls `POST /devices/initiate` (no session; optional `device_name`, at most 255 characters) and gets `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`: a 64-character token, a short code `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, the page to show as a QR code (`golem15.user.device_auth.auth_url`, default `{frontend_url}/device/scan/{token}`) and the expiry (`golem15.user.device_auth.ttl_minutes`, default 5). A signed-in user opens that page, or types the short code, and the site calls `POST /devices/authorize` with the user's session token and `short_code` (any case, hyphen optional) or `token`; there is no second-factor check, the user already passed it at sign-in. The device polls `GET /devices/status/{token}`: `pending`, then on the first poll after the authorization `{"success":true,"data":{"status":"confirmed","user_id",…,"message":"Device authorized - complete login","token":,"user":}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. A signed-in user lists their devices with `GET /devices` (`{"success":true,"devices":[{"id","device_name","device_ip","authorized_at","last_activity","is_current"}]}`, confirmed and used sessions, most recent activity first; `device_name` is the stored name or one parsed from the user agent; `is_current` marks the session whose token the request carries) and revokes one with `DELETE /devices/{id}` (`{"success":true,"message":"Device revoked successfully"}`, 404 `{"error":"Device not found"}` for another user's session). A revoke marks the session `revoked` and blacklists the jti of the session token the device holds for the token's whole refresh window, so the device is signed out at once and cannot refresh. `POST /refresh` moves a used session's `jwt_jti` to the refreshed token (`classes.FollowDeviceJTI`), so a device that refreshed before the revoke is signed out too. The revoke reads the jti with its own `UPDATE … RETURNING`, under the row lock the refresh's hand-over takes, so a revoke and a refresh that race cannot leave the device signed in: a refresh that comes second finds the session revoked and answers 401 `{"error":"Could not refresh token","msg":"The token has been blacklisted"}` without a token. The hand-over fails closed: a database error answers the opaque 500 instead of a token the session does not follow. With `golem15.user.jwt.blacklist_grace` above 0, two concurrent refreshes of one token can fork a device's token lineage and only one fork is followed; a password change still ends both through `tokens_valid_after`. The device routes check the session inside the handler and answer PHP's 401 `{"error":"Unauthorized"}`. - **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. - **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage). - **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them. @@ -255,7 +255,7 @@ Packages: - `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation). - `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin(ctx, attempt)`), `LoginGates` with `NewLoginGates()`, `Add(gate)` and `Check(ctx, attempt)`, the login-gate seam (see Login gates), and the sources `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor` and `LoginSourceDevice`. - `LoginEvent` / `LoginEventName` (see Login event). - - `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURL(app, token)`, `DeviceDisplayName(stored, userAgent)` and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti; `POST /refresh` calls it), the device authorization helpers; device tokens are hashed with `HashAPIToken`. + - `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURL(app, token)`, `DeviceDisplayName(stored, userAgent)`, `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`. - `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook). - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. @@ -355,6 +355,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker. -The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a deactivated or banned user's session becomes revoked); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay. +The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a deactivated or banned user's session becomes revoked); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceRevokeRacesRefresh` (a refresh of a session revoked after the blacklist check is refused without a token, and a device refreshing in a tight loop while it is revoked ends with its last token refused); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay. The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/classes/device_auth.go b/classes/device_auth.go index 79e3a13..c788cea 100644 --- a/classes/device_auth.go +++ b/classes/device_auth.go @@ -132,13 +132,41 @@ func DeviceDisplayName(stored *string, userAgent string) string { // HandOverDeviceJTI moves a used device session from the jti of a session // token to the jti of the token that refreshed it, and stamps its // last_activity_at, so revoking the device still reaches the token the -// device holds now. POST /refresh calls it after a successful refresh; +// device holds now. It is FollowDeviceJTI without the revoked report; // empty values do nothing. func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error { - if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI { - return nil - } - return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions - SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW() - WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error + _, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI) + return err +} + +// FollowDeviceJTI moves a used device session from oldJTI, the jti of the +// session token a refresh presented, to newJTI, the jti of the token it +// minted, and stamps last_activity_at. It reports revoked when no used +// session moved but a session with oldJTI exists in another status: the +// device was revoked after the refresh passed the blacklist check, so the +// caller must refuse the refresh and drop the new token. The move is one +// guarded UPDATE that takes the session's row lock, as the revoke's +// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke +// that runs after the move blacklists newJTI, and a move that runs after +// the revoke matches nothing and reports revoked. POST /refresh calls it +// and fails closed on an error. Empty or equal values do nothing. +func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) { + if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI { + return false, nil + } + res := db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions + SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW() + WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI) + if res.Error != nil { + return false, res.Error + } + if res.RowsAffected > 0 { + return false, nil + } + var stale int64 + if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions + WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil { + return false, err + } + return stale > 0, nil } diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 3b932e7..95c8e55 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -300,29 +300,52 @@ func Refresh(app *backpack.App) http.HandlerFunc { }) return } + // A device session follows its token through refresh (D-12b), so a + // revoke blacklists the token the device holds now. Reading the old + // jti unverified is safe: bouncer.Refresh has just verified it. The + // hand-over and the revoke take the session's row lock, so whichever + // runs second sees the other: a device revoked after bouncer.Refresh + // checked the blacklist is refused here with the blacklisted-token + // answer and the new token is never sent. The hand-over fails + // closed: a database error answers the opaque 500 (the presented + // token is already rotated out), never a token the device row does + // not follow. With golem15.user.jwt.blacklist_grace above 0 two + // concurrent refreshes of one token can fork a device's lineage and + // only one fork is followed; a password change still ends both + // (tokens_valid_after). Log lines carry no token or jti. + if oldJTI := unverifiedJTI(raw); oldJTI != "" { + _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret) + if err != nil { + pluginLog.Error("golem15.user: read the refreshed token", "user_id", userID, "err", err) + writeOpaque500(w) + return + } + revoked, err := classes.FollowDeviceJTI(r.Context(), gdb, oldJTI, newJTI) + if err != nil { + pluginLog.Error("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err) + writeOpaque500(w) + return + } + if revoked { + writeJSON(w, http.StatusUnauthorized, map[string]any{ + "error": "Could not refresh token", + "msg": refreshBlacklistedMsg, + }) + return + } + } // A failed last_seen write is logged and never fails the refresh. if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil { pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err) } - // A device session follows its token through refresh (D-12b), so a - // revoke blacklists the token the device holds now. Reading the old - // jti unverified is safe: bouncer.Refresh has just verified it. With - // golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of - // one token can fork a device's lineage and only one fork is - // followed; a password change still ends both (tokens_valid_after). - // A failure is logged without any token or jti and never fails the - // refresh. - if oldJTI := unverifiedJTI(raw); oldJTI != "" { - if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil { - if err := classes.HandOverDeviceJTI(r.Context(), gdb, oldJTI, newJTI); err != nil { - pluginLog.Warn("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err) - } - } - } writeSession(w, app, rotate, next, map[string]any{}) } } +// refreshBlacklistedMsg is bouncer.Refresh's message for a blacklisted +// token, which POST /refresh also answers for a revoked device session. +const refreshBlacklistedMsg = "The token has been blacklisted" + // unverifiedJTI returns the jti claim of a token without verifying it, "" // when there is none. Only call it on a token that was verified already. func unverifiedJTI(raw string) string { diff --git a/controllers/devices.go b/controllers/devices.go index 85a6f1a..994c666 100644 --- a/controllers/devices.go +++ b/controllers/devices.go @@ -458,12 +458,15 @@ func DeviceIndex(app *backpack.App) http.HandlerFunc { // device sessions. In one transaction the row is marked revoked and the jti // of the session token the device holds (followed through refresh) is // added to the JWT blacklist for the token's whole refresh window, so the -// device is signed out at once and cannot refresh. A blacklist failure -// answers the opaque 500 and leaves the row as it was. Success answers -// PHP's body with success true and the message Device revoked -// successfully, also for a row already revoked; a row of another user, or -// none, answers 404 with the error Device not found; no session answers -// 401 {"error":"Unauthorized"}. +// device is signed out at once and cannot refresh. The jti is the one the +// revoking UPDATE returns, read under the row lock a concurrent refresh's +// hand-over also takes (classes.FollowDeviceJTI), so a refresh that moved +// the session first is blacklisted and one that comes after is refused. A +// blacklist failure answers the opaque 500 and leaves the row as it was. +// Success answers PHP's body with success true and the message Device +// revoked successfully, also for a row already revoked; a row of another +// user, or none, answers 404 with the error Device not found; no session +// answers 401 {"error":"Unauthorized"}. func DeviceDestroy(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) @@ -480,22 +483,24 @@ func DeviceDestroy(app *backpack.App) http.HandlerFunc { notFound() return } - var row models.DeviceAuthSession - res := gdb.WithContext(r.Context()).Where("id = ? AND user_id = ?", id, user.ID).Limit(1).Find(&row) - if res.Error != nil { - writeOpaque500(w) - return - } - if res.RowsAffected == 0 { - notFound() - return - } + found := false err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error { - if err := tx.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW() - WHERE id = ? AND user_id = ?`, row.ID, user.ID).Error; err != nil { + // The jti current at the revoke, under the row lock: never one + // read before a concurrent refresh moved the session on. + var revoked []struct { + JWTJTI *string `gorm:"column:jwt_jti"` + } + if err := tx.WithContext(ctx).Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW() + WHERE id = ? AND user_id = ? + RETURNING jwt_jti`, id, user.ID).Scan(&revoked).Error; err != nil { return err } - if row.JWTJTI == nil || *row.JWTJTI == "" { + if len(revoked) == 0 { + return nil + } + found = true + jti := revoked[0].JWTJTI + if jti == nil || *jti == "" { return nil } bl, ok := app.Lookup[bouncer.BlacklistStore]() @@ -503,13 +508,17 @@ func DeviceDestroy(app *backpack.App) http.HandlerFunc { return errors.New("golem15.user: no JWT blacklist store is published") } now := time.Now() - return bl.Add(ctx, *row.JWTJTI, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now) + return bl.Add(ctx, *jti, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now) }) if err != nil { pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err) writeOpaque500(w) return } + if !found { + notFound() + return + } writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"}) } } diff --git a/device_auth_test.go b/device_auth_test.go index 54f58f0..8c7ec94 100644 --- a/device_auth_test.go +++ b/device_auth_test.go @@ -1038,6 +1038,88 @@ func TestDeviceRevokeAfterRefresh(t *testing.T) { }) } +// TestDeviceRevokeRacesRefresh pins that a revoke and a refresh of the same +// device cannot leave the device signed in, whichever commits first. A +// refresh whose device session was revoked after the token passed the +// blacklist check (the revoke committed between bouncer.Refresh and the +// hand-over) is refused with the blacklisted-token answer and hands out no +// token; a revoke reads the jti under the row lock the hand-over takes, so +// a device that refreshes in a tight loop while it is revoked always ends +// with its last token refused. +func TestDeviceRevokeRacesRefresh(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + const blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}` + + t.Run("revoked_after_the_blacklist_check", func(t *testing.T) { + _, session := envSession(t, env, "drace-check") + token, jti, id := envDevice(t, env, session, "Race desk") + // The state a revoke leaves when it commits after bouncer.Refresh + // checked the blacklist: the row is revoked and the token the + // refresh presented is not blacklisted (yet). + envSetDevice(t, env, id, map[string]any{"status": models.DeviceStatusRevoked}) + wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil), http.StatusUnauthorized, blacklistedBody) + var row models.DeviceAuthSession + if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.JWTJTI == nil || *row.JWTJTI != jti || row.Status != models.DeviceStatusRevoked { + t.Fatalf("a refused refresh moved the revoked row: %+v", row) + } + if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("the presented token still works: %d", rec.Code) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) + }) + + t.Run("refresh_loop_and_revoke", func(t *testing.T) { + for round := range 8 { + // A user per round keeps the refreshes inside the per-user + // user-api bucket. + _, session := envSession(t, env, "drace-loop-"+strconv.Itoa(round)) + token, _, id := envDevice(t, env, session, "Loop desk") + var ( + wg sync.WaitGroup + last = token + status int + ) + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil) + if status = rec.Code; status != http.StatusOK { + return + } + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" { + t.Errorf("refresh %s: %v", rec.Body.String(), err) + return + } + last = body.Token + } + }() + time.Sleep(time.Duration(round) * 2 * time.Millisecond) + wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody) + wg.Wait() + if status != http.StatusUnauthorized { + t.Fatalf("round %d: the refresh loop ended with %d, want the 401 of a revoked device", round, status) + } + if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", last, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("round %d: the device's last token still works after the revoke: %d", round, rec.Code) + } + if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("round %d: the device's last token still refreshes after the revoke: %d", round, rec.Code) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) + } + }) +} + // TestDeviceTokensAtRest pins D-06 for device sessions: the token column is // the SHA-256 hex of the token, never the token; no column holds the token // or the session JWT the device received; the model hides token,