feat: device authorization by QR or short code

PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
This commit is contained in:
2026-10-26 10:14:48 +01:00
parent 3d4c8d2160
commit 4e3e5b05b5
13 changed files with 1036 additions and 24 deletions

View File

@@ -12,7 +12,7 @@ Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `gole
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links` and `golem15_user_backend_link_requests`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links`, `golem15_user_backend_link_requests` and `golem15_user_device_auth_sessions`, the JSON API under `/_user/api/v1`, and the Users, User Groups and Organisations screens of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
## Features
@@ -24,6 +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`.
- **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.
@@ -132,6 +133,9 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
| `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"}`) |
`/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.
@@ -209,6 +213,12 @@ Migrations added for the frontend account link, all additive:
|----|------|
| `202610220001_create_golem15_user_backend_links` | the tables `golem15_user_backend_links` (`user_id` unique, cascade FK to `users`; `backend_user_id` unique, cascade FK to `backend_users`; `linked_by` FK to `backend_users`, set null on delete; `created_at`) and `golem15_user_backend_link_requests` (`code_hash` `CHAR(64)` unique, `backend_user_id` cascade FK, `expires_at`, `used_at`, `created_at`). The `backend_users` keys are added when that table exists, which it does whenever the application migrates through `lagoon.Migrate`. |
Migration added for device authorization, additive:
| ID | Adds |
|----|------|
| `202610260001_create_device_auth_sessions` | the table `golem15_user_device_auth_sessions` with PHP's columns (`token` holding the SHA-256 hex of the device token, unique; `short_code` unique; `user_id` nullable, cascade FK to `users`; `status` checked against the five statuses; `expires_at`, `device_ip`, `device_user_agent`, `device_name`, `auth_ip`, `confirmed_at`, `used_at`, `session_id`, `last_activity_at`) plus `jwt_jti`, indexed on `user_id`, `status`, `expires_at` and `jwt_jti`; rollback drops it |
Packages:
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
@@ -223,6 +233,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).
- `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:
@@ -242,10 +253,11 @@ 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`.
- `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`.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
- `updates`: the gormigrate migrations, returned by `updates.All()`.
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
@@ -276,7 +288,9 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
| `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 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=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
| `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=<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.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. |
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register` (only `/register` while `golem15.user.password.public_routes` is false). Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. |

128
classes/device_auth.go Normal file
View File

