diff --git a/README.md b/README.md index d277562..ba4932e 100644 --- a/README.md +++ b/README.md @@ -355,4 +355,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker. +The device authorization flow is pinned by `TestDeviceCodes` (in `classes`, `-short`: 10,000 tokens and short codes, the alphabet in every position, no modulo bias, the normalization table), `TestDeviceAuthURL` and `TestDeviceDisplayName` (both `-short`: the `auth_url` template, the TTL setting and PHP's user-agent order), and on Postgres through the assembled routes: `TestDeviceInitiate` (PHP's answer, the stored pending row, the TTL, the 255-character `device_name` bound, the cleanup of at most 500 stale pending or expired sessions per call, the retry of an insert whose short code collides with a stored one); `TestDeviceStatus` (unknown, pending, PHP's flat expired body for a pending or confirmed session past `expires_at`, used and revoked sessions that never expire); `TestDeviceAuthorize` (short-code forms, the 422 and 400 refusals, the token path, body-only fields, sessions bound to another user, a repeated authorize, expired, used and revoked sessions, concurrent authorizes by two users binding one, and the 401 for a missing, invalid, foreign-signed or ended session); `TestDeviceHandOverConcurrent` (50 concurrent polls hand one token over with one login event of source `device`; a deactivated or banned user's session becomes revoked); `TestDeviceListAndRevoke` (only confirmed and used sessions, newest activity first, `is_current`, the display name, an empty list as `[]`, 404 for foreign and unknown ids, a repeated revoke, a revoked device signed out); `TestDeviceRevokeAfterRefresh` (the row follows one and two refreshes, a revoke ends the whole token lineage and the blacklist entry outlives the refresh window, a failed refresh leaves the row); `TestDeviceTokensAtRest` (the token column is the SHA-256 hex, no column holds the token or the session JWT). `TestDeviceAuthSessionsMigration` (in `updates`) checks the table's PHP column names plus `jwt_jti`, the types, the unique `token` and `short_code`, the status CHECK, the indexes, the cascade on user delete, the rollback and a second up. The plan 01 smoke tests `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke` and `TestDeviceRevokeSmoke` stay. + The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/classes/device_auth_test.go b/classes/device_auth_test.go new file mode 100644 index 0000000..75cc81d --- /dev/null +++ b/classes/device_auth_test.go @@ -0,0 +1,129 @@ +package classes + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" + "testing" +) + +// TestDeviceCodes pins the device token and short code (D-11, D-06) +// without a database: 10,000 tokens are 64 base64url characters, all +// distinct, and their stored form is the SHA-256 hex; 10,000 short codes +// are XXXX-XXXX over DeviceShortCodeAlphabet with every character of the +// alphabet in every position and no character far from its expected +// share; NormalizeDeviceShortCode accepts case, spaces and a missing +// hyphen and refuses lengths other than eight and the excluded letters. +func TestDeviceCodes(t *testing.T) { + const draws = 10000 + + t.Run("tokens", func(t *testing.T) { + seen := make(map[string]struct{}, draws) + for range draws { + tok, err := NewDeviceToken() + if err != nil { + t.Fatal(err) + } + if len(tok) != 64 { + t.Fatalf("token %q has %d characters", tok, len(tok)) + } + if strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" { + t.Fatalf("token %q is not base64url", tok) + } + if _, dup := seen[tok]; dup { + t.Fatalf("token %q drawn twice", tok) + } + seen[tok] = struct{}{} + } + tok, _ := NewDeviceToken() + sum := sha256.Sum256([]byte(tok)) + if got := HashAPIToken(tok); got != hex.EncodeToString(sum[:]) || len(got) != 64 || got == tok { + t.Fatalf("stored form %q is not the SHA-256 hex of the token", got) + } + }) + + t.Run("short_codes", func(t *testing.T) { + alphabet := DeviceShortCodeAlphabet + if alphabet != "23456789ABCDEFGHJKMNPQRSTUVWXYZ" || len(alphabet) != 31 { + t.Fatalf("alphabet = %q", alphabet) + } + format := regexp.MustCompile(`^[` + alphabet + `]{4}-[` + alphabet + `]{4}$`) + perPosition := make([]map[byte]int, 8) + for i := range perPosition { + perPosition[i] = map[byte]int{} + } + total := map[byte]int{} + seen := map[string]struct{}{} + for range draws { + code, err := NewDeviceShortCode() + if err != nil { + t.Fatal(err) + } + if !format.MatchString(code) { + t.Fatalf("short code %q", code) + } + seen[code] = struct{}{} + raw := code[:4] + code[5:] + for i := 0; i < 8; i++ { + perPosition[i][raw[i]]++ + total[raw[i]]++ + } + } + // 31^8 codes: 10,000 draws repeating more than a handful would mean + // a broken generator. + if len(seen) < draws-5 { + t.Fatalf("%d distinct codes in %d draws", len(seen), draws) + } + for i, counts := range perPosition { + for j := 0; j < len(alphabet); j++ { + if counts[alphabet[j]] == 0 { + t.Fatalf("character %q never drawn at position %d", alphabet[j], i) + } + } + } + // Each character is expected 80000/31 ≈ 2581 times; a modulo bias + // of the byte draw would push the first 8 characters ~3% up. Allow + // 10% either way, far outside the noise of a fair draw. + expected := float64(8*draws) / float64(len(alphabet)) + for j := 0; j < len(alphabet); j++ { + got := float64(total[alphabet[j]]) + if got < expected*0.9 || got > expected*1.1 { + t.Fatalf("character %q drawn %v times, expected about %.0f", alphabet[j], got, expected) + } + } + for _, excluded := range "01ILO" { + if total[byte(excluded)] != 0 { + t.Fatalf("excluded character %q drawn", excluded) + } + } + }) + + t.Run("normalization", func(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"ABCD-EFGH", "ABCD-EFGH"}, + {"abcd-efgh", "ABCD-EFGH"}, + {"AbCd-eFgH", "ABCD-EFGH"}, + {"ABCDEFGH", "ABCD-EFGH"}, + {"abcdefgh", "ABCD-EFGH"}, + {" ab cd ef gh ", "ABCD-EFGH"}, + {"ABCD - EFGH", "ABCD-EFGH"}, + {"A-B-C-D-E-F-G-H", "ABCD-EFGH"}, + {"2345-6789", "2345-6789"}, + {"zzzz zzzz", "ZZZZ-ZZZZ"}, + } { + if got, ok := NormalizeDeviceShortCode(c.in); !ok || got != c.want { + t.Errorf("normalize %q = %q, %v; want %q", c.in, got, ok, c.want) + } + } + for _, in := range []string{ + "", "-", "ABCDEFG", "ABCD-EFG", "ABCDEFGHJ", "ABCD-EFGHJ", + "ABCDEFG0", "ABCDEFG1", "ABCDEFGI", "ABCDEFGL", "ABCDEFGO", "abcdefgo", "abcdefgl", "abcdefgi", + "ABCD_EFGH", "ABCD.EFGH", "ABCDÉFGH", "ABCD\tEFGH", + } { + if got, ok := NormalizeDeviceShortCode(in); ok { + t.Errorf("normalize %q accepted as %q", in, got) + } + } + }) +} diff --git a/device_auth_test.go b/device_auth_test.go new file mode 100644 index 0000000..54f58f0 --- /dev/null +++ b/device_auth_test.go @@ -0,0 +1,1091 @@ +package user + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "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" + "github.com/golang-jwt/jwt/v5" +) + +// TestDeviceAuthURL pins the auth_url template and the session TTL (D-11) +// without a database. +func TestDeviceAuthURL(t *testing.T) { + if got := classes.DeviceAuthURL(nil, "tok"); got != "/device/scan/tok" { + t.Fatalf("auth_url without an app = %q", got) + } + if got := classes.DeviceAuthTTL(nil); got != 5*time.Minute { + t.Fatalf("ttl without an app = %s", got) + } + app := backpack.New(sessionConfig(t)) + set := func(key string, value any) { + t.Helper() + if err := app.Config.Set(key, value); err != nil { + t.Fatal(err) + } + } + for _, step := range []struct { + key string + value any + want string + }{ + {"", nil, "/device/scan/tok-1_A"}, + {"app.url", "https://api.example.test", "https://api.example.test/device/scan/tok-1_A"}, + {"app.url", "https://api.example.test///", "https://api.example.test/device/scan/tok-1_A"}, + {"golem15.user.frontend_url", " https://www.example.test/ ", "https://www.example.test/device/scan/tok-1_A"}, + {"golem15.user.device_auth.auth_url", " ", "https://www.example.test/device/scan/tok-1_A"}, + {"golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}", "https://www.example.test/link?device=tok-1_A"}, + {"golem15.user.device_auth.auth_url", "https://tv.example.test/pair/{token}/{token}", "https://tv.example.test/pair/tok-1_A/tok-1_A"}, + {"golem15.user.device_auth.auth_url", "{frontend_url}/pair", "https://www.example.test/pair"}, + } { + if step.key != "" { + set(step.key, step.value) + } + if got := classes.DeviceAuthURL(app, "tok-1_A"); got != step.want { + t.Fatalf("after %s=%v: auth_url = %q, want %q", step.key, step.value, got, step.want) + } + } + for _, c := range []struct { + minutes any + want time.Duration + }{ + {nil, 5 * time.Minute}, + {10, 10 * time.Minute}, + {1, time.Minute}, + {0, 5 * time.Minute}, + {-3, 5 * time.Minute}, + } { + if c.minutes != nil { + set("golem15.user.device_auth.ttl_minutes", c.minutes) + } + if got := classes.DeviceAuthTTL(app); got != c.want { + t.Fatalf("ttl_minutes %v = %s, want %s", c.minutes, got, c.want) + } + } +} + +// TestDeviceDisplayName pins PHP's device_name accessor order: a stored +// name wins; otherwise iPhone, iPad, Android, Windows, Macintosh and Linux +// are checked in that order (an iPhone's "like Mac OS X" stays an iPhone, +// Android's "Linux" stays Android), then Unknown Device. +func TestDeviceDisplayName(t *testing.T) { + stored := "Living room TV" + empty := "" + for _, c := range []struct { + name string + stored *string + ua string + want string + }{ + {"stored_wins", &stored, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", stored}, + {"empty_stored_parses", &empty, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Windows PC"}, + {"iphone_before_mac", nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", "iPhone"}, + {"ipad", nil, "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)", "iPad"}, + {"ipad_desktop_mode_is_mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/17.0 Safari", "Mac"}, + {"android_before_linux", nil, "Mozilla/5.0 (Linux; Android 14; Pixel 8)", "Android Device"}, + {"windows", nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"}, + {"mac", nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"}, + {"linux", nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"}, + {"smart_tv_on_linux", nil, "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)", "Linux PC"}, + {"case_sensitive_like_php", nil, "mozilla/5.0 (iphone)", "Unknown Device"}, + {"curl", nil, "curl/8.0", "Unknown Device"}, + {"no_user_agent", nil, "", "Unknown Device"}, + } { + t.Run(c.name, func(t *testing.T) { + if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want { + t.Fatalf("DeviceDisplayName(%q) = %q, want %q", c.ua, got, c.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Device flow through the assembled routes (Postgres). + +// envInitiate posts devices/initiate through the router. +func envInitiate(t *testing.T, env *adminEnv, body any) (deviceInitiated, *httptest.ResponseRecorder) { + t.Helper() + rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", body) + var out struct { + Data deviceInitiated `json:"data"` + } + if rec.Code == http.StatusOK { + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out.Data, rec +} + +func envNewDevice(t *testing.T, env *adminEnv) deviceInitiated { + t.Helper() + dev, rec := envInitiate(t, env, map[string]any{}) + if rec.Code != http.StatusOK { + t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) + } + return dev +} + +func envPoll(t *testing.T, env *adminEnv, token string) *httptest.ResponseRecorder { + t.Helper() + return env.call(t, http.MethodGet, "/_user/api/v1/devices/status/"+token, "", nil) +} + +func envAuthorize(t *testing.T, env *adminEnv, session string, body any) *httptest.ResponseRecorder { + t.Helper() + return env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize", session, body) +} + +func envDeviceRow(t *testing.T, env *adminEnv, token string) models.DeviceAuthSession { + t.Helper() + var row models.DeviceAuthSession + if err := env.gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil { + t.Fatal(err) + } + return row +} + +func envSetDevice(t *testing.T, env *adminEnv, id uint, values map[string]any) { + t.Helper() + if err := env.gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", id).Updates(values).Error; err != nil { + t.Fatal(err) + } +} + +// envSession signs a fresh user in and returns it with its session token. +func envSession(t *testing.T, env *adminEnv, label string) (*models.User, string) { + t.Helper() + user := env.seedUser(t, label, nil) + status, token := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK || token == "" { + t.Fatalf("login %s = %d", label, status) + } + return user, token +} + +func wantBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { + t.Helper() + if rec.Code != status || rec.Body.String() != body { + t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), status, body) + } +} + +const ( + deviceAuthorizedBody = `{"message":"Device authorized successfully","success":true}` + deviceInvalidBody = `{"error":"Invalid or expired code"}` + deviceCodeLengthBody = `{"error":"Please enter a valid 8-character code"}` + deviceUnauthorized = `{"error":"Unauthorized"}` + deviceExpiredBody = `{"message":"Authorization session has expired","status":"expired","success":true}` + deviceNotFoundBody = `{"error":"Session not found"}` + deviceRevokedOKBody = `{"message":"Device revoked successfully","success":true}` + deviceNotFoundRevoke = `{"error":"Device not found"}` + deviceNameTooLongBody = `{"error":"The device name may not be greater than 255 characters.","errors":{"device_name":["The device name may not be greater than 255 characters."]}}` +) + +func deviceStatusBody(status string) string { + return `{"data":{"status":"` + status + `"},"success":true}` +} + +// TestDeviceInitiate pins POST devices/initiate: PHP's answer shape, the +// stored pending row, the TTL setting, the device_name bound, the bounded +// cleanup of stale sessions and the retry of an insert whose short code +// collides with a stored one. +func TestDeviceInitiate(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + + t.Run("answer_and_row", func(t *testing.T) { + before := time.Now() + rec := env.send(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", []byte(`{"device_name":"Kitchen TV"}`), "application/json", + map[string]string{"User-Agent": "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)"}) + if rec.Code != http.StatusOK { + t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) + } + var raw map[string]map[string]any + var top map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &top); err != nil || top["success"] != true || len(top) != 2 { + t.Fatalf("initiate body %s", rec.Body.String()) + } + _ = json.Unmarshal(rec.Body.Bytes(), &raw) + data := raw["data"] + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + slices.Sort(keys) + if strings.Join(keys, ",") != "auth_url,expires_at,short_code,token" { + t.Fatalf("data keys = %v", keys) + } + token, _ := data["token"].(string) + code, _ := data["short_code"].(string) + if len(token) != 64 || !deviceShortCodeRE.MatchString(code) { + t.Fatalf("token %q, short code %q", token, code) + } + if data["auth_url"] != "https://app.example.test/device/scan/"+token { + t.Fatalf("auth_url = %v", data["auth_url"]) + } + expires, err := time.Parse("2006-01-02T15:04:05-07:00", data["expires_at"].(string)) + if err != nil || !strings.HasSuffix(data["expires_at"].(string), "+00:00") { + t.Fatalf("expires_at %v: %v", data["expires_at"], err) + } + if d := expires.Sub(before); d < 4*time.Minute+58*time.Second || d > 5*time.Minute+2*time.Second { + t.Fatalf("expires_at is %s after the request, want 5m", d) + } + row := envDeviceRow(t, env, token) + if row.Status != models.DeviceStatusPending || row.UserID != nil || row.ShortCode == nil || *row.ShortCode != code || + row.DeviceName == nil || *row.DeviceName != "Kitchen TV" || row.DeviceIP == nil || !strings.HasPrefix(*row.DeviceIP, "198.51.100.") || + row.DeviceUserAgent == nil || *row.DeviceUserAgent != "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)" || + row.ConfirmedAt != nil || row.UsedAt != nil || row.JWTJTI != nil || row.AuthIP != nil || row.SessionID != nil { + t.Fatalf("stored row = %+v", row) + } + }) + + t.Run("ttl_setting", func(t *testing.T) { + if err := env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 10); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.device_auth.ttl_minutes", 5) }) + dev := envNewDevice(t, env) + row := envDeviceRow(t, env, dev.Token) + if d := time.Until(*row.ExpiresAt); d < 9*time.Minute+50*time.Second || d > 10*time.Minute+time.Second { + t.Fatalf("expires in %s, want 10m", d) + } + }) + + t.Run("device_name", func(t *testing.T) { + var before, after int64 + env.gdb.Model(&models.DeviceAuthSession{}).Count(&before) + _, rec := envInitiate(t, env, map[string]any{"device_name": strings.Repeat("n", 256)}) + wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody) + _, rec = envInitiate(t, env, map[string]any{"device_name": strings.Repeat("ł", 256)}) + wantBody(t, rec, http.StatusUnprocessableEntity, deviceNameTooLongBody) + env.gdb.Model(&models.DeviceAuthSession{}).Count(&after) + if after != before { + t.Fatalf("a refused initiate stored %d rows", after-before) + } + long := strings.Repeat("ł", 255) + dev, rec := envInitiate(t, env, map[string]any{"device_name": long}) + if rec.Code != http.StatusOK { + t.Fatalf("255 characters = %d %s", rec.Code, rec.Body.String()) + } + if row := envDeviceRow(t, env, dev.Token); row.DeviceName == nil || *row.DeviceName != long { + t.Fatal("the 255-character name was not stored as sent") + } + dev = envNewDevice(t, env) + if row := envDeviceRow(t, env, dev.Token); row.DeviceName != nil { + t.Fatalf("no device_name stored %q", *row.DeviceName) + } + }) + + t.Run("cleanup_is_bounded", func(t *testing.T) { + stamp := adminStamp() + batch := 0 + insert := func(n int, status, expires string) { + t.Helper() + batch++ + if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, status, expires_at) + SELECT 'cleanup-' || ? || '-' || ? || '-' || g, ?, `+expires+` FROM generate_series(1, ?) g`, stamp, strconv.Itoa(batch), status, n).Error; err != nil { + t.Fatal(err) + } + } + insert(503, "pending", "NOW() - INTERVAL '2 hours'") + insert(2, "expired", "NOW() - INTERVAL '3 hours'") + insert(1, "pending", "NOW() - INTERVAL '30 minutes'") + insert(1, "confirmed", "NOW() - INTERVAL '2 hours'") + insert(1, "used", "NOW() - INTERVAL '2 hours'") + insert(1, "revoked", "NOW() - INTERVAL '2 hours'") + insert(1, "pending", "NULL") + stale := func() int64 { + var n int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions + WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n + } + mine := func(where string) int64 { + var n int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE token LIKE ? AND `+where, "cleanup-"+stamp+"-%").Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n + } + before := stale() + if before < 505 { + t.Fatalf("stale rows before = %d", before) + } + envNewDevice(t, env) + if after := stale(); after != before-500 { + t.Fatalf("one initiate deleted %d stale rows, want 500", before-after) + } + for envNewDevice(t, env); stale() > 0; envNewDevice(t, env) { + } + if n := mine(`status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'`); n != 0 { + t.Fatalf("%d stale rows survived", n) + } + if n := mine(`status = 'pending' AND (expires_at IS NULL OR expires_at > NOW() - INTERVAL '1 hour')`); n != 2 { + t.Fatalf("recent and undated pending rows left = %d, want 2", n) + } + if n := mine(`status IN ('confirmed', 'used', 'revoked')`); n != 3 { + t.Fatalf("confirmed, used and revoked rows left = %d, want 3", n) + } + }) + + t.Run("short_code_collision_retry", func(t *testing.T) { + const taken = "QQQQ-QQQQ" + if err := env.gdb.Exec(`INSERT INTO golem15_user_device_auth_sessions (token, short_code, status, expires_at) + VALUES (?, ?, 'pending', NOW() + INTERVAL '5 minutes')`, "collision-"+adminStamp(), taken).Error; err != nil { + t.Fatal(err) + } + // A BEFORE INSERT trigger gives the first `collide` inserts the + // stored short code, so the unique index refuses them exactly as a + // random collision would. + collide := func(n int) { + t.Helper() + for _, stmt := range []string{ + `DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`, + `DROP SEQUENCE IF EXISTS device_collision_seq`, + `CREATE SEQUENCE device_collision_seq`, + `CREATE OR REPLACE FUNCTION device_force_collision() RETURNS trigger AS $$ +BEGIN + IF nextval('device_collision_seq') <= ` + strconv.Itoa(n) + ` THEN + NEW.short_code := '` + taken + `'; + END IF; + RETURN NEW; +END $$ LANGUAGE plpgsql`, + `CREATE TRIGGER device_force_collision BEFORE INSERT ON golem15_user_device_auth_sessions + FOR EACH ROW EXECUTE FUNCTION device_force_collision()`, + } { + if err := env.gdb.Exec(stmt).Error; err != nil { + t.Fatal(err) + } + } + } + t.Cleanup(func() { + _ = env.gdb.Exec(`DROP TRIGGER IF EXISTS device_force_collision ON golem15_user_device_auth_sessions`).Error + _ = env.gdb.Exec(`DROP FUNCTION IF EXISTS device_force_collision()`).Error + _ = env.gdb.Exec(`DROP SEQUENCE IF EXISTS device_collision_seq`).Error + }) + var before, after int64 + fresh := func(n *int64) { + t.Helper() + if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions WHERE status = 'pending' AND expires_at > NOW()`).Scan(n).Error; err != nil { + t.Fatal(err) + } + } + collide(2) + dev := envNewDevice(t, env) + if dev.ShortCode == taken || !deviceShortCodeRE.MatchString(dev.ShortCode) { + t.Fatalf("retried initiate answered short code %q", dev.ShortCode) + } + var calls int64 + if err := env.gdb.Raw(`SELECT last_value FROM device_collision_seq`).Scan(&calls).Error; err != nil || calls != 3 { + t.Fatalf("insert attempts = %d (%v), want 3", calls, err) + } + if row := envDeviceRow(t, env, dev.Token); row.ShortCode == nil || *row.ShortCode != dev.ShortCode { + t.Fatalf("stored short code = %v", row.ShortCode) + } + fresh(&before) + collide(5) + _, rec := envInitiate(t, env, map[string]any{}) + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "token") { + t.Fatalf("five collisions = %d %s, want the opaque 500", rec.Code, rec.Body.String()) + } + fresh(&after) + if after != before { + t.Fatalf("a failed initiate stored %d rows", after-before) + } + }) +} + +// TestDeviceStatus pins GET devices/status/{token} for every state: an +// unknown token, pending, a pending or confirmed session past expires_at +// (PHP's flat expired body; the row becomes expired), an expired row, and +// used and revoked sessions, which never become expired (Pitfall 13). +func TestDeviceStatus(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + _, session := envSession(t, env, "dst-owner") + past := time.Now().Add(-time.Minute) + + t.Run("unknown", func(t *testing.T) { + wantBody(t, envPoll(t, env, strings.Repeat("z", 64)), http.StatusNotFound, deviceNotFoundBody) + wantBody(t, envPoll(t, env, "short"), http.StatusNotFound, deviceNotFoundBody) + dev := envNewDevice(t, env) + // The stored hash is not a token: polling with it finds nothing. + wantBody(t, envPoll(t, env, classes.HashAPIToken(dev.Token)), http.StatusNotFound, deviceNotFoundBody) + }) + + t.Run("pending", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending")) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("pending")) + }) + + t.Run("expired_pending", func(t *testing.T) { + dev := envNewDevice(t, env) + row := envDeviceRow(t, env, dev.Token) + envSetDevice(t, env, row.ID, map[string]any{"expires_at": past}) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) + if got := envDeviceRow(t, env, dev.Token).Status; got != models.DeviceStatusExpired { + t.Fatalf("row status = %s", got) + } + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) + }) + + t.Run("expired_confirmed_hands_nothing_over", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + row := envDeviceRow(t, env, dev.Token) + envSetDevice(t, env, row.ID, map[string]any{"expires_at": past}) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) + row = envDeviceRow(t, env, dev.Token) + if row.Status != models.DeviceStatusExpired || row.UsedAt != nil || row.JWTJTI != nil { + t.Fatalf("row = %+v", row) + } + }) + + t.Run("expired_row_without_expires_at_passing", func(t *testing.T) { + dev := envNewDevice(t, env) + row := envDeviceRow(t, env, dev.Token) + envSetDevice(t, env, row.ID, map[string]any{"status": models.DeviceStatusExpired}) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceExpiredBody) + }) + + t.Run("used_and_revoked_never_expire", func(t *testing.T) { + for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} { + dev := envNewDevice(t, env) + row := envDeviceRow(t, env, dev.Token) + envSetDevice(t, env, row.ID, map[string]any{"status": status, "expires_at": time.Now().Add(-24 * time.Hour)}) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody(status)) + if got := envDeviceRow(t, env, dev.Token).Status; got != status { + t.Fatalf("%s row became %s", status, got) + } + } + }) +} + +// TestDeviceAuthorize pins POST devices/authorize: the short-code forms +// and refusals with PHP's 422 and 400 bodies, the token path, sessions +// bound to another user, a repeated authorize by the same user, the body- +// only fields, expired, used and revoked sessions, concurrent authorizes by +// two users binding exactly one, and PHP's 401 for a missing, invalid, +// foreign-signed or ended session token. +func TestDeviceAuthorize(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + owner, session := envSession(t, env, "dau-owner") + other, otherSession := envSession(t, env, "dau-other") + + t.Run("short_code_forms", func(t *testing.T) { + for _, form := range []func(string) string{ + func(c string) string { return c }, + strings.ToLower, + func(c string) string { return strings.ReplaceAll(c, "-", "") }, + func(c string) string { return strings.ToLower(strings.ReplaceAll(c, "-", "")) }, + func(c string) string { return c[:2] + "-" + c[2:4] + c[5:] }, + func(c string) string { return strings.ToLower(c[:4]) + c[4:] }, + // Surrounding whitespace is trimmed from the field. + func(c string) string { return " " + c + " " }, + } { + dev := envNewDevice(t, env) + code := form(dev.ShortCode) + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": code}), http.StatusOK, deviceAuthorizedBody) + row := envDeviceRow(t, env, dev.Token) + if row.Status != models.DeviceStatusConfirmed || row.UserID == nil || *row.UserID != owner.ID || + row.ConfirmedAt == nil || row.AuthIP == nil || !strings.HasPrefix(*row.AuthIP, "198.51.100.") { + t.Fatalf("short code %q: row = %+v", code, row) + } + } + }) + + t.Run("short_code_refusals", func(t *testing.T) { + dev := envNewDevice(t, env) + for _, c := range []struct { + code string + status int + body string + }{ + {"", http.StatusUnprocessableEntity, deviceCodeLengthBody}, + {"ABCDEFG", http.StatusUnprocessableEntity, deviceCodeLengthBody}, + {"ABCD-EFG", http.StatusUnprocessableEntity, deviceCodeLengthBody}, + {"ABCDEFGHJ", http.StatusUnprocessableEntity, deviceCodeLengthBody}, + {"ABCD-EFGH-J", http.StatusUnprocessableEntity, deviceCodeLengthBody}, + // Inner spaces count toward the length; only hyphens are + // removed before the length check, as in PHP. + {strings.ReplaceAll(dev.ShortCode, "-", " "), http.StatusUnprocessableEntity, deviceCodeLengthBody}, + {"ABCD-EFGO", http.StatusBadRequest, deviceInvalidBody}, + {"ABCD-EFG0", http.StatusBadRequest, deviceInvalidBody}, + {"ABCD-EFG1", http.StatusBadRequest, deviceInvalidBody}, + {"ABCD-EFGI", http.StatusBadRequest, deviceInvalidBody}, + {"ABCD-EFGL", http.StatusBadRequest, deviceInvalidBody}, + {"ABC EFGH", http.StatusBadRequest, deviceInvalidBody}, + } { + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": c.code}), c.status, c.body) + } + // A well-formed code nobody holds. + unknown := "2222-2222" + if dev.ShortCode == unknown { + unknown = "3333-3333" + } + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": unknown}), http.StatusBadRequest, deviceInvalidBody) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil { + t.Fatalf("refusals touched the session: %+v", row) + } + }) + + t.Run("token_path_and_missing_fields", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": strings.Repeat("y", 64)}), http.StatusBadRequest, deviceInvalidBody) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": classes.HashAPIToken(dev.Token)}), http.StatusBadRequest, deviceInvalidBody) + wantBody(t, envAuthorize(t, env, session, map[string]string{}), http.StatusBadRequest, deviceInvalidBody) + // The fields come from the body only, never the query string. + wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?token="+dev.Token, session, map[string]string{}), + http.StatusBadRequest, deviceInvalidBody) + wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/devices/authorize?short_code="+dev.ShortCode, session, map[string]string{}), + http.StatusBadRequest, deviceInvalidBody) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending { + t.Fatalf("status = %s", row.Status) + } + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + // short_code wins when both are sent, as in PHP. + dev2 := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": "ABC", "token": dev2.Token}), http.StatusUnprocessableEntity, deviceCodeLengthBody) + }) + + t.Run("bound_to_another_user", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody) + wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"short_code": dev.ShortCode}), http.StatusBadRequest, deviceInvalidBody) + if row := envDeviceRow(t, env, dev.Token); row.UserID == nil || *row.UserID != owner.ID { + t.Fatalf("the session moved to %v", row.UserID) + } + // A pending session already carrying a user_id binds only to it. + pre := envNewDevice(t, env) + row := envDeviceRow(t, env, pre.Token) + envSetDevice(t, env, row.ID, map[string]any{"user_id": owner.ID}) + wantBody(t, envAuthorize(t, env, otherSession, map[string]string{"token": pre.Token}), http.StatusBadRequest, deviceInvalidBody) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": pre.Token}), http.StatusOK, deviceAuthorizedBody) + }) + + t.Run("same_user_again", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody) + first := envDeviceRow(t, env, dev.Token) + time.Sleep(10 * time.Millisecond) + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + again := envDeviceRow(t, env, dev.Token) + if !again.ConfirmedAt.Equal(*first.ConfirmedAt) || *again.AuthIP != *first.AuthIP { + t.Fatal("repeating the authorize rewrote the confirmation") + } + }) + + t.Run("expired_used_revoked", func(t *testing.T) { + expired := envNewDevice(t, env) + row := envDeviceRow(t, env, expired.Token) + envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)}) + wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": expired.ShortCode}), http.StatusBadRequest, deviceInvalidBody) + if got := envDeviceRow(t, env, expired.Token).Status; got != models.DeviceStatusExpired { + t.Fatalf("expired session status = %s", got) + } + // A confirmed session past expires_at cannot be authorized again + // either, not even by its own user. + late := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusOK, deviceAuthorizedBody) + row = envDeviceRow(t, env, late.Token) + envSetDevice(t, env, row.ID, map[string]any{"expires_at": time.Now().Add(-time.Second)}) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": late.Token}), http.StatusBadRequest, deviceInvalidBody) + for _, status := range []string{models.DeviceStatusUsed, models.DeviceStatusRevoked} { + dev := envNewDevice(t, env) + row := envDeviceRow(t, env, dev.Token) + envSetDevice(t, env, row.ID, map[string]any{"status": status, "user_id": owner.ID}) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusBadRequest, deviceInvalidBody) + if got := envDeviceRow(t, env, dev.Token).Status; got != status { + t.Fatalf("%s session became %s", status, got) + } + } + }) + + t.Run("concurrent_two_users_bind_one", func(t *testing.T) { + for round := range 3 { + dev := envNewDevice(t, env) + var ( + wg sync.WaitGroup + mu sync.Mutex + ok = map[uint]int{} + no = map[uint]int{} + ) + for i := range 10 { + who, tok := owner.ID, session + if i%2 == 1 { + who, tok = other.ID, otherSession + } + wg.Add(1) + go func() { + defer wg.Done() + rec := envAuthorize(t, env, tok, map[string]string{"short_code": dev.ShortCode}) + mu.Lock() + defer mu.Unlock() + switch { + case rec.Code == http.StatusOK && rec.Body.String() == deviceAuthorizedBody: + ok[who]++ + case rec.Code == http.StatusBadRequest && rec.Body.String() == deviceInvalidBody: + no[who]++ + default: + t.Errorf("round %d: unexpected answer %d %s", round, rec.Code, rec.Body.String()) + } + }() + } + wg.Wait() + row := envDeviceRow(t, env, dev.Token) + if row.UserID == nil { + t.Fatalf("round %d: no user bound", round) + } + winner := *row.UserID + loser := owner.ID + if winner == owner.ID { + loser = other.ID + } + if ok[winner] != 5 || no[winner] != 0 || ok[loser] != 0 || no[loser] != 5 { + t.Fatalf("round %d: winner %d ok=%d no=%d, loser ok=%d no=%d", round, winner, ok[winner], no[winner], ok[loser], no[loser]) + } + } + }) + + t.Run("unauthorized", func(t *testing.T) { + dev := envNewDevice(t, env) + body := map[string]string{"short_code": dev.ShortCode} + wantBody(t, envAuthorize(t, env, "", body), http.StatusUnauthorized, deviceUnauthorized) + wantBody(t, envAuthorize(t, env, "not-a-jwt", body), http.StatusUnauthorized, deviceUnauthorized) + foreign := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": "dau-foreign", "prv": prvHash, + "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }) + signed, err := foreign.SignedString([]byte("another-secret-entirely-another-secret")) + if err != nil { + t.Fatal(err) + } + wantBody(t, envAuthorize(t, env, signed, body), http.StatusUnauthorized, deviceUnauthorized) + _, ended := envSession(t, env, "dau-ended") + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/logout", ended, nil) + wantBody(t, envAuthorize(t, env, ended, body), http.StatusUnauthorized, deviceUnauthorized) + if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusPending || row.UserID != nil { + t.Fatalf("an unauthorized call touched the session: %+v", row) + } + }) +} + +// TestDeviceHandOverConcurrent pins the one-time hand-over (D-12a): of 50 +// concurrent polls of a confirmed session exactly one receives a token, the +// others answer used, and one login event with source device fires; a +// session whose user was deactivated or banned after the authorize hands +// nothing over and becomes revoked. +func TestDeviceHandOverConcurrent(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + events := listenLoginEvents(env.app, "test.device-hand-over") + owner, session := envSession(t, env, "dho-owner") + + t.Run("fifty_polls_one_token", func(t *testing.T) { + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + before := events.count() + var ( + wg sync.WaitGroup + mu sync.Mutex + tokens []string + used int + ) + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + rec := envPoll(t, env, dev.Token) + var body struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + mu.Lock() + defer mu.Unlock() + switch { + case rec.Code == http.StatusOK && body.Data["token"] != nil: + tokens = append(tokens, body.Data["token"].(string)) + case rec.Code == http.StatusOK && rec.Body.String() == deviceStatusBody("used"): + used++ + default: + t.Errorf("unexpected poll answer %d %s", rec.Code, rec.Body.String()) + } + }() + } + wg.Wait() + if len(tokens) != 1 || used != 49 { + t.Fatalf("50 polls: %d tokens, %d used", len(tokens), used) + } + sub, _, _, jti, err := bouncer.VerifyClaims(tokens[0], sessionSecret) + if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) { + t.Fatalf("device token sub %q: %v", sub, err) + } + row := envDeviceRow(t, env, dev.Token) + if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil || row.LastActivityAt == nil { + t.Fatalf("row after the hand-over = %+v", row) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokens[0], nil) + events.mu.Lock() + fired := events.got[before:] + events.mu.Unlock() + if len(fired) != 1 || fired[0].Source != classes.LoginSourceDevice || fired[0].User.ID != owner.ID { + t.Fatalf("login events = %+v", fired) + } + }) + + for _, c := range []struct { + name string + after func(*testing.T, *models.User) + }{ + {"deactivated_user", func(t *testing.T, u *models.User) { + if err := env.gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + }}, + {"banned_user", func(t *testing.T, u *models.User) { env.seedThrottle(t, u, "", true, nil) }}, + } { + t.Run(c.name+"_hands_nothing_over", func(t *testing.T) { + user, userSession := envSession(t, env, "dho-"+c.name) + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + c.after(t, user) + before := events.count() + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) + wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked")) + row := envDeviceRow(t, env, dev.Token) + if row.Status != models.DeviceStatusRevoked || row.JWTJTI != nil || row.UsedAt != nil { + t.Fatalf("row = %+v", row) + } + if events.count() != before { + t.Fatal("a refused hand-over fired a login event") + } + }) + } + + t.Run("deleted_user_cascades", func(t *testing.T) { + user, userSession := envSession(t, env, "dho-gone") + dev := envNewDevice(t, env) + wantBody(t, envAuthorize(t, env, userSession, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + if err := env.gdb.Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Unscoped().Delete(&models.User{}, user.ID).Error; err != nil { + t.Fatal(err) + } + wantBody(t, envPoll(t, env, dev.Token), http.StatusNotFound, deviceNotFoundBody) + }) +} + +// TestDeviceListAndRevoke pins GET devices and DELETE devices/{id}: only +// the caller's confirmed and used sessions, most recent activity first +// (nulls last, then newest id), is_current for the requesting token, the +// display name, PHP's item keys and an empty list as []; a revoke of a +// foreign or unknown id is 404, a non-numeric id never reaches the +// handler, a repeated revoke answers success, and a revoked device is +// signed out and no longer listed. +func TestDeviceListAndRevoke(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + owner, session := envSession(t, env, "dlr-owner") + _, otherSession := envSession(t, env, "dlr-other") + + t.Run("empty", func(t *testing.T) { + _, fresh := envSession(t, env, "dlr-empty") + wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", fresh, nil), http.StatusOK, `{"devices":[],"success":true}`) + wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil), http.StatusUnauthorized, deviceUnauthorized) + }) + + tokA, _, idA := envDevice(t, env, session, "A") + _, _, idB := envDevice(t, env, session, "B") + _, _, idC := envDevice(t, env, session, "") + _, _, idD := envDevice(t, env, session, "D") + _, _, foreign := envDevice(t, env, otherSession, "Foreign") + hour := time.Now().Add(-time.Hour).Truncate(time.Second) + envSetDevice(t, env, idA, map[string]any{"last_activity_at": time.Now().Add(-3 * time.Hour)}) + envSetDevice(t, env, idB, map[string]any{"last_activity_at": hour}) + envSetDevice(t, env, idC, map[string]any{"last_activity_at": nil, "device_user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"}) + envSetDevice(t, env, idD, map[string]any{"last_activity_at": hour}) + // Sessions of the owner that are not listed. + for _, status := range []string{models.DeviceStatusPending, models.DeviceStatusExpired, models.DeviceStatusRevoked} { + row := models.DeviceAuthSession{TokenHash: "dlr-" + status + "-" + adminStamp(), UserID: &owner.ID, Status: status} + if err := env.gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + + t.Run("order_names_and_current", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", tokA, nil) + var raw struct { + Success bool `json:"success"` + Devices []map[string]any `json:"devices"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil || !raw.Success { + t.Fatalf("list %s: %v", rec.Body.String(), err) + } + var ids []uint + for _, d := range raw.Devices { + keys := make([]string, 0, len(d)) + for k := range d { + keys = append(keys, k) + } + slices.Sort(keys) + if strings.Join(keys, ",") != "authorized_at,device_ip,device_name,id,is_current,last_activity" { + t.Fatalf("item keys = %v", keys) + } + ids = append(ids, uint(d["id"].(float64))) + } + want := []uint{idD, idB, idA, idC} + if !slices.Equal(ids, want) { + t.Fatalf("order = %v, want %v (D and B tie on activity, newest id first; C has none)", ids, want) + } + names := map[uint]string{} + current := map[uint]bool{} + for _, d := range raw.Devices { + names[uint(d["id"].(float64))] = d["device_name"].(string) + current[uint(d["id"].(float64))] = d["is_current"].(bool) + if at, _ := d["authorized_at"].(string); !strings.HasSuffix(at, "+00:00") { + t.Fatalf("authorized_at = %v", d["authorized_at"]) + } + } + if names[idA] != "A" || names[idC] != "iPhone" || names[idD] != "D" { + t.Fatalf("names = %v", names) + } + if !current[idA] || current[idB] || current[idC] || current[idD] { + t.Fatalf("is_current = %v, want only A", current) + } + if last := raw.Devices[3]["last_activity"]; last != nil { + t.Fatalf("last_activity of C = %v, want null", last) + } + for _, d := range envDevices(t, env, session).Devices { + if d.IsCurrent { + t.Fatal("the owner's own login session is not a device") + } + } + }) + + t.Run("revoke", func(t *testing.T) { + path := func(id uint) string { return "/_user/api/v1/devices/" + strconv.FormatUint(uint64(id), 10) } + wantBody(t, env.call(t, http.MethodDelete, path(idB), "", nil), http.StatusUnauthorized, deviceUnauthorized) + wantBody(t, env.call(t, http.MethodDelete, path(foreign), session, nil), http.StatusNotFound, deviceNotFoundRevoke) + wantBody(t, env.call(t, http.MethodDelete, path(999999999), session, nil), http.StatusNotFound, deviceNotFoundRevoke) + wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/0", session, nil), http.StatusNotFound, deviceNotFoundRevoke) + for _, bad := range []string{"abc", "1a", "-1"} { + // The digit constraint keeps these from the handler, which + // would answer 401 without a session. + rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+bad, "", nil) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("DELETE /devices/%s = %d %s", bad, rec.Code, rec.Body.String()) + } + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tokA, nil) + wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody) + wantBody(t, env.call(t, http.MethodDelete, path(idA), session, nil), http.StatusOK, deviceRevokedOKBody) + env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tokA, nil) + // A device may revoke itself; a revoked device cannot list. + wantBody(t, env.call(t, http.MethodGet, "/_user/api/v1/devices", tokA, nil), http.StatusUnauthorized, deviceUnauthorized) + var foreignRow models.DeviceAuthSession + if err := env.gdb.Where("id = ?", foreign).Take(&foreignRow).Error; err != nil || foreignRow.Status != models.DeviceStatusUsed { + t.Fatalf("the foreign device = %+v %v", foreignRow, err) + } + // A used row without a jti (a Winter import) is revoked too. + envSetDevice(t, env, idC, map[string]any{"jwt_jti": nil}) + wantBody(t, env.call(t, http.MethodDelete, path(idC), session, nil), http.StatusOK, deviceRevokedOKBody) + var left []uint + for _, d := range envDevices(t, env, session).Devices { + left = append(left, d.ID) + } + if !slices.Equal(left, []uint{idD, idB}) { + t.Fatalf("listed after revokes = %v", left) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) + }) +} + +// TestDeviceRevokeAfterRefresh pins D-12b through refresh: after one and +// after two refreshes the row tracks the newest jti, a revoke signs out +// every token of the lineage and refuses the next refresh, and the +// blacklist entry lasts the whole refresh window. A refresh that fails +// leaves the row as it was. +func TestDeviceRevokeAfterRefresh(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + owner, session := envSession(t, env, "drr-owner") + refresh := func(t *testing.T, token string) string { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil) + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" { + t.Fatalf("refresh %s: %v", rec.Body.String(), err) + } + return body.Token + } + jtiOf := func(t *testing.T, token string) string { + t.Helper() + _, _, _, jti, err := bouncer.VerifyClaims(token, sessionSecret) + if err != nil { + t.Fatal(err) + } + return jti + } + + for _, n := range []int{1, 2} { + t.Run(strconv.Itoa(n)+"_refreshes", func(t *testing.T) { + first, _, id := envDevice(t, env, session, "Desk "+strconv.Itoa(n)) + chain := []string{first} + for range n { + chain = append(chain, refresh(t, chain[len(chain)-1])) + var row models.DeviceAuthSession + if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.JWTJTI == nil || *row.JWTJTI != jtiOf(t, chain[len(chain)-1]) || row.Status != models.DeviceStatusUsed { + t.Fatalf("row after refresh %d = %+v", len(chain)-1, row) + } + } + latest := chain[len(chain)-1] + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", latest, nil) + start := time.Now() + wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody) + for i, tok := range chain { + if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", tok, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("token %d of the lineage still works after the revoke: %d", i, rec.Code) + } + } + env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", latest, nil) + var expires time.Time + if err := env.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jtiOf(t, latest)).Scan(&expires).Error; err != nil { + t.Fatal(err) + } + if window := start.Add(controllers.RefreshTTL(env.app)); expires.Before(window) { + t.Fatalf("blacklist entry ends %s, before the refresh window %s", expires, window) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) + }) + } + + t.Run("failed_refresh_leaves_the_row", func(t *testing.T) { + token, jti, id := envDevice(t, env, session, "Desk fail") + var before models.DeviceAuthSession + if err := env.gdb.Where("id = ?", id).Take(&before).Error; err != nil { + t.Fatal(err) + } + // The device's jti in a token signed with another key. + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": strconv.FormatUint(uint64(owner.ID), 10), "jti": jti, "prv": prvHash, + "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }) + signed, err := forged.SignedString([]byte("another-secret-entirely-another-secret")) + if err != nil { + t.Fatal(err) + } + env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", signed, nil) + // The device token itself, refused by the subject check once the + // user is banned. + banned, bannedSession := envSession(t, env, "drr-banned") + bannedToken, bannedJTI, bannedID := envDevice(t, env, bannedSession, "Banned desk") + env.seedThrottle(t, banned, "", true, nil) + env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", bannedToken, nil) + for _, c := range []struct { + id uint + jti string + }{{id, jti}, {bannedID, bannedJTI}} { + var row models.DeviceAuthSession + if err := env.gdb.Where("id = ?", c.id).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.JWTJTI == nil || *row.JWTJTI != c.jti || row.Status != models.DeviceStatusUsed { + t.Fatalf("a failed refresh moved the row: %+v", row) + } + } + if after := refresh(t, token); jtiOf(t, after) == jti { + t.Fatal("the device token did not rotate") + } + }) +} + +// TestDeviceTokensAtRest pins D-06 for device sessions: the token column is +// the SHA-256 hex of the token, never the token; no column holds the token +// or the session JWT the device received; the model hides token, +// short_code and jwt_jti from JSON. +func TestDeviceTokensAtRest(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + _, session := envSession(t, env, "dtr-owner") + dev := envNewDevice(t, env) + sum := sha256.Sum256([]byte(dev.Token)) + row := envDeviceRow(t, env, dev.Token) + if row.TokenHash != hex.EncodeToString(sum[:]) || row.TokenHash == dev.Token || len(row.TokenHash) != 64 { + t.Fatalf("token column = %q", row.TokenHash) + } + rowJSON := func() string { + t.Helper() + var out string + if err := env.gdb.Raw(`SELECT row_to_json(s)::text FROM golem15_user_device_auth_sessions s WHERE id = ?`, row.ID).Scan(&out).Error; err != nil { + t.Fatal(err) + } + return out + } + if strings.Contains(rowJSON(), dev.Token) { + t.Fatal("a column holds the device token") + } + wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody) + rec := envPoll(t, env, dev.Token) + var polled struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" { + t.Fatalf("hand-over %s", rec.Body.String()) + } + stored := rowJSON() + if strings.Contains(stored, dev.Token) || strings.Contains(stored, polled.Data.Token) { + t.Fatal("a column holds the device token or its session JWT") + } + raw, err := json.Marshal(envDeviceRow(t, env, dev.Token)) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{row.TokenHash, dev.ShortCode, `"JWTJTI"`, `"TokenHash"`, `"ShortCode"`} { + if strings.Contains(string(raw), secret) { + t.Fatalf("the model's JSON carries %s: %s", secret, raw) + } + } +} diff --git a/updates/device_auth_sessions_test.go b/updates/device_auth_sessions_test.go new file mode 100644 index 0000000..c2d6f71 --- /dev/null +++ b/updates/device_auth_sessions_test.go @@ -0,0 +1,192 @@ +package updates + +import ( + "errors" + "slices" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +// TestDeviceAuthSessionsMigration pins 202610260001_create_device_auth_sessions +// (D-16, D-06, D-12b) on a database of its own: the PHP table and column +// names plus jwt_jti, the column types and nullability of PHP's v2.1.0 to +// v2.7.1 migrations, the unique token and short_code, the CHECK on status, +// the indexes, the cascade on user delete, the rollback and a second up. +func TestDeviceAuthSessionsMigration(t *testing.T) { + sqlDB := dedicatedDB(t, "user_device_auth_sessions") + db, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All()) + if err := m.Migrate(); err != nil { + t.Fatal(err) + } + const table = "golem15_user_device_auth_sessions" + if len(deviceAuthSessionMigrations) != 1 || deviceAuthSessionMigrations[0].ID != "202610260001_create_device_auth_sessions" { + t.Fatalf("device migrations = %v", deviceAuthSessionMigrations) + } + + type column struct { + Name string + Type string + Length *int + Nullable string + } + var columns []column + if err := db.Raw(`SELECT column_name AS name, data_type AS type, character_maximum_length AS length, is_nullable AS nullable + FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position`, table).Scan(&columns).Error; err != nil { + t.Fatal(err) + } + got := map[string]column{} + var names []string + for _, c := range columns { + got[c.Name] = c + names = append(names, c.Name) + } + want := []string{ + // PHP's columns (v2.1.0, v2.3.1 short_code). + "id", "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", + // D-12b. + "jwt_jti", + "created_at", "updated_at", + } + if !slices.Equal(names, want) { + t.Fatalf("columns = %v, want %v", names, want) + } + for name, spec := range map[string]struct { + typ string + length int + nullable string + }{ + "token": {"character varying", 64, "NO"}, + "short_code": {"character varying", 9, "YES"}, + "user_id": {"integer", 0, "YES"}, + "status": {"character varying", 16, "NO"}, + "expires_at": {"timestamp with time zone", 0, "YES"}, + "device_name": {"character varying", 255, "YES"}, + "device_user_agent": {"text", 0, "YES"}, + "jwt_jti": {"character varying", 64, "YES"}, + "session_id": {"character varying", 255, "YES"}, + } { + c := got[name] + length := 0 + if c.Length != nil { + length = *c.Length + } + if c.Type != spec.typ || length != spec.length || c.Nullable != spec.nullable { + t.Fatalf("%s = %s(%d) nullable %s, want %s(%d) nullable %s", name, c.Type, length, c.Nullable, spec.typ, spec.length, spec.nullable) + } + } + + var indexes []string + if err := db.Raw(`SELECT indexdef FROM pg_indexes WHERE tablename = ?`, table).Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + joined := strings.Join(indexes, "\n") + for _, def := range []string{ + "UNIQUE INDEX " + table + "_token_key ON public." + table + " USING btree (token)", + "UNIQUE INDEX " + table + "_short_code_key ON public." + table + " USING btree (short_code)", + "INDEX " + table + "_user_id_index ON public." + table + " USING btree (user_id)", + "INDEX " + table + "_status_index ON public." + table + " USING btree (status)", + "INDEX " + table + "_expires_at_index ON public." + table + " USING btree (expires_at)", + "INDEX " + table + "_jwt_jti_index ON public." + table + " USING btree (jwt_jti)", + } { + if !strings.Contains(joined, def) { + t.Fatalf("index %q missing from:\n%s", def, joined) + } + } + + pgCode := func(err error) string { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code + } + return "" + } + insert := func(token string, shortCode any, status string, userID any) error { + return db.Exec(`INSERT INTO `+table+` (token, short_code, status, user_id, expires_at) VALUES (?, ?, ?, ?, NOW())`, + token, shortCode, status, userID).Error + } + if err := insert("t1", "AAAA-AAAA", "pending", nil); err != nil { + t.Fatal(err) + } + if code := pgCode(insert("t1", "BBBB-BBBB", "pending", nil)); code != "23505" { + t.Fatalf("duplicate token: SQLSTATE %q, want 23505", code) + } + if code := pgCode(insert("t2", "AAAA-AAAA", "pending", nil)); code != "23505" { + t.Fatalf("duplicate short_code: SQLSTATE %q, want 23505", code) + } + if err := insert("t3", nil, "used", nil); err != nil { + t.Fatal(err) + } + if err := insert("t4", nil, "revoked", nil); err != nil { + t.Fatalf("a second NULL short_code: %v", err) + } + for _, status := range []string{"confirmed", "expired"} { + if err := insert("t-"+status, nil, status, nil); err != nil { + t.Fatalf("status %s refused: %v", status, err) + } + } + for _, status := range []string{"unknown", "PENDING", ""} { + if code := pgCode(insert("t-bad-"+status, nil, status, nil)); code != "23514" { + t.Fatalf("status %q: SQLSTATE %q, want 23514 (check)", status, code) + } + } + var status string + if err := db.Raw(`INSERT INTO ` + table + ` (token) VALUES ('t-default') RETURNING status`).Scan(&status).Error; err != nil || status != "pending" { + t.Fatalf("default status = %q (%v)", status, err) + } + + user := &models.User{Email: "device-migration@example.test", Password: "x"} + if err := db.Omit("Groups").Create(user).Error; err != nil { + t.Fatal(err) + } + if code := pgCode(insert("t-fk", nil, "pending", 987654321)); code != "23503" { + t.Fatalf("unknown user_id: SQLSTATE %q, want 23503", code) + } + if err := insert("t-owned", nil, "used", user.ID); err != nil { + t.Fatal(err) + } + if err := db.Exec(`DELETE FROM users WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + var owned int64 + if err := db.Raw(`SELECT COUNT(*) FROM ` + table + ` WHERE token = 't-owned'`).Scan(&owned).Error; err != nil || owned != 0 { + t.Fatalf("rows of a deleted user = %d (%v), want 0 (ON DELETE CASCADE)", owned, err) + } + + history := func() int64 { + var n int64 + if err := db.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, deviceAuthSessionMigrations[0].ID).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n + } + if history() != 1 { + t.Fatal("the migration is not recorded") + } + if err := m.RollbackMigration(deviceAuthSessionMigrations[0]); err != nil { + t.Fatal(err) + } + if db.Migrator().HasTable(table) || history() != 0 { + t.Fatal("rollback left the table or its history row") + } + if err := m.Migrate(); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(table) || history() != 1 { + t.Fatal("a second up did not recreate the table") + } + var rows int64 + if err := db.Session(&gorm.Session{}).Raw(`SELECT COUNT(*) FROM ` + table).Scan(&rows).Error; err != nil || rows != 0 { + t.Fatalf("the recreated table has %d rows (%v)", rows, err) + } +}