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
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"strings"
"github.com/mileusna/useragent"
)
const (
unknownDeviceSystem = "unknown"
unknownDeviceBrowser = "unknown"
)
func deviceSessionFromMetadata(metadata RequestMetadata) DeviceSession {
parsed := useragent.Parse(strings.TrimSpace(metadata.UserAgent))
deviceType := "desktop"
client := "PC-WEB"
if clientHintMobile(metadata.Mobile) || parsed.Mobile || parsed.Tablet {
deviceType = "mobile"
client = "MOBILE-WEB"
}
system := cleanDeviceLabel(parsed.OS, unknownDeviceSystem)
if platform := cleanClientHint(metadata.Platform); platform != "" {
system = platform
}
browser := cleanDeviceLabel(parsed.Name, unknownDeviceBrowser)
if parsed.Version != "" && browser != unknownDeviceBrowser {
browser += " " + majorVersion(parsed.Version)
}
return DeviceSession{
DeviceType: deviceType,
System: system,
Browser: browser,
Client: client,
UserAgent: limitMetadata(strings.TrimSpace(metadata.UserAgent), 1024),
}
}
func clientHintMobile(value string) bool {
value = strings.Trim(strings.TrimSpace(value), `"`)
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "?1")
}
func cleanClientHint(value string) string {
value = strings.Trim(strings.TrimSpace(value), `"`)
if value == "" || strings.EqualFold(value, "unknown") {
return ""
}
return limitMetadata(value, 80)
}
func cleanDeviceLabel(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "unknown") {
return fallback
}
return limitMetadata(value, 80)
}
func majorVersion(version string) string {
version = strings.TrimSpace(version)
if index := strings.IndexByte(version, '.'); index >= 0 {
version = version[:index]
}
return limitMetadata(version, 16)
}
func limitMetadata(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
@@ -0,0 +1,62 @@
package auth
import (
"strings"
"testing"
)
func TestDeviceSessionFromMetadata(t *testing.T) {
tests := []struct {
name string
metadata RequestMetadata
deviceType string
system string
browser string
client string
}{
{
name: "unknown desktop fallback",
metadata: RequestMetadata{},
deviceType: "desktop",
system: "unknown",
browser: "unknown",
client: "PC-WEB",
},
{
name: "desktop chrome",
metadata: RequestMetadata{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
Platform: `"Windows"`,
Mobile: "?0",
},
deviceType: "desktop",
system: "Windows",
browser: "Chrome 126",
client: "PC-WEB",
},
{
name: "mobile safari from client hints",
metadata: RequestMetadata{
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
Platform: `"iOS"`,
Mobile: "?1",
},
deviceType: "mobile",
system: "iOS",
browser: "Safari 18",
client: "MOBILE-WEB",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := deviceSessionFromMetadata(test.metadata)
if session.DeviceType != test.deviceType || session.System != test.system || session.Client != test.client {
t.Fatalf("unexpected device metadata %#v", session)
}
if !strings.HasPrefix(session.Browser, test.browser) {
t.Fatalf("expected browser %q, got %q", test.browser, session.Browser)
}
})
}
}
+173 -5
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"sort"
"strings"
"sync"
"time"
@@ -10,15 +11,17 @@ import (
)
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
codes map[string]VerificationCode
mu sync.RWMutex
users map[string]User
codes map[string]VerificationCode
sessions map[string]DeviceSession
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
users: make(map[string]User),
codes: make(map[string]VerificationCode),
users: make(map[string]User),
codes: make(map[string]VerificationCode),
sessions: make(map[string]DeviceSession),
}
}
@@ -104,6 +107,171 @@ func (s *MemoryStore) UpdateUserProfile(ctx context.Context, userID string, patc
return user, nil
}
func (s *MemoryStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.sessions[session.ID]; !exists {
s.sessions[session.ID] = session
}
s.enforceDeviceSessionLimitLocked(session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt)
return nil
}
func (s *MemoryStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
if err := ctx.Err(); err != nil {
return DeviceSession{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[strings.TrimSpace(id)]
if !ok {
return DeviceSession{}, design.ErrNotFound
}
return session, nil
}
func (s *MemoryStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
userID = normalizeUUID(userID)
s.mu.RLock()
defer s.mu.RUnlock()
sessions := make([]DeviceSession, 0)
for _, session := range s.sessions {
if session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
continue
}
sessions = append(sessions, session)
}
return sessions, nil
}
func (s *MemoryStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.sessions[session.ID]
if !ok || stored.UserID != session.UserID {
return design.ErrNotFound
}
if stored.RevokedAt != nil || !stored.ExpiresAt.After(session.LastSeenAt) {
return design.ErrNotFound
}
if stored.LastSeenAt.After(session.LastSeenAt) {
return nil
}
stored.DeviceType = session.DeviceType
stored.System = session.System
stored.Browser = session.Browser
stored.Client = session.Client
stored.UserAgent = session.UserAgent
stored.LastSeenAt = session.LastSeenAt
s.sessions[session.ID] = stored
return nil
}
func (s *MemoryStore) EnforceDeviceSessionLimit(
ctx context.Context,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.enforceDeviceSessionLimitLocked(normalizeUUID(userID), deviceType, keepSessionID, activeLimit, revokedAt), nil
}
func (s *MemoryStore) enforceDeviceSessionLimitLocked(
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) int64 {
if activeLimit < 1 {
activeLimit = 1
}
active := make([]DeviceSession, 0)
for _, session := range s.sessions {
if session.UserID != userID || session.DeviceType != deviceType || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
continue
}
active = append(active, session)
}
sort.SliceStable(active, func(i, j int) bool {
iKeep := active[i].ID == keepSessionID
jKeep := active[j].ID == keepSessionID
if iKeep != jKeep {
return iKeep
}
if !active[i].CreatedAt.Equal(active[j].CreatedAt) {
return active[i].CreatedAt.After(active[j].CreatedAt)
}
if !active[i].LastSeenAt.Equal(active[j].LastSeenAt) {
return active[i].LastSeenAt.After(active[j].LastSeenAt)
}
return active[i].ID > active[j].ID
})
var removed int64
for index := int(activeLimit); index < len(active); index++ {
session := active[index]
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[session.ID] = session
removed++
}
return removed
}
func (s *MemoryStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
userID = normalizeUUID(userID)
s.mu.Lock()
defer s.mu.Unlock()
var removed int64
for id, session := range s.sessions {
if session.UserID != userID || session.ID == currentSessionID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
continue
}
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[id] = session
removed++
}
return removed, nil
}
func (s *MemoryStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
userID = normalizeUUID(userID)
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if !ok || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
return 0, nil
}
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[sessionID] = session
return 1, nil
}
func (s *MemoryStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
if err := ctx.Err(); err != nil {
return err
@@ -112,6 +112,174 @@ RETURNING id, region, phone_country_code, phone, email, name, avatar_url, passwo
return scanUser(row)
}
func (s *PostgresStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockDeviceSessions(ctx, tx, session.UserID, session.DeviceType); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO auth_device_sessions (
id, user_id, device_type, system, browser, client, user_agent,
created_at, last_seen_at, expires_at, revoked_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO NOTHING
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent,
session.CreatedAt, session.LastSeenAt, session.ExpiresAt, session.RevokedAt); err != nil {
return err
}
if _, err := enforceDeviceSessionLimitPostgres(ctx, tx, session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *PostgresStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
return scanDeviceSession(s.pool.QueryRow(ctx, `
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
FROM auth_device_sessions
WHERE id = $1
`, strings.TrimSpace(id)))
}
func (s *PostgresStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
FROM auth_device_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > $2
ORDER BY last_seen_at DESC, created_at DESC, id ASC
`, toPGUUID(userID), now)
if err != nil {
return nil, err
}
defer rows.Close()
sessions := make([]DeviceSession, 0)
for rows.Next() {
session, scanErr := scanDeviceSession(rows)
if scanErr != nil {
return nil, scanErr
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, err
}
return sessions, nil
}
func (s *PostgresStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET device_type = $3,
system = $4,
browser = $5,
client = $6,
user_agent = $7,
last_seen_at = GREATEST(last_seen_at, $8)
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > $8
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent, session.LastSeenAt)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return design.ErrNotFound
}
return nil
}
func (s *PostgresStore) EnforceDeviceSessionLimit(
ctx context.Context,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockDeviceSessions(ctx, tx, userID, deviceType); err != nil {
return 0, err
}
removed, err := enforceDeviceSessionLimitPostgres(ctx, tx, userID, deviceType, keepSessionID, activeLimit, revokedAt)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return removed, nil
}
func lockDeviceSessions(ctx context.Context, tx pgx.Tx, userID string, deviceType string) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, normalizeUUID(userID)+":"+deviceType)
return err
}
func enforceDeviceSessionLimitPostgres(
ctx context.Context,
tx pgx.Tx,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
if activeLimit < 1 {
activeLimit = 1
}
result, err := tx.Exec(ctx, `
WITH overflow AS (
SELECT id
FROM auth_device_sessions
WHERE user_id = $1 AND device_type = $2 AND revoked_at IS NULL AND expires_at > $4
ORDER BY CASE WHEN id = $3 THEN 0 ELSE 1 END,
created_at DESC,
last_seen_at DESC,
id DESC
OFFSET $5
)
UPDATE auth_device_sessions
SET revoked_at = $4
WHERE id IN (SELECT id FROM overflow)
`, toPGUUID(userID), deviceType, strings.TrimSpace(keepSessionID), revokedAt, activeLimit)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET revoked_at = $3
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL AND expires_at > $3
`, toPGUUID(userID), strings.TrimSpace(currentSessionID), revokedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET revoked_at = $3
WHERE user_id = $1 AND id = $2 AND revoked_at IS NULL AND expires_at > $3
`, toPGUUID(userID), strings.TrimSpace(sessionID), revokedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO auth_verification_codes (id, region, channel, target, purpose, code_hash, attempts, expires_at, created_at)
@@ -179,6 +347,31 @@ func scanUser(row rowScanner) (User, error) {
return user, nil
}
func scanDeviceSession(row rowScanner) (DeviceSession, error) {
var userID pgtype.UUID
var session DeviceSession
if err := row.Scan(
&session.ID,
&userID,
&session.DeviceType,
&session.System,
&session.Browser,
&session.Client,
&session.UserAgent,
&session.CreatedAt,
&session.LastSeenAt,
&session.ExpiresAt,
&session.RevokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DeviceSession{}, design.ErrNotFound
}
return DeviceSession{}, err
}
session.UserID = fromPGUUID(userID)
return session, nil
}
func toPGUUID(id string) pgtype.UUID {
parsed, err := uuid.Parse(normalizeUUID(id))
if err != nil {
@@ -0,0 +1,74 @@
package auth
import (
"context"
"os"
"sync"
"testing"
"time"
)
func TestPostgresStoreEnforcesConcurrentDeviceLimit(t *testing.T) {
dataSource := os.Getenv("POSTGRES_TEST_DSN")
if dataSource == "" {
t.Skip("POSTGRES_TEST_DSN is not configured")
}
ctx := context.Background()
store, err := NewPostgresStore(ctx, dataSource)
if err != nil {
t.Fatal(err)
}
defer func() { _ = store.Close() }()
user, err := store.UpsertUser(ctx, User{
ID: newID(),
Region: RegionGlobal,
Email: newID() + "@device-limit.test",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = store.pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, toPGUUID(user.ID))
}()
createdAt := time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC)
const loginCount = 8
const activeLimit = 2
errCh := make(chan error, loginCount)
var wg sync.WaitGroup
for index := 0; index < loginCount; index++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
sessionTime := createdAt.Add(time.Duration(index) * time.Millisecond)
errCh <- store.CreateDeviceSession(ctx, DeviceSession{
ID: newID(),
UserID: user.ID,
DeviceType: "desktop",
System: "macOS",
Browser: "Chrome 149",
Client: "PC-WEB",
CreatedAt: sessionTime,
LastSeenAt: sessionTime,
ExpiresAt: sessionTime.Add(time.Hour),
}, activeLimit)
}(index)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
sessions, err := store.ListDeviceSessions(ctx, user.ID, createdAt)
if err != nil {
t.Fatal(err)
}
if len(sessions) != activeLimit {
t.Fatalf("expected %d active desktop sessions after concurrent login, got %d", activeLimit, len(sessions))
}
}
+181 -21
View File
@@ -3,11 +3,14 @@ package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"math/big"
"net/url"
"regexp"
"sort"
"strings"
"time"
@@ -17,6 +20,11 @@ import (
"golang.org/x/crypto/bcrypt"
)
const (
deviceOnlineWindow = 5 * time.Minute
sessionTouchWindow = time.Minute
)
type Config struct {
Region string
RegionMode string
@@ -24,6 +32,7 @@ type Config struct {
TokenTTL time.Duration
RefreshTokenTTL time.Duration
CodeTTL time.Duration
DeviceLimits DeviceLimits
DevReturnCode bool
EmailFromName string
EmailFromEmail string
@@ -78,6 +87,12 @@ func NewServiceWithCodeStoreEmailAndHumanVerifier(store Store, codeStore Verific
if cfg.CodeTTL <= 0 {
cfg.CodeTTL = 10 * time.Minute
}
if cfg.DeviceLimits.Desktop <= 0 {
cfg.DeviceLimits.Desktop = 2
}
if cfg.DeviceLimits.Mobile <= 0 {
cfg.DeviceLimits.Mobile = 1
}
if strings.TrimSpace(cfg.EmailFromName) == "" {
cfg.EmailFromName = loginEmailFromName
}
@@ -123,6 +138,10 @@ func (s *Service) Options() Options {
}, Turnstile: turnstile}
}
func (s *Service) DeviceLimits() DeviceLimits {
return s.cfg.DeviceLimits
}
func (s *Service) RequestChinaSMSCode(ctx context.Context, phone string, countryCode string, purpose string) (CodeResult, error) {
if err := s.requireRegion(RegionChina); err != nil {
return CodeResult{}, err
@@ -371,11 +390,39 @@ func (s *Service) LoginGlobalGoogle(ctx context.Context, idToken string) (Sessio
}
func (s *Service) UserIDFromBearer(ctx context.Context, authorization string) (string, bool) {
identity, ok := s.IdentityFromBearer(ctx, authorization)
return identity.UserID, ok
}
func (s *Service) IdentityFromBearer(ctx context.Context, authorization string) (Identity, bool) {
token, ok := bearerToken(authorization)
if !ok {
return "", false
return Identity{}, false
}
return parseToken(ctx, token, s.cfg.TokenSecret, s.now())
now := s.now().UTC()
payload, ok := parseAccessTokenPayload(ctx, token, s.cfg.TokenSecret, now)
if !ok {
return Identity{}, false
}
userID := normalizeUUID(payload.Subject)
sessionID := strings.TrimSpace(payload.SessionID)
if sessionID == "" {
sessionID = legacyDeviceSessionID(token)
if err := s.ensureLegacyDeviceSession(ctx, sessionID, userID, payload, now); err != nil {
return Identity{}, false
}
}
session, err := s.store.FindDeviceSession(ctx, sessionID)
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
return Identity{}, false
}
if !session.LastSeenAt.After(now.Add(-sessionTouchWindow)) {
if err := s.touchDeviceSession(ctx, session, now); err != nil {
return Identity{}, false
}
}
return Identity{UserID: userID, SessionID: sessionID}, true
}
func (s *Service) GetProfile(ctx context.Context, userID string) (User, error) {
@@ -401,35 +448,79 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, patch Profil
}
func (s *Service) ListDevices(ctx context.Context, userID string) ([]Device, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return nil, err
}
return []Device{
{
ID: "current",
Online: true,
Current: true,
DeviceType: "desktop",
System: "unknown",
Browser: "unknown",
Client: "PC-WEB",
LastSeenAt: s.now().UTC(),
},
}, nil
now := s.now().UTC()
currentSessionID := SessionIDFromContext(ctx)
for _, policy := range []struct {
deviceType string
limit int64
}{
{deviceType: "desktop", limit: s.cfg.DeviceLimits.Desktop},
{deviceType: "mobile", limit: s.cfg.DeviceLimits.Mobile},
} {
if _, err := s.store.EnforceDeviceSessionLimit(ctx, userID, policy.deviceType, currentSessionID, policy.limit, now); err != nil {
return nil, err
}
}
sessions, err := s.store.ListDeviceSessions(ctx, userID, now)
if err != nil {
return nil, err
}
devices := make([]Device, 0, len(sessions))
for _, session := range sessions {
current := session.ID == currentSessionID
devices = append(devices, Device{
ID: session.ID,
Online: current || session.LastSeenAt.After(now.Add(-deviceOnlineWindow)),
Current: current,
DeviceType: session.DeviceType,
System: session.System,
Browser: session.Browser,
Client: session.Client,
LastSeenAt: session.LastSeenAt,
})
}
if currentSessionID == "" && len(devices) == 1 {
devices[0].Current = true
devices[0].Online = true
}
sort.SliceStable(devices, func(i, j int) bool {
if devices[i].Current != devices[j].Current {
return devices[i].Current
}
if !devices[i].LastSeenAt.Equal(devices[j].LastSeenAt) {
return devices[i].LastSeenAt.After(devices[j].LastSeenAt)
}
return devices[i].ID < devices[j].ID
})
return devices, nil
}
func (s *Service) RemoveOtherDevices(ctx context.Context, userID string) (int64, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return 0, err
}
return 0, nil
currentSessionID := SessionIDFromContext(ctx)
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
return 0, err
}
return s.store.RevokeOtherDeviceSessions(ctx, userID, currentSessionID, s.now().UTC())
}
func (s *Service) Logout(ctx context.Context, userID string) (int64, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return 0, err
}
return 0, nil
currentSessionID := SessionIDFromContext(ctx)
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
return 0, err
}
return s.store.RevokeDeviceSession(ctx, userID, currentSessionID, s.now().UTC())
}
func (s *Service) Close() error {
@@ -527,14 +618,24 @@ func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error)
return Session{}, err
}
now := s.now()
token, expiresIn, err := signToken(saved.ID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
sessionID := newID()
token, expiresIn, err := signSessionToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
if err != nil {
return Session{}, err
}
refreshToken, refreshExpiresIn, err := signRefreshToken(saved.ID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
refreshToken, refreshExpiresIn, err := signSessionRefreshToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
if err != nil {
return Session{}, err
}
deviceSession := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
deviceSession.ID = sessionID
deviceSession.UserID = saved.ID
deviceSession.CreatedAt = now.UTC()
deviceSession.LastSeenAt = now.UTC()
deviceSession.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second).UTC()
if err := s.store.CreateDeviceSession(ctx, deviceSession, s.deviceSessionLimit(deviceSession.DeviceType)); err != nil {
return Session{}, err
}
return Session{
Status: SessionStatusAuthenticated,
User: saved,
@@ -545,6 +646,65 @@ func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error)
}, nil
}
func (s *Service) ensureLegacyDeviceSession(ctx context.Context, sessionID string, userID string, payload tokenPayload, now time.Time) error {
if _, err := s.store.FindDeviceSession(ctx, sessionID); err == nil {
return nil
} else if !errors.Is(err, design.ErrNotFound) {
return err
}
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return err
}
createdAt := time.Unix(payload.Issued, 0).UTC()
if payload.Issued <= 0 || createdAt.After(now) {
createdAt = now
}
session := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
session.ID = sessionID
session.UserID = userID
session.CreatedAt = createdAt
session.LastSeenAt = now
session.ExpiresAt = time.Unix(payload.Expires, 0).UTC()
return s.store.CreateDeviceSession(ctx, session, s.deviceSessionLimit(session.DeviceType))
}
func (s *Service) touchDeviceSession(ctx context.Context, session DeviceSession, now time.Time) error {
metadata := RequestMetadataFromContext(ctx)
if metadata.UserAgent != "" || metadata.Platform != "" || metadata.Mobile != "" {
parsed := deviceSessionFromMetadata(metadata)
session.DeviceType = parsed.DeviceType
session.System = parsed.System
session.Browser = parsed.Browser
session.Client = parsed.Client
session.UserAgent = parsed.UserAgent
}
session.LastSeenAt = now
return s.store.TouchDeviceSession(ctx, session)
}
func (s *Service) requireActiveDeviceSession(ctx context.Context, userID string, sessionID string) error {
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("%w: current session is required", ErrInvalidCredentials)
}
session, err := s.store.FindDeviceSession(ctx, sessionID)
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(s.now().UTC()) {
return fmt.Errorf("%w: current session is invalid", ErrInvalidCredentials)
}
return nil
}
func (s *Service) deviceSessionLimit(deviceType string) int64 {
if deviceType == "mobile" {
return s.cfg.DeviceLimits.Mobile
}
return s.cfg.DeviceLimits.Desktop
}
func legacyDeviceSessionID(token string) string {
digest := sha256.Sum256([]byte(strings.TrimSpace(token)))
return "legacy:" + base64.RawURLEncoding.EncodeToString(digest[:])
}
func (s *Service) requireRegion(region string) error {
if normalizeRegion(s.cfg.Region) != region {
return fmt.Errorf("%w: current region is %s", ErrInvalidRegion, s.cfg.Region)
+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")
}
}
@@ -0,0 +1,24 @@
package auth
import "context"
type requestMetadataContextKey struct{}
type sessionIDContextKey struct{}
func ContextWithRequestMetadata(ctx context.Context, metadata RequestMetadata) context.Context {
return context.WithValue(ctx, requestMetadataContextKey{}, metadata)
}
func RequestMetadataFromContext(ctx context.Context) RequestMetadata {
metadata, _ := ctx.Value(requestMetadataContextKey{}).(RequestMetadata)
return metadata
}
func ContextWithSessionID(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionIDContextKey{}, sessionID)
}
func SessionIDFromContext(ctx context.Context) string {
sessionID, _ := ctx.Value(sessionIDContextKey{}).(string)
return sessionID
}
+39 -37
View File
@@ -13,67 +13,69 @@ import (
)
type tokenPayload struct {
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
SessionID string `json:"sid,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
}
func signToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", secret, ttl, now)
return signTypedToken(userID, "access", "", secret, ttl, now)
}
func signRefreshToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", secret, ttl, now)
return signTypedToken(userID, "refresh", "", secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
func signSessionToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", sessionID, secret, ttl, now)
}
func signSessionRefreshToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", sessionID, secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
payload := tokenPayload{
Subject: userID,
Type: tokenType,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
Subject: userID,
Type: tokenType,
SessionID: sessionID,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
}
token, err := signPayload(payload, secret)
return token, int64(ttl.Seconds()), err
}
func parseToken(ctx context.Context, token string, secret string, now time.Time) (string, bool) {
if err := ctx.Err(); err != nil {
return "", false
}
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 2 {
return "", false
}
expected := signBytes([]byte(parts[0]), secret)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return "", false
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
var payload tokenPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", false
}
if payload.Subject == "" || payload.Expires <= now.Unix() {
return "", false
payload, ok := parseAccessTokenPayload(ctx, token, secret, now)
return payload.Subject, ok
}
func parseAccessTokenPayload(ctx context.Context, token string, secret string, now time.Time) (tokenPayload, bool) {
payload, ok := parsePayload(ctx, token, secret)
if !ok || payload.Subject == "" || payload.Expires <= now.Unix() {
return tokenPayload{}, false
}
if payload.Type != "" && payload.Type != "access" {
return "", false
return tokenPayload{}, false
}
return payload.Subject, true
return payload, true
}
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
+37
View File
@@ -79,6 +79,36 @@ type Device struct {
LastSeenAt time.Time
}
type DeviceSession struct {
ID string
UserID string
DeviceType string
System string
Browser string
Client string
UserAgent string
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
}
type RequestMetadata struct {
UserAgent string
Platform string
Mobile string
}
type Identity struct {
UserID string
SessionID string
}
type DeviceLimits struct {
Desktop int64
Mobile int64
}
type Session struct {
Status string
User User
@@ -146,6 +176,13 @@ type Store interface {
FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error)
UpsertUser(ctx context.Context, user User) (User, error)
UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error)
CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error
FindDeviceSession(ctx context.Context, id string) (DeviceSession, error)
ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error)
TouchDeviceSession(ctx context.Context, session DeviceSession) error
EnforceDeviceSessionLimit(ctx context.Context, userID string, deviceType string, keepSessionID string, activeLimit int64, revokedAt time.Time) (int64, error)
RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error)
RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error)
VerificationCodeStore
Close() error
}