@@ -0,0 +1,128 @@
package classes
import (
"crypto/rand"
"encoding/base64"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// DeviceShortCodeAlphabet is the alphabet of device short codes (PHP's
// 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}"
// defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset.
const defaultDeviceAuthTTL = 5 * time.Minute
// NewDeviceToken returns a fresh device token: 48 bytes from crypto/rand as
// unpadded base64url, 64 characters. Store only its HashAPIToken.
func NewDeviceToken() (string, error) {
var b [48]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// NewDeviceShortCode returns a fresh short code XXXX-XXXX, eight characters
// drawn uniformly from DeviceShortCodeAlphabet with crypto/rand (rejection
// sampling, no modulo bias).
func NewDeviceShortCode() (string, error) {
n := len(DeviceShortCodeAlphabet)
limit := 256 - 256%n // largest multiple of n that fits a byte
out := make([]byte, 0, 9)
var buf [16]byte
for len(out) < 9 {
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
for _, c := range buf {
if int(c) >= limit {
continue
}
if len(out) == 4 {
out = append(out, '-')
}
out = append(out, DeviceShortCodeAlphabet[int(c)%n])
if len(out) == 9 {
break
}
}
}
return string(out), nil
}
// NormalizeDeviceShortCode turns what a user typed into the stored form
// XXXX-XXXX: upper case, spaces and hyphens removed, exactly eight
// characters of DeviceShortCodeAlphabet. It reports false otherwise.
func NormalizeDeviceShortCode(s string) (string, bool) {
s = strings.ToUpper(s)
s = strings.NewReplacer(" ", "", "-", "").Replace(s)
if len(s) != 8 {
return "", false
}
for i := 0; i < len(s); i++ {
if strings.IndexByte(DeviceShortCodeAlphabet, s[i]) < 0 {
return "", false
}
}
return s[:4] + "-" + s[4:], true
}
// DeviceAuthTTL is how long a device authorization session can be
// authorized and handed over: golem15.user.device_auth.ttl_minutes, default
// 5.
func DeviceAuthTTL(app *backpack.App) time.Duration {
if app != nil && app.Config != nil {
if mins := app.Config.Int("golem15.user.device_auth.ttl_minutes"); mins > 0 {
return time.Duration(mins) * time.Minute
}
}
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.
func DeviceAuthURL(app *backpack.App, token string) string {
tpl := ""
if app != nil && app.Config != nil {
tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url"))
}
if tpl == "" {
tpl = defaultDeviceAuthURL
}
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{token}", token).Replace(tpl)
}
// DeviceDisplayName is the name a device list shows (PHP's device_name
// accessor): the stored name, else a name parsed from the user agent
// (iPhone, iPad, Android Device, Windows PC, Mac, Linux PC), else Unknown
// Device.
func DeviceDisplayName(stored *string, userAgent string) string {
if stored != nil && *stored != "" {
return *stored
}
switch {
case strings.Contains(userAgent, "iPhone"):
return "iPhone"
case strings.Contains(userAgent, "iPad"):
return "iPad"
case strings.Contains(userAgent, "Android"):
return "Android Device"
case strings.Contains(userAgent, "Windows"):
return "Windows PC"
case strings.Contains(userAgent, "Macintosh"):
return "Mac"
case strings.Contains(userAgent, "Linux"):
return "Linux PC"
}
return "Unknown Device"
}

View File

@@ -39,9 +39,16 @@ privileged_groups:
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
turnstile: ""
# The site this plugin's links point to (the account link page,
# <frontend_url><backend_links.page_path>#code=...). Empty falls back to
# app.url.
# <frontend_url><backend_links.page_path>#code=..., and the device
# authorization page of device_auth.auth_url). Empty falls back to app.url.
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}"
# Minutes a device session can be authorized and handed over.
ttl_minutes: 5
backend_links:
# How long a link request an administrator starts on My account stays
# usable (a Go duration). The code is one-shot.

View File

@@ -1071,12 +1071,18 @@ func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[st
}
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
return token, err
}
// sessionTTL is the lifetime of a session JWT: golem15.user.jwt.ttl
// minutes, default 60.
func sessionTTL(app *backpack.App) time.Duration {
ttl := 60 * time.Minute
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
}
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)
return token, err
return ttl
}
// RefreshTTL is the refresh window of a session token:

409
controllers/devices.go Normal file
View File

