diff --git a/internal/repository/refresh_token_repository.go b/internal/repository/refresh_token_repository.go index b759d97..55d89ed 100644 --- a/internal/repository/refresh_token_repository.go +++ b/internal/repository/refresh_token_repository.go @@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri }) } -// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a -// user by revoking the oldest non-expired, non-revoked tokens. -func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error { +// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens +// for a user among tokens that are not bound to a device auth session +// (legacy web clients). Device sessions are independent: trimming one +// device's token because another device logged in would silently sign the +// first device out even though its session is still valid. +func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error { if limit < 1 { limit = 1 } return withSQLiteBusyRetry(ctx, func() error { var tokens []model.RefreshToken if err := r.db.WithContext(ctx). - Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()). + Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')", + userID, false, time.Now()). Order("created_at desc, id desc"). Find(&tokens).Error; err != nil { return err diff --git a/internal/service/auth_session_test.go b/internal/service/auth_session_test.go index e4b7ee7..fe8584e 100644 --- a/internal/service/auth_session_test.go +++ b/internal/service/auth_session_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "testing" "time" @@ -163,6 +164,52 @@ func TestRefreshKeepsSessionAssociation(t *testing.T) { } } +// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录: +// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销 +// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌 +// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备 +// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。 +func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) { + sessions, tokens, repos := newAuthSessionTestEnv(t) + ctx := context.Background() + user := seedSessionUser(t, repos) + + // 默认上限是 3,这里故意登录 4 台设备。 + const devices = 4 + pairs := make([]*TokenPair, 0, devices) + for i := 0; i < devices; i++ { + session, err := sessions.Create(ctx, CreateAuthSessionInput{ + UserID: user.ID, + DeviceID: fmt.Sprintf("device-%02d", i), + DeviceName: fmt.Sprintf("Phone %d", i+1), + }) + if err != nil { + t.Fatalf("create session %d: %v", i+1, err) + } + pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID) + if err != nil { + t.Fatalf("issue pair %d: %v", i+1, err) + } + pairs = append(pairs, pair) + } + + for i, pair := range pairs { + row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken)) + if err != nil { + t.Fatalf("load device %d token: %v", i+1, err) + } + if row == nil { + t.Fatalf("device %d refresh token missing", i+1) + } + if row.Revoked { + t.Fatalf("device %d refresh token was revoked by another device", i+1) + } + if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil { + t.Fatalf("device %d refresh failed: %v", i+1, err) + } + } +} + func TestServerIdentityPersistsAcrossInstances(t *testing.T) { db := newServiceTestDB(t, &model.Setting{}) repos := repository.New(db) diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index b887765..36ab925 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT if err := s.repo.RefreshToken.Create(ctx, rt); err != nil { return err } - if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil { - s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err)) + // 「最大同时登录终端」只约束未绑定设备会话的旧客户端。 + // + // 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一 + // 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备 + // 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫 + // 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。 + if rt.AuthSessionID == "" { + if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil { + s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err)) + } } return nil }