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.
This commit is contained in:
@@ -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":<session JWT>,"user":<payload>}}`, 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":<session JWT>,"user":<payload>}}`, 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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
183
device_revoke_smoke_test.go
Normal file
183
device_revoke_smoke_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user