@@ -0,0 +1,409 @@
package controllers
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-user-plugin/classes"
"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/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)
// deviceTable is the device authorization session table.
const deviceTable = "golem15_user_device_auth_sessions"
// deviceInsertAttempts bounds the retries of an initiate whose fresh short
// code (or token) collided with a stored one.
const deviceInsertAttempts = 5
// deviceCleanupBatch bounds the stale sessions one initiate deletes.
const deviceCleanupBatch = 500
// writeDeviceUnauthorized is the PHP device controller's 401 body, which
// differs from the session routes' {"error":true,"message":"Unauthorized"}.
func writeDeviceUnauthorized(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
}
// deviceCaller resolves the signed-in user of a device route that needs one
// (authorize, list, revoke). It reads and checks the session token as the
// plugin's jwt guard does (bearer header, then the session cookies; the
// blacklist and tokens_valid_after apply) and also returns the token's jti.
// Anything else answers PHP's 401 {"error":"Unauthorized"}.
func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) {
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil || principal.ID == 0 {
writeDeviceUnauthorized(w)
return nil, "", false
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
writeDeviceUnauthorized(w)
return nil, "", false
}
jti := ""
if raw := classes.SessionToken(r); raw != "" {
if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil {
jti = id
}
}
return &user, jti, true
}
// deviceClientIP is the client address device rows record: surf.ClientIP
// with http.trusted_proxies, as /login and /register use.
func deviceClientIP(app *backpack.App, r *http.Request) string {
return surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
}
// DeviceInitiate is POST /devices/initiate, called without a session by the
// device that wants to sign in. It stores a pending session (the SHA-256 of
// a 64-character token, a short code XXXX-XXXX, expires_at now plus
// golem15.user.device_auth.ttl_minutes, the device's address, user agent and
// optional device_name of at most 255 characters) and answers
// {"success":true,"data":{"token","short_code","auth_url","expires_at"}}.
// Each call first deletes at most 500 pending or expired sessions that
// expired more than an hour ago.
func DeviceInitiate(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
name := strField(fields, "device_name")
if utf8.RuneCountInString(name) > 255 {
writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}})
return
}
ctx := r.Context()
// Stale sessions go in bounded batches, so the unauthenticated route
// cannot grow the table without limit (a failure is only logged).
if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN (
SELECT id FROM `+deviceTable+`
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'
LIMIT ?)`, deviceCleanupBatch).Error; err != nil {
pluginLog.Warn("golem15.user: device session cleanup", "err", err)
}
ip := deviceClientIP(app, r)
ua := r.UserAgent()
expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second)
var (
token string
row models.DeviceAuthSession
)
for attempt := 0; ; attempt++ {
token, err = classes.NewDeviceToken()
if err != nil {
writeOpaque500(w)
return
}
code, err := classes.NewDeviceShortCode()
if err != nil {
writeOpaque500(w)
return
}
row = models.DeviceAuthSession{
TokenHash: classes.HashAPIToken(token),
ShortCode: &code,
Status: models.DeviceStatusPending,
ExpiresAt: &expires,
}
if ip != "" {
row.DeviceIP = &ip
}
if ua != "" {
row.DeviceUserAgent = &ua
}
if name != "" {
row.DeviceName = &name
}
err = gdb.WithContext(ctx).Create(&row).Error
if err == nil {
break
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts {
pluginLog.Error("golem15.user: store device session", "err", err)
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"data": map[string]any{
"token": token,
"short_code": *row.ShortCode,
"auth_url": classes.DeviceAuthURL(app, token),
"expires_at": wire.Time{Time: expires},
},
})
}
}
// DeviceStatus is GET /devices/status/{token}, polled by the waiting
// device. It answers 404 {"error":"Session not found"} for an unknown token
// and PHP's flat {"success":true,"status":"expired","message":…} for a
// pending or confirmed session past expires_at (which it marks expired).
// The first poll after a signed-in user authorized the session receives a
// session token: {"success":true,"data":{"status":"confirmed","user_id",
// "message":"Device authorized - complete login","token","user"}}. One
// 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":…}}.
func DeviceStatus(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
row, found, err := deviceByToken(r, gdb, r.PathValue("token"))
if err != nil {
writeOpaque500(w)
return
}
if !found {
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 handOverDevice(w, r, app, gdb, secret, row) {
return
}
// Another poll won the hand-over or the session just expired:
// answer what the row says now, never a token.
row, found, err = deviceByID(r, gdb, row.ID)
if err != nil || !found {
writeOpaque500(w)
return
}
}
writeDeviceStatus(w, r, gdb, row)
}
}
// 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
if token == "" {
return row, false, nil
}
res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row)
return row, res.RowsAffected == 1, res.Error
}
func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) {
var row models.DeviceAuthSession
res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row)
return row, res.RowsAffected == 1, res.Error
}
// writeDeviceStatus answers a status poll that hands over no token. A
// pending or confirmed session past expires_at is marked expired (used and
// revoked sessions never are); an expired session answers PHP's flat
// expired body.
func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) {
expire := row.ExpiredAt(time.Now())
if expire || row.Status == models.DeviceStatusExpired {
if expire {
if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil {
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"status": models.DeviceStatusExpired,
"message": "Authorization session has expired",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}})
}
// handOverDevice gives the confirmed session's user a session token for the
// device (D-12a). It reports false, having written nothing, when another
// poll already took the session or it expired meanwhile; the token it
// minted is then dropped unsent.
func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool {
ctx := r.Context()
var user models.User
res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user)
if res.Error != nil {
writeOpaque500(w)
return true
}
refused := res.RowsAffected == 0
if !refused {
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
if err != nil {
writeOpaque500(w)
return true
}
refused = banned[user.ID]
}
if refused {
// A deactivated, deleted or banned user signs no device in.
if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil {
writeOpaque500(w)
return true
}
return false
}
token, jti, err := mintWithJTI(app, secret, &user, requestURL(r))
if err != nil {
writeOpaque500(w)
return true
}
payload, err := apiArray(ctx, app, &user)
if err != nil {
writeOpaque500(w)
return true
}
res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+`
SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW()
WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID)
if res.Error != nil {
writeOpaque500(w)
return true
}
if res.RowsAffected != 1 {
return false
}
if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil {
pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err)
}
if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil {
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"data": map[string]any{
"status": models.DeviceStatusConfirmed,
"user_id": user.ID,
"message": "Device authorized - complete login",
"token": token,
"user": payload,
},
})
return true
}
// 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
// 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
// code"}; success, and repeating it for a session the caller already
// confirmed, answers {"success":true,"message":"Device authorized
// successfully"}. Without a session it answers 401 {"error":"Unauthorized"}.
// The fields are read from the body only, never the query string.
func DeviceAuthorize(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
}
fields, err := readBodyFields(r)
if err != nil {
writeOpaque500(w)
return
}
invalid := func() {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"})
}
var (
row models.DeviceAuthSession
found bool
)
if _, has := fields["short_code"]; has {
raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "")
if len(raw) != 8 {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"})
return
}
code, valid := classes.NormalizeDeviceShortCode(raw)
if !valid {
invalid()
return
}
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 if _, has := fields["token"]; has {
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
if err != nil {
writeOpaque500(w)
return
}
}
if !found {
invalid()
return
}
res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+`
SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW()
WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`,
user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected != 1 {
now, ok, err := deviceByID(r, gdb, row.ID)
if err != nil || !ok {
writeOpaque500(w)
return
}
again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now())
if !again {
if now.ExpiredAt(time.Now()) {
_ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error
}
invalid()
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"})
}
}
// nullableString is nil for "", else s.
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
// mintWithJTI mints the session JWT as mintFor does and also returns its
// jti, which device sessions store.
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))
}

