From 3b221342922d5f06e548650d9394cf27267e8127 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 10:19:25 +0100 Subject: [PATCH] feat: device list and revoke with the jti followed through refresh GET /devices lists the caller's confirmed and used device sessions (PHP's fields, device_name from the stored name or the user agent, is_current for the session the request's token belongs to). DELETE /devices/{id} marks the session revoked and blacklists the device token's jti for its whole refresh window in one transaction. POST /refresh hands a used session's jwt_jti over to the refreshed token (classes.HandOverDeviceJTI), so a device that refreshed before the revoke is signed out too. --- README.md | 8 +- classes/device_auth.go | 16 +++ controllers/api_controller.go | 26 +++++ controllers/devices.go | 114 +++++++++++++++++++++ device_revoke_smoke_test.go | 183 ++++++++++++++++++++++++++++++++++ password_routes_test.go | 12 +-- routes.go | 3 + 7 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 device_revoke_smoke_test.go diff --git a/README.md b/README.md index bbdd7e7..5326e94 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`. +- **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"}`. - **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. @@ -136,6 +136,8 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle | `POST /devices/initiate` | `controllers.DeviceInitiate` | | `GET /devices/status/{token}` | `controllers.DeviceStatus` | | `POST /devices/authorize` | `controllers.DeviceAuthorize` (session token checked in the handler, 401 `{"error":"Unauthorized"}`) | +| `GET /devices` | `controllers.DeviceIndex` (session token checked in the handler) | +| `DELETE /devices/{id}` | `controllers.DeviceDestroy` (session token checked in the handler; `id` is `[0-9]+`) | `/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies. @@ -233,7 +235,7 @@ Packages: - `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks, consults the login gates before it and fires the login event after it minted the token. - `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. A social-login plugin calls it before it issues a token. - `FireLoginEvent(ctx, app, user, source)` fires `classes.LoginEvent` and returns the listeners' error; call it after the token was minted and log the error. - - `DeviceInitiate`, `DeviceStatus` and `DeviceAuthorize`, the device authorization handlers (see Device authorization). + - `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization). - `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on). - `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`). - `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has: @@ -253,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)` and `DeviceDisplayName(stored, userAgent)`, the device authorization helpers; device tokens are hashed with `HashAPIToken`. + - `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`. - `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`. diff --git a/classes/device_auth.go b/classes/device_auth.go index 4380160..79e3a13 100644 --- a/classes/device_auth.go +++ b/classes/device_auth.go @@ -1,12 +1,14 @@ package classes import ( + "context" "crypto/rand" "encoding/base64" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" + "gorm.io/gorm" ) // DeviceShortCodeAlphabet is the alphabet of device short codes (PHP's @@ -126,3 +128,17 @@ func DeviceDisplayName(stored *string, userAgent string) string { } return "Unknown Device" } + +// 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; +// 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 +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index a40c8e1..3b932e7 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -304,10 +304,36 @@ func Refresh(app *backpack.App) http.HandlerFunc { 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{}) } } +// 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 { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil { + return "" + } + jti, _ := claims["jti"].(string) + return jti +} + // refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh // has already verified and reports whether its user may not slide the // session: not found by a scoped load (missing or deactivated), issued diff --git a/controllers/devices.go b/controllers/devices.go index d2577b1..85a6f1a 100644 --- a/controllers/devices.go +++ b/controllers/devices.go @@ -1,6 +1,7 @@ package controllers import ( + "context" "errors" "net/http" "strconv" @@ -12,6 +13,7 @@ import ( "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" "github.com/jackc/pgx/v5/pgconn" @@ -407,3 +409,115 @@ func nullableString(s string) any { func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) { return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app)) } + +// DeviceIndex is GET /devices: the caller's authorized devices (sessions +// confirmed or used), most recent activity first, as {"success":true, +// "devices":[{id, device_name, device_ip, authorized_at, last_activity, +// is_current}]}. device_name is the stored name or one parsed from the user +// agent (classes.DeviceDisplayName); is_current is true for the session +// whose token is the one the request carries. Without a session it answers +// 401 {"error":"Unauthorized"}. +func DeviceIndex(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + gdb, secret, ok := sessionDeps(w, app) + if !ok { + return + } + user, jti, ok := deviceCaller(w, r, app, gdb, secret) + if !ok { + return + } + var rows []models.DeviceAuthSession + if err := gdb.WithContext(r.Context()). + Where("user_id = ? AND status IN ?", user.ID, []string{models.DeviceStatusConfirmed, models.DeviceStatusUsed}). + Order("last_activity_at DESC NULLS LAST, id DESC"). + Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + devices := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + ua := "" + if row.DeviceUserAgent != nil { + ua = *row.DeviceUserAgent + } + devices = append(devices, map[string]any{ + "id": row.ID, + "device_name": classes.DeviceDisplayName(row.DeviceName, ua), + "device_ip": row.DeviceIP, + "authorized_at": wireTimeOrNil(row.ConfirmedAt), + "last_activity": wireTimeOrNil(row.LastActivityAt), + "is_current": jti != "" && row.JWTJTI != nil && *row.JWTJTI == jti, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"success": true, "devices": wire.Slice(devices)}) + } +} + +// DeviceDestroy is DELETE /devices/{id}: the caller revokes one of their +// 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"}. +func DeviceDestroy(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + gdb, secret, ok := sessionDeps(w, app) + if !ok { + return + } + user, _, ok := deviceCaller(w, r, app, gdb, secret) + if !ok { + return + } + notFound := func() { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Device not found"}) } + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil || id == 0 { + 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 + } + 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 { + return err + } + if row.JWTJTI == nil || *row.JWTJTI == "" { + return nil + } + bl, ok := app.Lookup[bouncer.BlacklistStore]() + if !ok || bl == nil { + 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) + }) + if err != nil { + pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err) + writeOpaque500(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"}) + } +} + +// wireTimeOrNil is t as a +00:00 timestamp, or nil. +func wireTimeOrNil(t *time.Time) any { + if t == nil { + return nil + } + return wire.Time{Time: *t} +} diff --git a/device_revoke_smoke_test.go b/device_revoke_smoke_test.go new file mode 100644 index 0000000..f23ad9f --- /dev/null +++ b/device_revoke_smoke_test.go @@ -0,0 +1,183 @@ +package user + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// envDevice signs a device in through the assembled routes: initiate, +// authorize by session, first poll. It returns the device's session token, +// its jti and the session row id. +func envDevice(t *testing.T, env *adminEnv, session, name string) (string, string, uint) { + t.Helper() + rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", map[string]string{"device_name": name}) + if rec.Code != http.StatusOK { + t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) + } + var started struct { + Data deviceInitiated `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/devices/authorize", session, map[string]string{"token": started.Data.Token}) + rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices/status/"+started.Data.Token, "", nil) + var polled struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" { + t.Fatalf("hand-over %s: %v", rec.Body.String(), err) + } + _, _, _, jti, err := bouncer.VerifyClaims(polled.Data.Token, sessionSecret) + if err != nil { + t.Fatal(err) + } + var row models.DeviceAuthSession + if err := env.gdb.Where("jwt_jti = ?", jti).Take(&row).Error; err != nil { + t.Fatal(err) + } + return polled.Data.Token, jti, row.ID +} + +type deviceListBody struct { + Success bool `json:"success"` + Devices []struct { + ID uint `json:"id"` + DeviceName string `json:"device_name"` + DeviceIP *string `json:"device_ip"` + AuthorizedAt *string `json:"authorized_at"` + LastActivity *string `json:"last_activity"` + IsCurrent bool `json:"is_current"` + } `json:"devices"` +} + +func envDevices(t *testing.T, env *adminEnv, token string) deviceListBody { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", token, nil) + var body deviceListBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body +} + +// TestDeviceRevokeSmoke lists and revokes device sessions through the +// assembled routes, including a device that refreshed its token first. +func TestDeviceRevokeSmoke(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + owner := env.seedUser(t, "dev-owner", nil) + other := env.seedUser(t, "dev-other", nil) + status, session := env.userLogin(t, owner.Email, "correct-horse") + if status != http.StatusOK { + t.Fatalf("owner login = %d", status) + } + otherStatus, otherSession := env.userLogin(t, other.Email, "correct-horse") + if otherStatus != http.StatusOK { + t.Fatalf("other login = %d", otherStatus) + } + + tvToken, _, tvID := envDevice(t, env, session, "Living room TV") + // A second used session with no name and an older activity, a pending + // session of the owner and a device of another user. + hourAgo := time.Now().Add(-time.Hour) + ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)" + oldJTI := "dev-old-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ownerID := owner.ID + phone := models.DeviceAuthSession{TokenHash: "a" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusUsed, + ExpiresAt: &hourAgo, DeviceUserAgent: &ua, ConfirmedAt: &hourAgo, LastActivityAt: &hourAgo, JWTJTI: &oldJTI} + pendingRow := models.DeviceAuthSession{TokenHash: "b" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusPending, ExpiresAt: &hourAgo} + if err := env.gdb.Create(&phone).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Create(&pendingRow).Error; err != nil { + t.Fatal(err) + } + _, _, foreignID := envDevice(t, env, otherSession, "Other TV") + + t.Run("list", func(t *testing.T) { + if rec := env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` { + t.Fatalf("list without a session = %d %s", rec.Code, rec.Body.String()) + } + list := envDevices(t, env, tvToken) + if !list.Success || len(list.Devices) != 2 { + t.Fatalf("list = %+v", list) + } + first, second := list.Devices[0], list.Devices[1] + if first.ID != tvID || first.DeviceName != "Living room TV" || !first.IsCurrent || first.AuthorizedAt == nil || first.LastActivity == nil { + t.Fatalf("first device = %+v", first) + } + if second.ID != phone.ID || second.DeviceName != "iPhone" || second.IsCurrent { + t.Fatalf("second device = %+v", second) + } + for _, d := range envDevices(t, env, session).Devices { + if d.IsCurrent { + t.Fatalf("device %d is current for the owner's own session", d.ID) + } + } + }) + + t.Run("revoke", func(t *testing.T) { + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil) + rec := env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil) + if rec.Body.String() != `{"message":"Device revoked successfully","success":true}` { + t.Fatalf("revoke body = %s", rec.Body.String()) + } + env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil) + env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil) + rec = env.expect(t, http.StatusNotFound, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(foreignID), 10), session, nil) + if rec.Body.String() != `{"error":"Device not found"}` { + t.Fatalf("foreign revoke body = %s", rec.Body.String()) + } + // The id constraint keeps a non-numeric id away from the handler, + // which would answer 401 without a session. + if rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/abc", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("DELETE /devices/abc = %d %s", rec.Code, rec.Body.String()) + } + var row models.DeviceAuthSession + if err := env.gdb.Where("id = ?", tvID).Take(&row).Error; err != nil || row.Status != models.DeviceStatusRevoked { + t.Fatalf("revoked row = %+v %v", row, err) + } + for _, d := range envDevices(t, env, session).Devices { + if d.ID == tvID { + t.Fatal("a revoked device is still listed") + } + } + }) + + t.Run("revoke_after_refresh", func(t *testing.T) { + deskToken, deskJTI, deskID := envDevice(t, env, session, "Desktop") + rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", deskToken, nil) + var refreshed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); err != nil || refreshed.Token == "" { + t.Fatalf("refresh %s: %v", rec.Body.String(), err) + } + _, _, _, newJTI, err := bouncer.VerifyClaims(refreshed.Token, sessionSecret) + if err != nil || newJTI == deskJTI { + t.Fatalf("refreshed jti %q err %v", newJTI, err) + } + var row models.DeviceAuthSession + if err := env.gdb.Where("id = ?", deskID).Take(&row).Error; err != nil || row.JWTJTI == nil || *row.JWTJTI != newJTI { + t.Fatalf("row after refresh = %+v %v, want jti %s", row, err, newJTI) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil) + env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(deskID), 10), session, nil) + env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", deskToken, nil) + env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil) + env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", refreshed.Token, nil) + // The owner's own session is untouched. + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) + }) +} diff --git a/password_routes_test.go b/password_routes_test.go index 544ff49..20ddb65 100644 --- a/password_routes_test.go +++ b/password_routes_test.go @@ -98,10 +98,10 @@ func TestPasswordPublicRoutes(t *testing.T) { // 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST // /backend-link/show and POST /backend-link/confirm (the account link), // GET /token/me and GET /tokens/scopes, plus POST /devices/initiate, - // GET /devices/status/{token} and POST /devices/authorize (device - // authorization). - if len(base) != 26 { - t.Fatalf("unset: %d routes, want 26", len(base)) + // GET /devices/status/{token}, POST /devices/authorize, GET /devices and + // DELETE /devices/{id} (device authorization). + if len(base) != 28 { + t.Fatalf("unset: %d routes, want 28", len(base)) } seen := 0 for _, route := range base { @@ -113,8 +113,8 @@ func TestPasswordPublicRoutes(t *testing.T) { t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys)) } off := withoutPasswordRoutes(base) - if len(off) != 23 { - t.Fatalf("base without the password routes has %d routes, want 23", len(off)) + if len(off) != 25 { + t.Fatalf("base without the password routes has %d routes, want 25", len(off)) } for _, c := range []struct { diff --git a/routes.go b/routes.go index 2fcfc68..47cfbd3 100644 --- a/routes.go +++ b/routes.go @@ -53,6 +53,9 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/devices/initiate", controllers.DeviceInitiate(p.app)) g.Get("/devices/status/{token}", controllers.DeviceStatus(p.app)) g.Post("/devices/authorize", controllers.DeviceAuthorize(p.app)) + g.Get("/devices", controllers.DeviceIndex(p.app)) + g.Delete("/devices/{id}", controllers.DeviceDestroy(p.app)) + g.Where("id", "[0-9]+") }) return nil }