diff --git a/README.md b/README.md index 8de71d1..ebf4aee 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 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"}`. +- **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/{short_code}`) 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` (the device token, or the short code the default page carries); 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`. The device token is the device's polling secret: the default `auth_url` carries the short code instead, so a photo of the QR code, the page's URL in a browser history or a `Referer` gives an onlooker nothing to poll with, and only a poll with the `User-Agent` the device initiated with receives the session token (another client gets `{"success":true,"data":{"status":"confirmed"}}` and takes nothing). A template with `{token}` still works but puts the polling secret on screen. 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)`, `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`. + - `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `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`. @@ -291,7 +291,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. | | `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password, Reset 2FA and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. | | `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `backend_links.page_path` + `#code=`, and it fills `{frontend_url}` in `device_auth.auth_url`. Empty falls back to `app.url`; a trailing slash is dropped. | -| `golem15.user.device_auth.auth_url` | `{frontend_url}/device/scan/{token}` | The `auth_url` a device shows as its QR code, where a signed-in user authorizes it. `{frontend_url}` is `golem15.user.frontend_url` (else `app.url`, no trailing slash) and `{token}` the device token. | +| `golem15.user.device_auth.auth_url` | `{frontend_url}/device/scan/{short_code}` | The `auth_url` a device shows as its QR code, where a signed-in user authorizes it. `{frontend_url}` is `golem15.user.frontend_url` (else `app.url`, no trailing slash), `{short_code}` the session's short code and `{token}` the device token. `POST /devices/authorize` takes the short code in `token` too. Keep `{token}` out of it: the device token is also the secret the device collects its session token with. | | `golem15.user.device_auth.ttl_minutes` | `5` | Minutes a device session can be authorized and handed over. | | `golem15.user.backend_links.page_path` | `/link-admin` | The site page that confirms an account link. A value without a leading `/` gets one. | | `golem15.user.backend_links.request_ttl` | `10m` | How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. | @@ -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); `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 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 with `{short_code}` as the default, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer with an `auth_url` that carries the short code and not the token, 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, the short code in the token field, 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 poll with another `User-Agent` than the initiating device's takes nothing and the device still collects its token; 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 0267081..1321796 100644 --- a/classes/device_auth.go +++ b/classes/device_auth.go @@ -15,8 +15,11 @@ import ( // recovery-code alphabet: no 0, 1, I, L or O). const DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" -// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset. -const defaultDeviceAuthURL = "{frontend_url}/device/scan/{token}" +// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset. It +// carries the short code, never the device token: the token is the secret +// the device polls the session token with, and a QR code, a scan URL in a +// browser history or a Referer must not hand it to an onlooker. +const defaultDeviceAuthURL = "{frontend_url}/device/scan/{short_code}" // defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset. const defaultDeviceAuthTTL = 5 * time.Minute @@ -88,12 +91,21 @@ func DeviceAuthTTL(app *backpack.App) time.Duration { return defaultDeviceAuthTTL } -// DeviceAuthURL is the auth_url a new device shows as its QR code: the -// template golem15.user.device_auth.auth_url (default -// {frontend_url}/device/scan/{token}) with {frontend_url} replaced by -// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash) -// and {token} by the device token. +// DeviceAuthURL is DeviceAuthURLFor without a short code: {short_code} +// is filled with the device token, as the template's only key. func DeviceAuthURL(app *backpack.App, token string) string { + return DeviceAuthURLFor(app, token, "") +} + +// DeviceAuthURLFor is the auth_url a new device shows as its QR code: the +// template golem15.user.device_auth.auth_url (default +// {frontend_url}/device/scan/{short_code}) with {frontend_url} replaced by +// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash), +// {short_code} by the session's short code and {token} by the device +// token. POST /devices/authorize accepts the short code in its token field +// too, so a page that posts what its URL carries as token works with +// either template. An empty shortCode fills {short_code} with the token. +func DeviceAuthURLFor(app *backpack.App, token, shortCode string) string { tpl := "" if app != nil && app.Config != nil { tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url")) @@ -101,7 +113,10 @@ func DeviceAuthURL(app *backpack.App, token string) string { if tpl == "" { tpl = defaultDeviceAuthURL } - return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{token}", token).Replace(tpl) + if shortCode == "" { + shortCode = token + } + return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{short_code}", shortCode, "{token}", token).Replace(tpl) } // DeviceDisplayName is the name a device list shows (PHP's device_name diff --git a/config/config.yaml b/config/config.yaml index f4bd72f..35b9c3d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -45,8 +45,11 @@ frontend_url: "" device_auth: # The page a new device shows as its QR code, where a signed-in user # authorizes it. {frontend_url} is frontend_url (else app.url, no trailing - # slash) and {token} the device token. - auth_url: "{frontend_url}/device/scan/{token}" + # slash), {short_code} the session's short code and {token} the device + # token. Keep {token} out of it: the device token is also the secret the + # device collects its session token with, and whoever sees the QR code or + # the page's URL could take the session. + auth_url: "{frontend_url}/device/scan/{short_code}" # Minutes a device session can be authorized and handed over. ttl_minutes: 5 backend_links: diff --git a/controllers/devices.go b/controllers/devices.go index 15c2dd1..28a0289 100644 --- a/controllers/devices.go +++ b/controllers/devices.go @@ -149,7 +149,7 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc { "data": map[string]any{ "token": token, "short_code": *row.ShortCode, - "auth_url": classes.DeviceAuthURL(app, token), + "auth_url": classes.DeviceAuthURLFor(app, token, *row.ShortCode), "expires_at": wire.Time{Time: expires}, }, }) @@ -166,7 +166,10 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc { // guarded confirmed-to-used UPDATE decides which poll that is, so of // concurrent polls exactly one gets the token; the others, and every later // poll, answer {"success":true,"data":{"status":"used"}}. Other statuses -// answer {"success":true,"data":{"status":…}}. +// answer {"success":true,"data":{"status":…}}. Only a poll with the +// User-Agent the device initiated with receives the token: another client +// that learned the device token answers {"status":"confirmed"} and takes +// nothing, so the device still collects its session. func DeviceStatus(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) @@ -182,7 +185,7 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"}) return } - if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) { + if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) && sameDeviceClient(r, row) { if handOverDevice(w, r, app, gdb, secret, row) { return } @@ -198,6 +201,17 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc { } } +// sameDeviceClient reports whether r comes with the User-Agent the device +// initiated the session with (none for none), the binding of the token +// hand-over to the initiating client. +func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool { + stored := "" + if row.DeviceUserAgent != nil { + stored = *row.DeviceUserAgent + } + return r.UserAgent() == stored +} + // deviceByToken loads the session whose token hash matches token. func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) { var row models.DeviceAuthSession @@ -323,7 +337,9 @@ func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) boo // 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 -// spaces optional, any case) or by its token (the QR code). A short_code of +// spaces optional, any case) or by its token (the QR code page). The token +// field also takes the short code, which the default auth_url carries in +// place of the device token. A short_code of // other than 8 characters after removing hyphens answers 422 {"error": // "Please enter a valid 8-character code"}; an unknown, expired, used, // revoked or foreign session answers 400 {"error":"Invalid or expired @@ -371,10 +387,22 @@ func DeviceAuthorize(app *backpack.App) http.HandlerFunc { } found = res.RowsAffected == 1 } else if _, has := fields["token"]; has { - row, found, err = deviceByToken(r, gdb, strField(fields, "token")) - if err != nil { - writeOpaque500(w) - return + raw := strField(fields, "token") + if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode { + // The QR page of the default auth_url carries the short + // code; a 64-character device token never normalizes. + res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row) + if res.Error != nil { + writeOpaque500(w) + return + } + found = res.RowsAffected == 1 + } else { + row, found, err = deviceByToken(r, gdb, raw) + if err != nil { + writeOpaque500(w) + return + } } } if !found { diff --git a/device_auth_test.go b/device_auth_test.go index 422d92d..853958f 100644 --- a/device_auth_test.go +++ b/device_auth_test.go @@ -58,6 +58,28 @@ func TestDeviceAuthURL(t *testing.T) { t.Fatalf("after %s=%v: auth_url = %q, want %q", step.key, step.value, got, step.want) } } + // DeviceAuthURLFor fills {short_code}; the default template carries the + // short code and never the device token (WR-01). + fresh := backpack.New(sessionConfig(t)) + if err := fresh.Config.Set("golem15.user.frontend_url", "https://www.example.test"); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + tpl string + want string + }{ + {"", "https://www.example.test/device/scan/ABCD-EFGH"}, + {"{frontend_url}/device/scan/{short_code}", "https://www.example.test/device/scan/ABCD-EFGH"}, + {"{frontend_url}/pair?code={short_code}&t={token}", "https://www.example.test/pair?code=ABCD-EFGH&t=tok-1_A"}, + {"{frontend_url}/device/scan/{token}", "https://www.example.test/device/scan/tok-1_A"}, + } { + if err := fresh.Config.Set("golem15.user.device_auth.auth_url", c.tpl); err != nil { + t.Fatal(err) + } + if got := classes.DeviceAuthURLFor(fresh, "tok-1_A", "ABCD-EFGH"); got != c.want { + t.Fatalf("template %q: auth_url = %q, want %q", c.tpl, got, c.want) + } + } for _, c := range []struct { minutes any want time.Duration @@ -236,7 +258,8 @@ func TestDeviceInitiate(t *testing.T) { if len(token) != 64 || !deviceShortCodeRE.MatchString(code) { t.Fatalf("token %q, short code %q", token, code) } - if data["auth_url"] != "https://app.example.test/device/scan/"+token { + // The QR code carries the short code, never the polling secret. + if data["auth_url"] != "https://app.example.test/device/scan/"+code || strings.Contains(data["auth_url"].(string), token) { t.Fatalf("auth_url = %v", data["auth_url"]) } expires, err := time.Parse("2006-01-02T15:04:05-07:00", data["expires_at"].(string)) @@ -572,6 +595,22 @@ func TestDeviceAuthorize(t *testing.T) { wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": "ABC", "token": dev2.Token}), http.StatusUnprocessableEntity, deviceCodeLengthBody) }) + t.Run("token_field_takes_the_short_code", func(t *testing.T) { + // The default auth_url page carries the short code and posts it as + // token; any form NormalizeDeviceShortCode accepts works. + for _, form := range []func(string) string{ + func(c string) string { return c }, + func(c string) string { return strings.ToLower(strings.ReplaceAll(c, "-", "")) }, + } { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": form(dev.ShortCode)}), http.StatusOK, deviceAuthorizedBody) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusConfirmed { + t.Fatalf("status = %s", row.Status) + } + } + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": "ZZZZ-ZZZZ"}), http.StatusBadRequest, deviceInvalidBody) + }) + t.Run("bound_to_another_user", func(t *testing.T) { dev := envNewDevice(t, env) wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) @@ -791,6 +830,39 @@ func TestDeviceHandOverConcurrent(t *testing.T) { }) } + t.Run("only_the_initiating_client_collects_the_token", func(t *testing.T) { + const tvAgent = "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)" + rec := env.send(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", []byte(`{}`), "application/json", map[string]string{"User-Agent": tvAgent}) + var started struct { + Data deviceInitiated `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil || started.Data.Token == "" { + t.Fatalf("initiate %s: %v", rec.Body.String(), err) + } + dev := started.Data + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody) + before := events.count() + // An onlooker that learned the device token polls with another + // client: the session stays confirmed and no token leaves. + for _, agent := range []string{"", "curl/8.5.0", tvAgent + " "} { + rec := env.send(t, http.MethodGet, "/_user/api/v1/devices/status/"+dev.Token, "", nil, "application/json", map[string]string{"User-Agent": agent}) + wantBody(t, rec, http.StatusOK, deviceStatusBody("confirmed")) + } + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusConfirmed || row.JWTJTI != nil { + t.Fatalf("a foreign poll changed the row: %+v", row) + } + if events.count() != before { + t.Fatal("a foreign poll fired a login event") + } + rec = env.send(t, http.MethodGet, "/_user/api/v1/devices/status/"+dev.Token, "", nil, "application/json", map[string]string{"User-Agent": tvAgent}) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token":"`) { + t.Fatalf("the initiating client got no token: %d %s", rec.Code, rec.Body.String()) + } + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusUsed { + t.Fatalf("status = %s", row.Status) + } + }) + t.Run("deleted_user_cascades", func(t *testing.T) { user, userSession := envSession(t, env, "dho-gone") dev := envNewDevice(t, env) diff --git a/device_flow_smoke_test.go b/device_flow_smoke_test.go index 3e82f79..80b5656 100644 --- a/device_flow_smoke_test.go +++ b/device_flow_smoke_test.go @@ -202,7 +202,7 @@ func TestDeviceFlowSmoke(t *testing.T) { } dev := initiateDevice(t, app, "Living room TV") - if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") { + if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.HasSuffix(dev.AuthURL, "/device/scan/"+dev.ShortCode) || strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") { t.Fatalf("initiate data = %+v", dev) } row := deviceRow(t, gdb, dev.Token)