发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
This commit is contained in:
+58
-12
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user