package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "sync" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "oci-portal/internal/cache" "oci-portal/internal/crypto" "oci-portal/internal/model" ) // ErrInvalidCredentials 表示用户名或密码错误;不区分两者以免泄露账号是否存在。 var ErrInvalidCredentials = errors.New("invalid username or password") // ErrLoginLocked 表示该 IP+用户名组合因连续失败被锁定;不提示剩余次数与时长细节。 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" // 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 cipher *crypto.Cipher // TOTP 密钥加密落库用,SetCipher 注入 totpMu sync.Mutex totpPending map[string]pendingTotp // username → setup 暂存密钥 // cleanupWG 追踪会话清理 goroutine;关停时 Wait 保证清理查询已结束 cleanupWG sync.WaitGroup } // NewAuthService 组装依赖。 func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { return &AuthService{ db: db, jwtSecret: []byte(jwtSecret), guard: newLoginGuard(), revoked: cache.New(4096), seen: cache.New(4096), revokedJti: newJtiTombstones(), totpPending: map[string]pendingTotp{}, } } // SetNotifier 注入锁定告警依赖;不注入(nil)则只锁定不推送。 func (s *AuthService) SetNotifier(n *Notifier, settings *SettingService) { s.notifier = n s.settings = settings } // SetCipher 注入加密组件(TOTP 密钥落库),main 启动时调用。 func (s *AuthService) SetCipher(c *crypto.Cipher) { s.cipher = c } // EnsureAdmin 保证管理员账号可用:用户不存在时以给定密码创建; // 已存在则不重置密码。库中无任何用户且未提供密码时报错,避免服务无法登录。 func (s *AuthService) EnsureAdmin(username, password string) error { if password == "" { var count int64 if err := s.db.Model(&model.User{}).Count(&count).Error; err != nil { return fmt.Errorf("count users: %w", err) } if count == 0 { return fmt.Errorf("ensure admin: no user exists, set ADMIN_PASSWORD to create one") } return nil } err := s.db.Where("username = ?", username).First(&model.User{}).Error if err == nil { return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("find admin user: %w", err) } return s.createUser(username, password) } func (s *AuthService) createUser(username, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } user := &model.User{Username: username, PasswordHash: string(hash)} if err := s.db.Create(user).Error; err != nil { return fmt.Errorf("create user %s: %w", username, err) } return nil } // 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 if s.guard.locked(key, now, lockFor) { return "", time.Time{}, ErrLoginLocked } // 禁用密码登录后直接拒绝(不计失败);读取失败按未禁用处理,防配置故障锁死 if off, err := s.PasswordLoginDisabled(ctx); err == nil && off { return "", time.Time{}, ErrPasswordLoginDisabled } var user model.User if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // 与密码错误分支对齐耗时,防用户枚举 _ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password)) 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, 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, meta.ClientIP, sec) } } s.guard.success(key) meta.Method = "password" return s.signSessionToken(ctx, &user, meta) } // failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。 func (s *AuthService) failLogin(key string, now time.Time, username, clientIP string, sec SecuritySettings) error { if !s.guard.fail(key, now, sec.LoginFailLimit, time.Duration(sec.LoginLockMinutes)*time.Minute) { return ErrInvalidCredentials } s.notifyLock(username, clientIP, sec) return ErrLoginLocked } // notifyLock 异步推送登录锁定告警;notifier 未注入或开关关闭则跳过。 func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings) { if s.notifier == nil { return } if s.settings != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if !s.settings.NotifyEventEnabled(ctx, "login_lock") { return } } s.notifier.SendTemplateAsync("login_lock", map[string]string{ "username": username, "ip": clientIP, "fail_count": fmt.Sprintf("%d", sec.LoginFailLimit), "lock_minutes": fmt.Sprintf("%d", sec.LoginLockMinutes), }) } // 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{ RegisteredClaims: jwt.RegisteredClaims{ Subject: username, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires), 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 token, expires, jti, nil } // 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 } token, expires, _, err := s.signToken(user.Username, user.TokenVersion) return token, expires, err } // bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。 func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error { 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) } return nil } // 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 起不会失败)。 func newTokenID() string { b := make([]byte, 16) _, _ = rand.Read(b) 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 { return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"]) } return s.jwtSecret, nil }) if err != nil { return "", TokenProof{}, fmt.Errorf("parse token: %w", err) } if _, hit := s.revoked.Get(tokenHash(tokenString)); hit { return "", TokenProof{}, errors.New("token revoked") } user, err := s.findUser(ctx, claims.Subject) if err != nil { return "", TokenProof{}, fmt.Errorf("token subject: %w", err) } if claims.Ver != user.TokenVersion { return "", TokenProof{}, errors.New("token version outdated") } 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(ctx context.Context, tokenString string) { claims := &jwt.RegisteredClaims{} _, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { return s.jwtSecret, nil }) if err != nil || claims.ExpiresAt == nil { return } ttl := time.Until(claims.ExpiresAt.Time) if ttl <= 0 { return } s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl) s.revokeSessionByJTI(ctx, claims.Subject, claims.ID, ttl) } // tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。 func tokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return "revoked|" + hex.EncodeToString(sum[:]) }