mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
|
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
|
||||||
// user by revoking the oldest non-expired, non-revoked tokens.
|
// for a user among tokens that are not bound to a device auth session
|
||||||
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
|
// (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 {
|
if limit < 1 {
|
||||||
limit = 1
|
limit = 1
|
||||||
}
|
}
|
||||||
return withSQLiteBusyRetry(ctx, func() error {
|
return withSQLiteBusyRetry(ctx, func() error {
|
||||||
var tokens []model.RefreshToken
|
var tokens []model.RefreshToken
|
||||||
if err := r.db.WithContext(ctx).
|
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").
|
Order("created_at desc, id desc").
|
||||||
Find(&tokens).Error; err != nil {
|
Find(&tokens).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Setting{})
|
db := newServiceTestDB(t, &model.Setting{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
|
|||||||
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user