320
device_flow_smoke_test.go Normal file
View File

@@ -0,0 +1,320 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`)
// TestDeviceCodesSmoke pins the device token, the short code, its
// normalisation and the auth_url template. No database.
func TestDeviceCodesSmoke(t *testing.T) {
seen := map[string]bool{}
for range 1000 {
tok, err := classes.NewDeviceToken()
if err != nil {
t.Fatal(err)
}
if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
t.Fatalf("token %q is not 64 base64url characters", tok)
}
if seen[tok] {
t.Fatalf("token %q repeated", tok)
}
seen[tok] = true
code, err := classes.NewDeviceShortCode()
if err != nil {
t.Fatal(err)
}
if !deviceShortCodeRE.MatchString(code) {
t.Fatalf("short code %q", code)
}
}
for in, want := range map[string]string{
"abcd-efgh": "ABCD-EFGH",
"ABCDEFGH": "ABCD-EFGH",
" ab cd ef gh ": "ABCD-EFGH",
"2345-6789": "2345-6789",
} {
if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want {
t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want)
}
}
for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} {
if got, ok := classes.NormalizeDeviceShortCode(in); ok {
t.Fatalf("normalize %q accepted as %q", in, got)
}
}
app := backpack.New(sessionConfig(t))
if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" {
t.Fatalf("auth_url without urls = %q", got)
}
if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" {
t.Fatalf("auth_url from app.url = %q", got)
}
if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" {
t.Fatalf("auth_url from frontend_url = %q", got)
}
if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" {
t.Fatalf("auth_url from a custom template = %q", got)
}
if got := classes.DeviceAuthTTL(app); got != 5*time.Minute {
t.Fatalf("default ttl = %s", got)
}
tv := "Living room TV"
for _, c := range []struct {
stored *string
ua string
want string
}{
{&tv, "Mozilla/5.0 (iPhone)", tv},
{nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"},
{nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"},
{nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"},
{nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
{nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
{nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
{nil, "curl/8.0", "Unknown Device"},
} {
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want)
}
}
}
// deviceCall runs one device handler with an optional bearer token and JSON
// body; pathToken fills {token} or {id}.
func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.Host = "example.com"
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV")
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
type deviceInitiated struct {
Token string `json:"token"`
ShortCode string `json:"short_code"`
AuthURL string `json:"auth_url"`
ExpiresAt string `json:"expires_at"`
}
func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated {
t.Helper()
body := `{}`
if name != "" {
body = `{"device_name":"` + name + `"}`
}
rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
}
var out struct {
Success bool `json:"success"`
Data deviceInitiated `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success {
t.Fatalf("initiate body %s: %v", rec.Body.String(), err)
}
return out.Data
}
func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) {
t.Helper()
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
return rec.Code, body
}
func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder {
t.Helper()
return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil)
}
func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession {
t.Helper()
var row models.DeviceAuthSession
if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
func statusData(body map[string]any) map[string]any {
data, _ := body["data"].(map[string]any)
return data
}
// TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over
// on Postgres.
func TestDeviceFlowSmoke(t *testing.T) {
if testing.Short() {
t.Skip("Postgres (testcontainers)")
}
app, gdb := sessionApp(t, "0s")
email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test"
session := loginToken(t, app, gdb, email)
var owner models.User
if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil {
t.Fatal(err)
}
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") {
t.Fatalf("initiate data = %+v", dev)
}
row := deviceRow(t, gdb, dev.Token)
if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil ||
row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil {
t.Fatalf("stored row = %+v", row)
}
if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" {
t.Fatalf("pending status = %d %v", code, body)
}
if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" {
t.Fatalf("unknown status = %d %v", code, body)
}
// Authorize needs a session, answering PHP's 401 body.
if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String())
}
if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` {
t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String())
}
lower := strings.ToLower(dev.ShortCode)
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` {
t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String())
}
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK {
t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String())
}
// The first poll after confirmation receives the token, once.
code, body := deviceStatus(t, app, dev.Token)
data := statusData(body)
if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) {
t.Fatalf("hand-over = %d %v", code, body)
}
jwtToken, _ := data["token"].(string)
sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret)
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
t.Fatalf("device token sub=%q err=%v", sub, err)
}
if user, _ := data["user"].(map[string]any); user["email"] != email {
t.Fatalf("hand-over user = %v", data["user"])
}
row = deviceRow(t, gdb, dev.Token)
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil {
t.Fatalf("row after hand-over = %+v", row)
}
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil {
t.Fatalf("second poll = %v", body)
}
// The used session is past nothing: even past expires_at it stays used.
if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil {
t.Fatal(err)
}
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" {
t.Fatalf("used session past expires_at = %v", body)
}
// A used session cannot be authorized again.
if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` {
t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String())
}
// Ten concurrent polls of a confirmed session yield exactly one token.
race := initiateDevice(t, app, "")
if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK {
t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String())
}
var (
wg sync.WaitGroup
mu sync.Mutex
tokens int
used int
)
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
_, body := deviceStatus(t, app, race.Token)
d := statusData(body)
mu.Lock()
defer mu.Unlock()
if tok, _ := d["token"].(string); tok != "" {
tokens++
} else if d["status"] == "used" {
used++
}
}()
}
wg.Wait()
if tokens != 1 || used != 9 {
t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used)
}
// A pending session past expires_at answers PHP's flat expired body.
stale := initiateDevice(t, app, "")
if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String())
}
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token})
if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` {
t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String())
}
if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired {
t.Fatalf("expired row status = %s", row.Status)
}
// device_name is bounded.
long := strings.Repeat("n", 256)
if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String())
}
}

