feat: 安全设置展示有效登录会话,支持剔除其它设备

按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 01:48:52 +08:00
parent 2ccfb5f330
commit 3a7bfe59df
16 changed files with 808 additions and 28 deletions

View File

@@ -63,7 +63,8 @@ type UserClaims struct {
Username string `json:"username"`
Role string `json:"role"`
Banned bool `json:"banned"`
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
FamilyID uint `json:"fid"` // 登录会话家族,剔除设备时使该会话 JWT 立即失效
}
// AuthService 认证服务
@@ -111,7 +112,7 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
}
// Login 用户登录,返回 access token + refresh token + user
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
@@ -126,11 +127,11 @@ func (s *AuthService) Login(username, password string) (string, string, *model.U
return "", "", nil, ErrAccountBanned
}
accessToken, err := s.generateToken(&user)
refreshToken, familyID, err := s.CreateRefreshToken(user.ID, ip, ua)
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.CreateRefreshToken(user.ID)
accessToken, err := s.generateToken(&user, familyID)
if err != nil {
return "", "", nil, err
}
@@ -138,7 +139,7 @@ func (s *AuthService) Login(username, password string) (string, string, *model.U
}
// generateToken 签发 access token(短期)
func (s *AuthService) generateToken(user *model.User) (string, error) {
func (s *AuthService) generateToken(user *model.User, familyID uint) (string, error) {
claims := &tokenClaims{
UserClaims: UserClaims{
ID: user.ID,
@@ -146,6 +147,7 @@ func (s *AuthService) generateToken(user *model.User) (string, error) {
Role: string(user.Role),
Banned: user.Banned,
TokenVersion: user.TokenVersion,
FamilyID: familyID,
},
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.Username,
@@ -190,9 +192,22 @@ func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
if user.TokenVersion != claims.TokenVersion {
return nil, ErrTokenRevoked
}
// 单设备剔除:该会话家族已无有效 refresh token 则立即失效
if claims.FamilyID != 0 && !s.familySessionActive(user.ID, claims.FamilyID) {
return nil, ErrTokenRevoked
}
return &user, nil
}
func (s *AuthService) familySessionActive(userID, familyID uint) bool {
var n int64
s.db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (family_id = ? OR (family_id = 0 AND id = ?))",
userID, false, time.Now(), familyID, familyID).
Count(&n)
return n > 0
}
// GetUserByID 根据 ID 获取用户
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
var user model.User
@@ -279,23 +294,33 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
return string(plain), nil
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文)
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
if err != nil {
return "", err
return "", 0, err
}
now := time.Now()
rt := &model.RefreshToken{
UserID: userID,
TokenHash: hashRefreshToken(token),
TokenCipher: cipherText,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
IP: truncateStr(ip, 45),
UserAgent: truncateStr(ua, 500),
LoggedInAt: now,
LastUsedAt: now,
ExpiresAt: now.Add(RefreshTokenTTL),
}
if err := s.db.Create(rt).Error; err != nil {
return "", err
return "", 0, err
}
return token, nil
if err := s.db.Model(rt).Update("family_id", rt.ID).Error; err != nil {
return "", 0, err
}
rt.FamilyID = rt.ID
s.revokeSameFingerprintExcept(userID, rt.FamilyID, ip, ua)
return token, rt.FamilyID, nil
}
// loadActiveUser 加载用户并实时校验封禁状态
@@ -336,7 +361,7 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
var (
accessToken, newRefresh string
outUser *model.User
@@ -368,7 +393,7 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
next.TokenCipher != "" {
if sameRefresh, e := s.decryptToken(next.TokenCipher); e == nil {
if at, e := s.generateToken(user); e == nil {
if at, e := s.generateToken(user, sessionFamilyID(next)); e == nil {
accessToken, newRefresh, outUser = at, sameRefresh, user
return nil
}
@@ -392,10 +417,24 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
if err != nil {
return err
}
familyID := sessionFamilyID(rt)
ipOut := truncateStr(ip, 45)
if ipOut == "" {
ipOut = rt.IP
}
uaOut := truncateStr(ua, 500)
if uaOut == "" {
uaOut = rt.UserAgent
}
nr := &model.RefreshToken{
UserID: rt.UserID,
FamilyID: familyID,
TokenHash: hashRefreshToken(plain),
TokenCipher: cipherText,
IP: ipOut,
UserAgent: uaOut,
LoggedInAt: tokenLoggedInAt(rt),
LastUsedAt: now,
ExpiresAt: now.Add(RefreshTokenTTL),
}
if err := tx.Create(nr).Error; err != nil {
@@ -411,7 +450,7 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
}).Error; err != nil {
return err
}
at, err := s.generateToken(user)
at, err := s.generateToken(user, familyID)
if err != nil {
return err
}