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