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
+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