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