+97
-32
@@ -39,13 +39,19 @@ type authClaims struct {
|
||||
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
||||
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
||||
|
||||
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)和 JWT 签发与验证。
|
||||
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)、JWT 签发与验证,
|
||||
// 以及会话落地与管理(session.go)。
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtSecret []byte
|
||||
guard *loginGuard
|
||||
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
||||
revoked *cache.Cache
|
||||
// seen 是会话最后活跃回写的节流缓存(jti→占位);撤销时清键保证即时生效
|
||||
seen *cache.Cache
|
||||
// revokedJti 是定点撤销的负缓存:无容量上限的专用结构,
|
||||
// 既不与高频 seen 回写争容量,也不存在满载淘汰导致的令牌复活
|
||||
revokedJti *jtiTombstones
|
||||
|
||||
notifier *Notifier
|
||||
settings *SettingService
|
||||
@@ -53,6 +59,9 @@ type AuthService struct {
|
||||
|
||||
totpMu sync.Mutex
|
||||
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
||||
|
||||
// cleanupWG 追踪会话清理 goroutine;关停时 Wait 保证清理查询已结束
|
||||
cleanupWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewAuthService 组装依赖。
|
||||
@@ -61,6 +70,8 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
db: db, jwtSecret: []byte(jwtSecret),
|
||||
guard: newLoginGuard(),
|
||||
revoked: cache.New(4096),
|
||||
seen: cache.New(4096),
|
||||
revokedJti: newJtiTombstones(),
|
||||
totpPending: map[string]pendingTotp{},
|
||||
}
|
||||
}
|
||||
@@ -109,11 +120,12 @@ func (s *AuthService) createUser(username, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破,
|
||||
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
|
||||
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
|
||||
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
|
||||
key := guardKey(clientIP, username)
|
||||
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT 并落地会话;
|
||||
// 按「IP+用户名」滑动窗口防爆破,锁定期内一律 ErrLoginLocked(正确密码同样拒绝);
|
||||
// 阈值与时长取安全设置。已启用两步验证时:密码通过但缺验证码返回
|
||||
// ErrTotpRequired(不计失败),验证码错误计入守卫。
|
||||
func (s *AuthService) Login(ctx context.Context, username, password, totpCode string, meta SessionMeta) (string, time.Time, error) {
|
||||
key := guardKey(meta.ClientIP, username)
|
||||
now := time.Now()
|
||||
sec := securityOf(s.settings)
|
||||
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
||||
@@ -129,23 +141,24 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 与密码错误分支对齐耗时,防用户枚举
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
||||
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||
}
|
||||
return "", time.Time{}, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
||||
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||
}
|
||||
if user.TotpSecretEnc != "" {
|
||||
if totpCode == "" {
|
||||
return "", time.Time{}, ErrTotpRequired
|
||||
}
|
||||
if !s.verifyTotp(&user, totpCode) {
|
||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
||||
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||
}
|
||||
}
|
||||
s.guard.success(key)
|
||||
return s.signToken(user.Username, user.TokenVersion)
|
||||
meta.Method = "password"
|
||||
return s.signSessionToken(ctx, &user, meta)
|
||||
}
|
||||
|
||||
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
|
||||
@@ -176,7 +189,13 @@ func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
|
||||
// signToken 签发 JWT,返回令牌、过期时间与 jti(会话落地用)。
|
||||
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, string, error) {
|
||||
return s.signTokenWithJTI(username, ver, newTokenID())
|
||||
}
|
||||
|
||||
// signTokenWithJTI 为敏感换发沿用会话 jti,使旧/新 JWT 始终指向同一会话。
|
||||
func (s *AuthService) signTokenWithJTI(username string, ver uint, jti string) (string, time.Time, string, error) {
|
||||
now := time.Now()
|
||||
expires := now.Add(tokenTTL)
|
||||
claims := authClaims{
|
||||
@@ -184,31 +203,37 @@ func (s *AuthService) signToken(username string, ver uint) (string, time.Time, e
|
||||
Subject: username,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expires),
|
||||
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
|
||||
ID: newTokenID(),
|
||||
ID: jti,
|
||||
},
|
||||
Ver: ver,
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
|
||||
return "", time.Time{}, "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
return token, expires, nil
|
||||
return token, expires, jti, nil
|
||||
}
|
||||
|
||||
// IssueToken 按账号当前令牌版本签发新 JWT;敏感操作递增版本后用它为
|
||||
// 操作者重签,避免操作者自身会话中断。
|
||||
// IssueToken 按账号当前令牌版本签发新 JWT(不落会话行);
|
||||
// 需要会话接续的换发场景用 RenewToken。
|
||||
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)
|
||||
token, expires, _, err := s.signToken(user.Username, user.TokenVersion)
|
||||
return token, expires, err
|
||||
}
|
||||
|
||||
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
|
||||
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
|
||||
err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", username).
|
||||
return bumpTokenVersionTx(s.db.WithContext(ctx), username)
|
||||
}
|
||||
|
||||
// bumpTokenVersionTx 事务内递增令牌版本:与认证因子写入同事务提交,
|
||||
// 避免「因子已生效而旧令牌仍有效」的半程状态。
|
||||
func bumpTokenVersionTx(tx *gorm.DB, username string) error {
|
||||
err := tx.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)
|
||||
@@ -216,12 +241,28 @@ func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) 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)
|
||||
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌;
|
||||
// oldToken 非空时其会话行接续到新令牌(当前设备在列表中保持连续)。
|
||||
// 行锁下复核 proof:请求挂起期间令牌已失效则拒绝,并发敏感操作后到者拒。
|
||||
func (s *AuthService) RevokeSessions(ctx context.Context, username, oldToken string, meta SessionMeta, proof TokenProof) (string, time.Time, error) {
|
||||
var token string
|
||||
var expires time.Time
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
user, err := lockUserForAuthChange(tx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpTokenVersionTx(tx, username); err != nil {
|
||||
return err
|
||||
}
|
||||
user.TokenVersion++
|
||||
token, expires, err = s.renewSessionTx(tx, user, oldToken, meta)
|
||||
return err
|
||||
})
|
||||
return token, expires, err
|
||||
}
|
||||
|
||||
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
|
||||
@@ -231,9 +272,28 @@ func newTokenID() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// TokenProof 是鉴权时观察到的令牌快照(版本 + jti):敏感事务在用户行锁下
|
||||
// 复核它仍是账号当前状态,防「请求挂起期间用户撤销 / 注销,恢复后仍完成
|
||||
// 敏感变更并换出新令牌」的在途绕过。
|
||||
type TokenProof struct {
|
||||
Ver uint
|
||||
Jti string
|
||||
}
|
||||
|
||||
// ErrTokenStale 表示请求令牌在处理期间已失效(版本已递增或会话已撤销);
|
||||
// api 层映射 401,前端按「发送时令牌 == 当前令牌」决定是否登出。
|
||||
var ErrTokenStale = errors.New("会话已失效,请重新登录")
|
||||
|
||||
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
||||
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
||||
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
|
||||
username, _, err := s.ParseTokenProof(ctx, tokenString)
|
||||
return username, err
|
||||
}
|
||||
|
||||
// ParseTokenProof 验证令牌并返回用户名与版本 / jti 快照(RequireAuth 与
|
||||
// 绑定发起点用;快照随后交由敏感事务复核)。
|
||||
func (s *AuthService) ParseTokenProof(ctx context.Context, tokenString string) (string, TokenProof, error) {
|
||||
claims := &authClaims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
@@ -242,23 +302,27 @@ func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (strin
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse token: %w", err)
|
||||
return "", TokenProof{}, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
|
||||
return "", errors.New("token revoked")
|
||||
return "", TokenProof{}, errors.New("token revoked")
|
||||
}
|
||||
user, err := s.findUser(ctx, claims.Subject)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token subject: %w", err)
|
||||
return "", TokenProof{}, fmt.Errorf("token subject: %w", err)
|
||||
}
|
||||
if claims.Ver != user.TokenVersion {
|
||||
return "", errors.New("token version outdated")
|
||||
return "", TokenProof{}, errors.New("token version outdated")
|
||||
}
|
||||
return claims.Subject, nil
|
||||
if err := s.checkSession(ctx, claims.ID); err != nil {
|
||||
return "", TokenProof{}, err
|
||||
}
|
||||
return claims.Subject, TokenProof{Ver: claims.Ver, Jti: claims.ID}, nil
|
||||
}
|
||||
|
||||
// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
|
||||
func (s *AuthService) Logout(tokenString string) {
|
||||
// Logout 把令牌拉进黑名单直至其自然过期,并标记对应会话行已撤销;
|
||||
// 无效/已过期令牌直接视为成功(幂等)。
|
||||
func (s *AuthService) Logout(ctx context.Context, tokenString string) {
|
||||
claims := &jwt.RegisteredClaims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||
return s.jwtSecret, nil
|
||||
@@ -271,6 +335,7 @@ func (s *AuthService) Logout(tokenString string) {
|
||||
return
|
||||
}
|
||||
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
|
||||
s.revokeSessionByJTI(ctx, claims.Subject, claims.ID, ttl)
|
||||
}
|
||||
|
||||
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
||||
|
||||
Reference in New Issue
Block a user