发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s

This commit is contained in:
Wang Defa
2026-07-10 17:38:34 +08:00
parent 4af6a0ca92
commit dbba1f4905
78 changed files with 6898 additions and 551 deletions
+58 -12
View File
@@ -28,6 +28,13 @@ var ErrLoginLocked = errors.New("too many failed attempts, try again later")
// tokenTTL 是登录令牌有效期。
const tokenTTL = 24 * time.Hour
// authClaims 在标准声明外携带令牌版本;版本落后于账号当前值即失效。
// 存量令牌无 ver 字段解析为 0,与存量账号的零值版本兼容(升级不强制登出)。
type authClaims struct {
jwt.RegisteredClaims
Ver uint `json:"ver"`
}
// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"),
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
@@ -106,7 +113,7 @@ func (s *AuthService) createUser(username, password string) error {
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
key := clientIP + "|" + username
key := guardKey(clientIP, username)
now := time.Now()
sec := securityOf(s.settings)
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
@@ -138,7 +145,7 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
}
}
s.guard.success(key)
return s.signToken(user.Username)
return s.signToken(user.Username, user.TokenVersion)
}
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
@@ -169,15 +176,18 @@ func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings
})
}
func (s *AuthService) signToken(username string) (string, time.Time, error) {
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
now := time.Now()
expires := now.Add(tokenTTL)
claims := jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
claims := authClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
ID: newTokenID(),
},
Ver: ver,
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
if err != nil {
@@ -186,6 +196,34 @@ func (s *AuthService) signToken(username string) (string, time.Time, error) {
return token, expires, nil
}
// IssueToken 按账号当前令牌版本签发新 JWT;敏感操作递增版本后用它为
// 操作者重签,避免操作者自身会话中断。
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
user, err := s.findUser(ctx, username)
if err != nil {
return "", time.Time{}, err
}
return s.signToken(user.Username, user.TokenVersion)
}
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", username).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
if err != nil {
return fmt.Errorf("bump token version: %w", err)
}
return nil
}
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌。
func (s *AuthService) RevokeSessions(ctx context.Context, username string) (string, time.Time, error) {
if err := s.bumpTokenVersion(ctx, username); err != nil {
return "", time.Time{}, err
}
return s.IssueToken(ctx, username)
}
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
func newTokenID() string {
b := make([]byte, 16)
@@ -193,9 +231,10 @@ func newTokenID() string {
return hex.EncodeToString(b)
}
// ParseToken 验证 JWT 签名有效期返回其中的用户名。
func (s *AuthService) ParseToken(tokenString string) (string, error) {
claims := &jwt.RegisteredClaims{}
// ParseToken 验证 JWT 签名有效期与令牌版本,返回其中的用户名。
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
claims := &authClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
@@ -208,6 +247,13 @@ func (s *AuthService) ParseToken(tokenString string) (string, error) {
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
return "", errors.New("token revoked")
}
user, err := s.findUser(ctx, claims.Subject)
if err != nil {
return "", fmt.Errorf("token subject: %w", err)
}
if claims.Ver != user.TokenVersion {
return "", errors.New("token version outdated")
}
return claims.Subject, nil
}