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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user