View File

@@ -0,0 +1,66 @@
package models
import "time"
// Device authorization session statuses, PHP's DeviceAuthSession constants.
// pending becomes confirmed when a signed-in user authorizes the device,
// confirmed becomes used on the device's first poll (which receives the
// session token), used becomes revoked when the user revokes the device,
// and pending or confirmed become expired once expires_at passes. used and
// revoked never become expired.
const (
DeviceStatusPending = "pending"
DeviceStatusConfirmed = "confirmed"
DeviceStatusUsed = "used"
DeviceStatusExpired = "expired"
DeviceStatusRevoked = "revoked"
)
// DeviceAuthSession is one QR or short-code device authorization
// (golem15_user_device_auth_sessions). TokenHash is the SHA-256 hex of the
// device token (column token, PHP's name); the token itself is never
// stored. JWTJTI is the jti of the session token the device received,
// followed through refresh.
type DeviceAuthSession struct {
ID uint `gorm:"column:id;primaryKey"`
TokenHash string `gorm:"column:token" json:"-"`
ShortCode *string `gorm:"column:short_code" json:"-"`
UserID *uint `gorm:"column:user_id"`
Status string `gorm:"column:status"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
DeviceIP *string `gorm:"column:device_ip"`
DeviceUserAgent *string `gorm:"column:device_user_agent"`
DeviceName *string `gorm:"column:device_name"`
AuthIP *string `gorm:"column:auth_ip"`
ConfirmedAt *time.Time `gorm:"column:confirmed_at"`
UsedAt *time.Time `gorm:"column:used_at"`
SessionID *string `gorm:"column:session_id"`
LastActivityAt *time.Time `gorm:"column:last_activity_at"`
JWTJTI *string `gorm:"column:jwt_jti" json:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (DeviceAuthSession) TableName() string { return "golem15_user_device_auth_sessions" }
func (DeviceAuthSession) Fillable() []string {
return []string{
"token", "short_code", "user_id", "status", "expires_at", "device_ip",
"device_user_agent", "device_name", "auth_ip", "confirmed_at", "used_at",
"session_id", "last_activity_at", "jwt_jti",
}
}
func (DeviceAuthSession) Hidden() []string { return []string{"token", "short_code", "jwt_jti"} }
// ExpiredAt reports whether a pending or confirmed session is past
// expires_at at now. A used, revoked or expired session, and one without
// expires_at, is never newly expired.
func (s DeviceAuthSession) ExpiredAt(now time.Time) bool {
if s.Status != DeviceStatusPending && s.Status != DeviceStatusConfirmed {
return false
}
return s.ExpiresAt != nil && !s.ExpiresAt.After(now)
}
func init() { Register(DeviceAuthSession{}) }

View File

@@ -97,9 +97,11 @@ 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.
if len(base) != 23 {
t.Fatalf("unset: %d routes, want 23", len(base))
// 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))
}
seen := 0
for _, route := range base {
@@ -111,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) != 20 {
t.Fatalf("base without the password routes has %d routes, want 20", len(off))
if len(off) != 23 {
t.Fatalf("base without the password routes has %d routes, want 23", len(off))
}
for _, c := range []struct {

View File

@@ -48,6 +48,11 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
// Device authorization (QR and short code). The routes authenticate
// inside the handlers, as PHP's controller does.
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))
})
return nil
}

