281 lines
9.8 KiB
Go
281 lines
9.8 KiB
Go
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 签发与验证。
|
|
type AuthService struct {
|
|
db *gorm.DB
|
|
jwtSecret []byte
|
|
guard *loginGuard
|
|
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
|
revoked *cache.Cache
|
|
|
|
notifier *Notifier
|
|
settings *SettingService
|
|
cipher *crypto.Cipher // TOTP 密钥加密落库用,SetCipher 注入
|
|
|
|
totpMu sync.Mutex
|
|
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
|
}
|
|
|
|
// NewAuthService 组装依赖。
|
|
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
|
return &AuthService{
|
|
db: db, jwtSecret: []byte(jwtSecret),
|
|
guard: newLoginGuard(),
|
|
revoked: cache.New(4096),
|
|
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, clientIP, totpCode string) (string, time.Time, error) {
|
|
key := guardKey(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, 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)
|
|
}
|
|
if user.TotpSecretEnc != "" {
|
|
if totpCode == "" {
|
|
return "", time.Time{}, ErrTotpRequired
|
|
}
|
|
if !s.verifyTotp(&user, totpCode) {
|
|
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
|
}
|
|
}
|
|
s.guard.success(key)
|
|
return s.signToken(user.Username, user.TokenVersion)
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
|
|
now := time.Now()
|
|
expires := now.Add(tokenTTL)
|
|
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 {
|
|
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
|
|
}
|
|
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)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// 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"])
|
|
}
|
|
return s.jwtSecret, nil
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse token: %w", err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
|
|
func (s *AuthService) Logout(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)
|
|
}
|
|
|
|
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
|
func tokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return "revoked|" + hex.EncodeToString(sum[:])
|
|
}
|