feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user