The revoke now reads the device's jti with UPDATE ... RETURNING under the row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a session revoked meanwhile, so the refresh answers the blacklisted-token 401 without a token. A hand-over error fails the refresh closed instead of leaving the session on a stale jti.
1174 lines
49 KiB
Go
1174 lines
49 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDeviceRevokeRacesRefresh pins that a revoke and a refresh of the same
|
|
// device cannot leave the device signed in, whichever commits first. A
|
|
// refresh whose device session was revoked after the token passed the
|
|
// blacklist check (the revoke committed between bouncer.Refresh and the
|
|
// hand-over) is refused with the blacklisted-token answer and hands out no
|
|
// token; a revoke reads the jti under the row lock the hand-over takes, so
|
|
// a device that refreshes in a tight loop while it is revoked always ends
|
|
// with its last token refused.
|
|
func TestDeviceRevokeRacesRefresh(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Postgres")
|
|
}
|
|
env := newAdminEnv(t)
|
|
const blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
|
|
|
|
t.Run("revoked_after_the_blacklist_check", func(t *testing.T) {
|
|
_, session := envSession(t, env, "drace-check")
|
|
token, jti, id := envDevice(t, env, session, "Race desk")
|
|
// The state a revoke leaves when it commits after bouncer.Refresh
|
|
// checked the blacklist: the row is revoked and the token the
|
|
// refresh presented is not blacklisted (yet).
|
|
envSetDevice(t, env, id, map[string]any{"status": models.DeviceStatusRevoked})
|
|
wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil), http.StatusUnauthorized, blacklistedBody)
|
|
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 != jti || row.Status != models.DeviceStatusRevoked {
|
|
t.Fatalf("a refused refresh moved the revoked row: %+v", row)
|
|
}
|
|
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("the presented token still works: %d", rec.Code)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
|
|
})
|
|
|
|
t.Run("refresh_loop_and_revoke", func(t *testing.T) {
|
|
for round := range 8 {
|
|
// A user per round keeps the refreshes inside the per-user
|
|
// user-api bucket.
|
|
_, session := envSession(t, env, "drace-loop-"+strconv.Itoa(round))
|
|
token, _, id := envDevice(t, env, session, "Loop desk")
|
|
var (
|
|
wg sync.WaitGroup
|
|
last = token
|
|
status int
|
|
)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 100 {
|
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil)
|
|
if status = rec.Code; status != http.StatusOK {
|
|
return
|
|
}
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
|
|
t.Errorf("refresh %s: %v", rec.Body.String(), err)
|
|
return
|
|
}
|
|
last = body.Token
|
|
}
|
|
}()
|
|
time.Sleep(time.Duration(round) * 2 * time.Millisecond)
|
|
wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody)
|
|
wg.Wait()
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("round %d: the refresh loop ended with %d, want the 401 of a revoked device", round, status)
|
|
}
|
|
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", last, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("round %d: the device's last token still works after the revoke: %d", round, rec.Code)
|
|
}
|
|
if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("round %d: the device's last token still refreshes after the revoke: %d", round, rec.Code)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|