Files
usernode/internal/auth/otp.go
T
cao.wangrenbo ae45aba607 feat(M0): 工程骨架 — Go 后端 + Vue3 前端脚手架
- Go 工程:cmd/usernode CLI(serve/migrate/admin create/reset-password/user otp)
  + internal/{config,model,service,system,auth,cron,api,router,server,pkg,webui}
- 配置:TOML + 环境变量覆盖(USERNODE_<SEC>_<FIELD>),config.example.toml
- 数据:GORM 双驱动(SQLite/MySQL)8 表模型,migrate 子命令可跑通
- 系统层:system.Manager 接口(useradd/userdel/passwd 白名单,dev dry-run)
- HTTP:Gin 路由骨架(/api/v1 + 501 占位),healthz,slog 结构化日志,优雅启停
- 前端:Vite + Vue3 + TS + Element Plus 最小可运行(web/),go:embed 打通
- 构建:deploy/Containerfile 多阶段单二进制镜像,web/Containerfile.dev 前端 dev
  镜像,Makefile(build/test/dev/web-build/image);go.mod 锁定 go 1.22
- 验证:go build/vet/test 通过;podman 镜像构建运行 healthz+embed 通过
2026-08-29 22:43:27 +08:00

114 lines
2.9 KiB
Go

// Package auth 提供认证相关能力:OTP(双通道)、会话、bcrypt、图形验证码。
//
// OTP 双通道对齐:邮件发送与 CLI 获取共用同一 OTPStore(同一验证码、同一
// 10 分钟有效期、同一 60s 冷却与失败限速),邮件失败不阻断 CLI 通道。
// 单实例用内存存储;多实例需改为 DB/Redis(PLAN §6 注明)。
package auth
import (
"errors"
"sync"
"time"
"ws_usernode/internal/pkg"
)
// OTP 错误。
var (
ErrCooldown = errors.New("auth: otp 发送冷却中")
ErrInvalidCode = errors.New("auth: 验证码错误")
ErrTooManyFails = errors.New("auth: 失败次数过多,请稍后再试")
)
const (
otpCodeLen = 6
maxFailures = 5 // 单账号连续失败限速阈值
failureWin = 10 * time.Minute // 失败计数窗口
)
// OTPStore 为 OTP 验证码存储。内存实现为单实例默认实现。
type OTPStore interface {
// Send 为 username 生成新验证码(覆盖旧码)。冷却期内调用返回 ErrCooldown。
// 邮件与 CLI 双通道都走该方法,保证对齐。
Send(username string, ttl, cooldown time.Duration) (string, error)
// Verify 校验验证码并一次性消费。失败累计计数(达到阈值返回 ErrTooManyFails)。
Verify(username, code string) (bool, error)
// Failures 返回 username 当前失败计数。
Failures(username string) (int, error)
}
type otpEntry struct {
code string
expiresAt time.Time
cooldownAt time.Time
failures int
}
// MemoryOTPStore 为单实例内存实现。
type MemoryOTPStore struct {
mu sync.Mutex
entries map[string]*otpEntry
}
// NewMemoryOTPStore 创建内存 OTP 存储。
func NewMemoryOTPStore() *MemoryOTPStore {
return &MemoryOTPStore{entries: make(map[string]*otpEntry)}
}
func (s *MemoryOTPStore) Send(username string, ttl, cooldown time.Duration) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if e, ok := s.entries[username]; ok && now.Before(e.cooldownAt) {
return "", ErrCooldown
}
code, err := pkg.RandomDigits(otpCodeLen)
if err != nil {
return "", err
}
s.entries[username] = &otpEntry{
code: code,
expiresAt: now.Add(ttl),
cooldownAt: now.Add(cooldown),
failures: 0,
}
return code, nil
}
func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[username]
if !ok {
return false, ErrInvalidCode
}
now := time.Now()
if now.After(e.expiresAt) {
delete(s.entries, username)
return false, ErrInvalidCode
}
if e.failures >= maxFailures {
return false, ErrTooManyFails
}
if e.code != code {
e.failures++
if e.failures >= maxFailures {
return false, ErrTooManyFails
}
return false, ErrInvalidCode
}
delete(s.entries, username) // 一次性
return true, nil
}
func (s *MemoryOTPStore) Failures(username string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.entries[username]; ok {
return e.failures, nil
}
return 0, nil
}