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