feat(auth): add device session tracking with configurable limits

Track authenticated sessions per device (parsing user-agent for desktop
vs mobile via mileusna/useragent), enforce configurable concurrent device
limits (Auth.DeviceLimits), and surface device status and "remove other
devices" management in the account dialog. Adds device/session context to
the auth module stores and exposes limits through the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:01:54 +08:00
parent 875fff825c
commit 58f11302fe
32 changed files with 1783 additions and 253 deletions
+242 -10
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -95,6 +96,127 @@ func TestGlobalEmailCodeLogin(t *testing.T) {
}
}
func TestDeviceLimitsUseConfiguredValuesAndSafeDefaults(t *testing.T) {
configured := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
DeviceLimits: DeviceLimits{Desktop: 5, Mobile: 3},
})
if limits := configured.DeviceLimits(); limits.Desktop != 5 || limits.Mobile != 3 {
t.Fatalf("unexpected configured limits %#v", limits)
}
defaults := NewService(NewMemoryStore(), Config{Region: RegionGlobal, TokenSecret: "test-secret"})
if limits := defaults.DeviceLimits(); limits.Desktop != 2 || limits.Mobile != 1 {
t.Fatalf("unexpected default limits %#v", limits)
}
}
func TestDesktopDeviceLimitRevokesOldestSession(t *testing.T) {
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
DeviceLimits: DeviceLimits{Desktop: 1, Mobile: 1},
})
now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
})
login := func() Session {
t.Helper()
code, err := service.RequestGlobalEmailCode(desktopCtx, "limit@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(desktopCtx, "limit@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
return session
}
first := login()
firstIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken)
if !ok {
t.Fatal("expected first desktop session to authenticate")
}
now = now.Add(time.Second)
second := login()
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
if !ok {
t.Fatal("expected newest desktop session to authenticate")
}
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
t.Fatal("expected oldest desktop session to be revoked at the configured limit")
}
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
devices, err := service.ListDevices(secondCtx, second.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID || !devices[0].Current {
t.Fatalf("expected only the newest desktop session, got %#v (first=%s)", devices, firstIdentity.SessionID)
}
}
func TestListDevicesReconcilesSessionsCreatedBeforeLimitEnforcement(t *testing.T) {
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
DeviceLimits: DeviceLimits{Desktop: 2, Mobile: 1},
})
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
})
login := func() Session {
t.Helper()
code, err := service.RequestGlobalEmailCode(desktopCtx, "reconcile@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(desktopCtx, "reconcile@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
return session
}
first := login()
now = now.Add(time.Second)
second := login()
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
if !ok {
t.Fatal("expected newest session to authenticate")
}
service.cfg.DeviceLimits.Desktop = 1
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
devices, err := service.ListDevices(secondCtx, second.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID {
t.Fatalf("expected list reconciliation to preserve only the current session, got %#v", devices)
}
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
t.Fatal("expected pre-enforcement excess session to be revoked")
}
}
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
@@ -130,36 +252,146 @@ func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
}
}
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
func TestAccountDevicesTrackAndRevokeOtherSessions(t *testing.T) {
store := NewMemoryStore()
service := NewService(store, Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
})
now := time.Date(2026, 7, 11, 9, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
desktopMetadata := RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
}
desktopLoginCtx := ContextWithRequestMetadata(context.Background(), desktopMetadata)
code, err := service.RequestGlobalEmailCode(desktopLoginCtx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
desktopSession, err := service.LoginGlobalEmailCode(desktopLoginCtx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
devices, err := service.ListDevices(ctx, session.User.ID)
desktopIdentity, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken)
if !ok {
t.Fatal("expected desktop session to authenticate")
}
now = now.Add(2 * time.Minute)
mobileMetadata := RequestMetadata{
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
Platform: `"iOS"`,
Mobile: "?1",
}
mobileLoginCtx := ContextWithRequestMetadata(context.Background(), mobileMetadata)
code, err = service.RequestGlobalEmailCode(mobileLoginCtx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
mobileSession, err := service.LoginGlobalEmailCode(mobileLoginCtx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
mobileIdentity, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken)
if !ok {
t.Fatal("expected mobile session to authenticate")
}
mobileCtx := ContextWithSessionID(mobileLoginCtx, mobileIdentity.SessionID)
devices, err := service.ListDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 2 {
t.Fatalf("expected two devices, got %#v", devices)
}
if !devices[0].Current || devices[0].ID != mobileIdentity.SessionID || devices[0].DeviceType != "mobile" || devices[0].System != "iOS" {
t.Fatalf("expected current mobile device first, got %#v", devices[0])
}
if devices[1].Current || devices[1].ID != desktopIdentity.SessionID || devices[1].DeviceType != "desktop" || devices[1].System != "macOS" {
t.Fatalf("expected secondary desktop device, got %#v", devices[1])
}
removed, err := service.RemoveOtherDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("expected one removable device, got %d", removed)
}
if _, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken); ok {
t.Fatal("expected removed desktop session to be rejected")
}
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); !ok {
t.Fatal("expected current mobile session to remain valid")
}
devices, err = service.ListDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || !devices[0].Current {
t.Fatalf("expected one current device, got %#v", devices)
t.Fatalf("expected only the current device after removal, got %#v", devices)
}
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
removed, err = service.Logout(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("expected no removable devices, got %d", removed)
if removed != 1 {
t.Fatalf("expected logout to revoke one current session, got %d", removed)
}
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); ok {
t.Fatal("expected logged-out session to be rejected")
}
}
func TestLegacyBearerMigratesToRevocableDeviceSession(t *testing.T) {
store := NewMemoryStore()
service := NewService(store, Config{Region: RegionGlobal, TokenSecret: "test-secret", TokenTTL: time.Hour})
now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
user, err := store.UpsertUser(context.Background(), User{ID: newID(), Region: RegionGlobal, Email: "legacy@example.com", Status: "active"})
if err != nil {
t.Fatal(err)
}
legacyToken, _, err := signToken(user.ID, "test-secret", time.Hour, now)
if err != nil {
t.Fatal(err)
}
ctx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
})
identity, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken)
if !ok || identity.UserID != user.ID || !strings.HasPrefix(identity.SessionID, "legacy:") {
t.Fatalf("expected migrated legacy identity, got %#v / %v", identity, ok)
}
authenticatedCtx := ContextWithSessionID(ctx, identity.SessionID)
devices, err := service.ListDevices(authenticatedCtx, user.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || !devices[0].Current || devices[0].System != "Windows" {
t.Fatalf("unexpected migrated device %#v", devices)
}
if _, err := service.Logout(authenticatedCtx, user.ID); err != nil {
t.Fatal(err)
}
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken); ok {
t.Fatal("expected revoked legacy token to stay revoked")
}
nonUserToken, _, err := signToken(newID(), "test-secret", time.Hour, now)
if err != nil {
t.Fatal(err)
}
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+nonUserToken); ok {
t.Fatal("expected signed token for a missing user to be rejected")
}
}