Files
moteva/server/internal/modules/auth/memory_store.go
T
root 58f11302fe 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>
2026-07-11 01:02:05 +08:00

356 lines
9.2 KiB
Go

package auth
import (
"context"
"sort"
"strings"
"sync"
"time"
"img_infinite_canvas/internal/domain/design"
)
type MemoryStore struct {
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),
sessions: make(map[string]DeviceSession),
}
}
func (s *MemoryStore) FindUserByID(ctx context.Context, id string) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[strings.TrimSpace(id)]
if !ok {
return User{}, design.ErrNotFound
}
return user, nil
}
func (s *MemoryStore) FindUserByPhone(ctx context.Context, countryCode string, phone string) (User, error) {
return s.find(ctx, func(user User) bool {
return user.CountryCode == normalizeCountryCode(countryCode) && user.Phone == normalizePhone(phone)
})
}
func (s *MemoryStore) FindUserByEmail(ctx context.Context, email string) (User, error) {
email = normalizeEmail(email)
return s.find(ctx, func(user User) bool {
return normalizeEmail(user.Email) == email
})
}
func (s *MemoryStore) FindUserByGoogleSubject(ctx context.Context, subject string) (User, error) {
subject = strings.TrimSpace(subject)
return s.find(ctx, func(user User) bool {
return user.GoogleSubject == subject
})
}
func (s *MemoryStore) FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error) {
openID = strings.TrimSpace(openID)
unionID = strings.TrimSpace(unionID)
return s.find(ctx, func(user User) bool {
if unionID != "" && user.WechatUnionID == unionID {
return true
}
return openID != "" && user.WechatOpenID == openID
})
}
func (s *MemoryStore) UpsertUser(ctx context.Context, user User) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
user.ID = normalizeUUID(user.ID)
user.Email = normalizeEmail(user.Email)
user.Phone = normalizePhone(user.Phone)
user.CountryCode = normalizeCountryCode(user.CountryCode)
user.Name = strings.TrimSpace(user.Name)
user.AvatarURL = strings.TrimSpace(user.AvatarURL)
user.Status = activeStatus(user.Status)
s.mu.Lock()
defer s.mu.Unlock()
s.users[user.ID] = user
return user, nil
}
func (s *MemoryStore) UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
userID = normalizeUUID(userID)
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return User{}, design.ErrNotFound
}
if patch.Name != nil {
user.Name = strings.TrimSpace(*patch.Name)
}
if patch.AvatarURL != nil {
user.AvatarURL = strings.TrimSpace(*patch.AvatarURL)
}
s.users[user.ID] = user
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
}
s.mu.Lock()
defer s.mu.Unlock()
s.codes[code.ID] = code
return nil
}
func (s *MemoryStore) FindActiveVerificationCode(ctx context.Context, region string, channel string, target string, purpose string) (VerificationCode, error) {
if err := ctx.Err(); err != nil {
return VerificationCode{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
var latest VerificationCode
for _, code := range s.codes {
if code.Region != region || code.Channel != channel || code.Target != target || code.Purpose != purpose {
continue
}
if code.ConsumedAt != nil {
continue
}
if latest.ID == "" || code.ExpiresAt.After(latest.ExpiresAt) {
latest = code
}
}
if latest.ID == "" {
return VerificationCode{}, design.ErrNotFound
}
return latest, nil
}
func (s *MemoryStore) MarkVerificationCodeConsumed(ctx context.Context, id string, consumedAt time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
code, ok := s.codes[id]
if !ok {
return design.ErrNotFound
}
code.ConsumedAt = &consumedAt
s.codes[id] = code
return nil
}
func (s *MemoryStore) IncrementVerificationCodeAttempts(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
code, ok := s.codes[id]
if !ok {
return design.ErrNotFound
}
code.Attempts++
s.codes[id] = code
return nil
}
func (s *MemoryStore) Close() error {
return nil
}
func (s *MemoryStore) find(ctx context.Context, match func(User) bool) (User, error) {
if err := ctx.Err(); err != nil {
return User{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.users {
if match(user) {
return user, nil
}
}
return User{}, design.ErrNotFound
}