diff --git a/README.md b/README.md index 6af5f90..8de71d1 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes. - **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates. - **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none. -- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices; a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing. +- **Password-change hook.** A password reset, the user's own `POST /change-password` and an administrator setting a password on the Users screen fire `classes.AccessRevokedEvent` inside the write's transaction (see Access revocation); `(*AccessRevokedEvent).PasswordChanged()` reports these three reasons. A two-factor plugin listens to it to revoke trusted devices, and this plugin revokes the users' pending and confirmed device sessions (see Device authorization); a listener error rolls the password write back. The bcrypt rehash on login and a new account fire nothing. - **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.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"}`. +- **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 user who is gone, deactivated or banned, or whose `tokens_valid_after` is later than the authorization (a password change or `RevokeUserAccess` after the user authorized the device), gets no token: the session becomes `revoked`. Every `AccessRevokedEvent` (a ban, a password reset, change or an administrator's new password) also revokes the user's pending and confirmed sessions inside the write's transaction (`classes.RevokeDeviceSessions`). 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)`, `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`. + - `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURL(app, token)`, `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `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, and a privileged-group member refused with 403 before `Reset` without `golem15.users.manage_privileged_groups`. 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); `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 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); `TestDeviceAccessRevocation` (a password change revokes the user's confirmed sessions and keeps used ones and other users' sessions, and a session authorized before `tokens_valid_after` hands nothing over without the event); `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 c788cea..0267081 100644 --- a/classes/device_auth.go +++ b/classes/device_auth.go @@ -139,6 +139,20 @@ func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) return err } +// RevokeDeviceSessions marks the pending and confirmed device sessions of +// userIDs revoked, so none of them hands a session token over any more. +// Used sessions keep their status: their tokens end through +// tokens_valid_after or a revoke. The plugin calls it on db, the write's +// transaction, for every AccessRevokedEvent (a ban or a password change). +func RevokeDeviceSessions(ctx context.Context, db *gorm.DB, userIDs ...uint) error { + if db == nil || len(userIDs) == 0 { + return nil + } + return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions + SET status = 'revoked', updated_at = NOW() + WHERE user_id IN ? AND status IN ('pending', 'confirmed')`, userIDs).Error +} + // 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 diff --git a/controllers/devices.go b/controllers/devices.go index 994c666..15c2dd1 100644 --- a/controllers/devices.go +++ b/controllers/devices.go @@ -241,7 +241,10 @@ func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row // handOverDevice gives the confirmed session's user a session token for the // device (D-12a). It reports false, having written nothing, when another // poll already took the session or it expired meanwhile; the token it -// minted is then dropped unsent. +// minted is then dropped unsent. A user who is gone, deactivated or +// banned, or whose tokens_valid_after (a password change, a revocation) +// is later than the authorization, gets nothing: the session becomes +// revoked, as a token minted at the authorization would be refused. func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool { ctx := r.Context() var user models.User @@ -257,7 +260,7 @@ func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, g writeOpaque500(w) return true } - refused = banned[user.ID] + refused = banned[user.ID] || authorizedBeforeCutoff(&user, row) } if refused { // A deactivated, deleted or banned user signs no device in. @@ -307,6 +310,16 @@ func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, g return true } +// authorizedBeforeCutoff reports whether row was confirmed before the +// user's tokens_valid_after, compared as the jwt guard compares a token's +// whole-second iat. +func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) bool { + if user.TokensValidAfter == nil || row.ConfirmedAt == nil { + return false + } + return row.ConfirmedAt.Truncate(time.Second).Before(*user.TokensValidAfter) +} + // DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid, // not blacklisted session token; no second-factor check, D-12c) binds a // pending device session to themselves by its short_code (hyphens and diff --git a/device_auth_test.go b/device_auth_test.go index 8c7ec94..422d92d 100644 --- a/device_auth_test.go +++ b/device_auth_test.go @@ -1038,6 +1038,79 @@ func TestDeviceRevokeAfterRefresh(t *testing.T) { }) } +// TestDeviceAccessRevocation pins that a password change or a ban ends the +// device sessions a user authorized before it: AccessRevokedEvent revokes +// the user's pending and confirmed sessions in the write's transaction (a +// used session keeps its status), and a confirmed session authorized +// before the user's tokens_valid_after hands nothing over even when no +// event fired (an application calling RevokeUserAccess alone). +func TestDeviceAccessRevocation(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + + t.Run("password_change_revokes_confirmed_sessions", func(t *testing.T) { + user, session := envSession(t, env, "dar-change") + if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("has_self_set_password", true).Error; err != nil { + t.Fatal(err) + } + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + _, _, usedID := envDevice(t, env, session, "Used desk") + bystander, bystanderSession := envSession(t, env, "dar-bystander") + other := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, bystanderSession, map[string]string{"token": other.Token}), http.StatusOK, deviceAuthorizedBody) + + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{ + "current_password": "correct-horse", "password": "dar-changed-password", "password_confirmation": "dar-changed-password", + }) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusRevoked { + t.Fatalf("the confirmed session survived the password change: %+v", row) + } + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) + var used models.DeviceAuthSession + if err := env.gdb.Where("id = ?", usedID).Take(&used).Error; err != nil { + t.Fatal(err) + } + if used.Status != models.DeviceStatusUsed { + t.Fatalf("the used session changed status: %+v", used) + } + // Another user's confirmed session is untouched and still hands over. + if row := envDeviceRow(t, env, other.Token); row.Status != models.DeviceStatusConfirmed || row.UserID == nil || *row.UserID != bystander.ID { + t.Fatalf("a bystander's session changed: %+v", row) + } + if rec := envPoll(t, env, other.Token); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) { + t.Fatalf("the bystander's device got no token: %d %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("cutoff_after_the_authorization_hands_nothing_over", func(t *testing.T) { + user, session := envSession(t, env, "dar-cutoff") + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + row := envDeviceRow(t, env, dev.Token) + // RevokeUserAccess without the event: only tokens_valid_after moves. + cutoff := row.ConfirmedAt.Truncate(time.Second).Add(time.Second) + if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil { + t.Fatal(err) + } + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusRevoked || row.JWTJTI != nil { + t.Fatalf("row = %+v", row) + } + // A device authorized after the cutoff signs in. + if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(-time.Minute).Truncate(time.Second)).Error; err != nil { + t.Fatal(err) + } + later := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": later.Token}), http.StatusOK, deviceAuthorizedBody) + if rec := envPoll(t, env, later.Token); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) { + t.Fatalf("a device authorized after the cutoff got no token: %d %s", rec.Code, rec.Body.String()) + } + }) +} + // 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 diff --git a/plugin.go b/plugin.go index 5a84821..27af92c 100644 --- a/plugin.go +++ b/plugin.go @@ -116,6 +116,7 @@ func (p *Plugin) Boot(app *backpack.App) error { if err := p.bootBackendLinks(app); err != nil { return err } + p.bootDeviceRevocation(app) if err := p.bootAPITokenScopes(app); err != nil { return err } @@ -125,6 +126,31 @@ func (p *Plugin) Boot(app *backpack.App) error { return classes.RegisterUserAuthorizer(app) } +// bootDeviceRevocation revokes the pending and confirmed device sessions of +// users who lose access (a ban or a password change, AccessRevokedEvent) +// inside the write's transaction, so a device authorized before the change +// is never handed a session token after it; a failed write rolls the change +// back. +func (p *Plugin) bootDeviceRevocation(app *backpack.App) { + if app == nil || app.Events == nil { + return + } + app.Events.Listen(p.ID(), func(ctx context.Context, ev *classes.AccessRevokedEvent) error { + if ev == nil || len(ev.UserIDs) == 0 { + return nil + } + db := ev.Tx + if db == nil { + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return fmt.Errorf("golem15.user: revoke device sessions: database is not configured") + } + db = gdb + } + return classes.RevokeDeviceSessions(ctx, db, ev.UserIDs...) + }) +} + // bootAPITokenScopes compiles the member token scope catalog from the // activated plugins (pact.HasFrontendTokenScopes), checks that every phrase // key it names exists, and publishes it. A bad declaration fails boot.