View File

@@ -0,0 +1,50 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// deviceAuthSessionMigrations create golem15_user_device_auth_sessions, the
// QR and short-code device authorization sessions, with the PHP plugin's
// table and column names (the v2.1.0 to v2.7.1 migrations: short_code, a
// nullable user_id and expires_at). The token column holds the SHA-256 hex
// of the 64-character device token, never the token itself; jwt_jti is
// added: the jti of the session token the device received, so a revoke can
// blacklist it. session_id is kept for a Winter import and stays NULL.
var deviceAuthSessionMigrations = []*gormigrate.Migration{
{
ID: "202610260001_create_device_auth_sessions",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE golem15_user_device_auth_sessions (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
short_code VARCHAR(9) NULL UNIQUE,
user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'used', 'expired', 'revoked')),
expires_at TIMESTAMPTZ NULL,
device_ip VARCHAR(255) NULL,
device_user_agent TEXT NULL,
device_name VARCHAR(255) NULL,
auth_ip VARCHAR(255) NULL,
confirmed_at TIMESTAMPTZ NULL,
used_at TIMESTAMPTZ NULL,
session_id VARCHAR(255) NULL,
last_activity_at TIMESTAMPTZ NULL,
jwt_jti VARCHAR(64) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX golem15_user_device_auth_sessions_user_id_index ON golem15_user_device_auth_sessions (user_id);
CREATE INDEX golem15_user_device_auth_sessions_status_index ON golem15_user_device_auth_sessions (status);
CREATE INDEX golem15_user_device_auth_sessions_expires_at_index ON golem15_user_device_auth_sessions (expires_at);
CREATE INDEX golem15_user_device_auth_sessions_jwt_jti_index ON golem15_user_device_auth_sessions (jwt_jti)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS golem15_user_device_auth_sessions").Error
},
},
}
func init() { Register(deviceAuthSessionMigrations...) }

View File

@@ -53,6 +53,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
"202610160002_add_handle_to_users",
"202610210001_add_users_email_verified_at",
"202610220001_create_golem15_user_backend_links",
"202610260001_create_device_auth_sessions",
}
if len(ids) != len(want) {
t.Fatalf("history = %v", ids)
@@ -63,9 +64,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
}
}
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
// jwt blacklist, throttle, extend-users, then organisations.
for range 13 {
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email proof, handle, OAuth
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, extend-users,
// then organisations.
for range 14 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -54,8 +54,9 @@ func TestUserGroupsMigration(t *testing.T) {
t.Fatalf("users_groups primary key = %q", pk)
}
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 9 {
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
// identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 10 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
t.Fatalf("defaults = %+v", row)
}
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
// jwt blacklist, throttle, then extend-users.
for range 12 {
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, then extend-users.
for range 13 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -88,8 +88,9 @@ func TestCreateUserThrottleMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 11 {
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 12 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -120,8 +121,9 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 10 {
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
// identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 11 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}