diff --git a/README.md b/README.md index 5233df4..bbdd7e7 100644 --- a/README.md +++ b/README.md @@ -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":,"user":}}`, exactly once (one guarded `UPDATE` moves the session from `confirmed` to `used`, so of concurrent polls one gets the token), then `used`. A pending or confirmed session past its expiry answers PHP's flat `{"success":true,"status":"expired","message":"Authorization session has expired"}`; a used or revoked session never turns into expired. The statuses are `pending`, `confirmed`, `used`, `expired` and `revoked`. Only the SHA-256 of the device token is stored (column `token`, `golem15_user_device_auth_sessions`), with the jti of the session token the device received (`jwt_jti`). The hand-over fires the login event with source `device`. Each initiate deletes up to 500 pending or expired sessions that expired over an hour ago. The token travels in the status URL, so a reverse proxy that logs paths records it; it is one-time and lives `ttl_minutes`. - **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=`. 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=`, 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. | diff --git a/classes/device_auth.go b/classes/device_auth.go new file mode 100644 index 0000000..4380160 --- /dev/null +++ b/classes/device_auth.go @@ -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" +} diff --git a/config/config.yaml b/config/config.yaml index 020f447..f4bd72f 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -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, -# #code=...). Empty falls back to -# app.url. +# #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. diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 1bc4a65..a40c8e1 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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: diff --git a/controllers/devices.go b/controllers/devices.go new file mode 100644 index 0000000..d2577b1 --- /dev/null +++ b/controllers/devices.go @@ -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)) +} diff --git a/device_flow_smoke_test.go b/device_flow_smoke_test.go new file mode 100644 index 0000000..3e82f79 --- /dev/null +++ b/device_flow_smoke_test.go @@ -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()) + } +} diff --git a/models/device_auth_session.go b/models/device_auth_session.go new file mode 100644 index 0000000..1377756 --- /dev/null +++ b/models/device_auth_session.go @@ -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{}) } diff --git a/password_routes_test.go b/password_routes_test.go index c60c7dc..544ff49 100644 --- a/password_routes_test.go +++ b/password_routes_test.go @@ -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 { diff --git a/routes.go b/routes.go index c6076e9..2fcfc68 100644 --- a/routes.go +++ b/routes.go @@ -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 } diff --git a/updates/202610260001_create_device_auth_sessions.go b/updates/202610260001_create_device_auth_sessions.go new file mode 100644 index 0000000..694d2a3 --- /dev/null +++ b/updates/202610260001_create_device_auth_sessions.go @@ -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...) } diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 05bf4f4..8da4211 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -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) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go index 856c80d..4d85989 100644 --- a/updates/user_groups_test.go +++ b/updates/user_groups_test.go @@ -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) } diff --git a/updates/user_session_test.go b/updates/user_session_test.go index 9b6ed08..b6af55b 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -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) }