feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证: - 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖) - 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速 - 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志) - cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件 用户管理(admin): - CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd - system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单) - Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板 - 关键操作接入 append-only 审计 其他: - CLI user otp 改 DB store,与邮件通道真正对齐 - 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1 - 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
This commit is contained in:
+159
-17
@@ -1,15 +1,22 @@
|
||||
// Package auth 提供认证相关能力:OTP(双通道)、会话、bcrypt、图形验证码。
|
||||
//
|
||||
// OTP 双通道对齐:邮件发送与 CLI 获取共用同一 OTPStore(同一验证码、同一
|
||||
// 10 分钟有效期、同一 60s 冷却与失败限速),邮件失败不阻断 CLI 通道。
|
||||
// 单实例用内存存储;多实例需改为 DB/Redis(PLAN §6 注明)。
|
||||
// 有效期、同一冷却与失败限速),邮件失败不阻断 CLI 通道。邮件通道经
|
||||
// Send 生成验证码,CLI 通道优先经 Current 复用同一验证码,无有效码时才
|
||||
// 触发 Send(仍受同一冷却约束)。验证码落 DB(otp_codes 表),单实例部署
|
||||
// 即可保证跨进程(HTTP 服务与 CLI 子命令)共享;多实例需改 DB 行锁/Redis
|
||||
// (PLAN §6)。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
@@ -21,20 +28,141 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
otpCodeLen = 6
|
||||
maxFailures = 5 // 单账号连续失败限速阈值
|
||||
failureWin = 10 * time.Minute // 失败计数窗口
|
||||
otpCodeLen = 6
|
||||
)
|
||||
|
||||
// OTPStore 为 OTP 验证码存储。内存实现为单实例默认实现。
|
||||
// OTP 失败限速默认参数(单账号连续失败阈值与计数窗口)。
|
||||
const (
|
||||
DefaultMaxFailures = 5
|
||||
DefaultFailureWin = 10 * time.Minute
|
||||
)
|
||||
|
||||
// OTPStore 为 OTP 验证码存储。DB 实现(DBOTPStore)为生产默认,
|
||||
// MemoryOTPStore 供测试与单进程内嵌场景使用。
|
||||
type OTPStore interface {
|
||||
// Send 为 username 生成新验证码(覆盖旧码)。冷却期内调用返回 ErrCooldown。
|
||||
// 邮件与 CLI 双通道都走该方法,保证对齐。
|
||||
Send(username string, ttl, cooldown time.Duration) (string, error)
|
||||
// Send 为 username 生成新验证码并覆盖旧码(邮件通道)。冷却期内返回 ErrCooldown。
|
||||
Send(ctx context.Context, username string, ttl, cooldown time.Duration) (string, error)
|
||||
// Current 返回当前有效(未过期、未消费)验证码,供 CLI 通道复用同一验证码。
|
||||
// 无有效验证码返回 ErrInvalidCode。
|
||||
Current(ctx context.Context, username string) (string, error)
|
||||
// Verify 校验验证码并一次性消费。失败累计计数(达到阈值返回 ErrTooManyFails)。
|
||||
Verify(username, code string) (bool, error)
|
||||
Verify(ctx context.Context, username, code string) (bool, error)
|
||||
// Failures 返回 username 当前失败计数。
|
||||
Failures(username string) (int, error)
|
||||
Failures(ctx context.Context, username string) (int, error)
|
||||
}
|
||||
|
||||
// DBOTPStore 基于 model.OTPCode 的存储实现,每用户一行(username 唯一)。
|
||||
type DBOTPStore struct {
|
||||
db *gorm.DB
|
||||
maxFailures int
|
||||
failureWin time.Duration
|
||||
}
|
||||
|
||||
// NewDBOTPStore 创建 DB OTP 存储。
|
||||
func NewDBOTPStore(db *gorm.DB, maxFailures int, failureWin time.Duration) *DBOTPStore {
|
||||
return &DBOTPStore{db: db, maxFailures: maxFailures, failureWin: failureWin}
|
||||
}
|
||||
|
||||
func (s *DBOTPStore) Send(ctx context.Context, username string, ttl, cooldown time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
var row model.OTPCode
|
||||
err := s.db.WithContext(ctx).Where("username = ?", username).First(&row).Error
|
||||
// 注意:First 的 err 不能复用给后续语句,避免被覆盖导致走错分支
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
if exists && now.Before(row.CooldownUntil) {
|
||||
return "", ErrCooldown
|
||||
}
|
||||
code, randErr := pkg.RandomDigits(otpCodeLen)
|
||||
if randErr != nil {
|
||||
return "", randErr
|
||||
}
|
||||
updates := map[string]any{
|
||||
"code": code,
|
||||
"expires_at": now.Add(ttl),
|
||||
"cooldown_until": now.Add(cooldown),
|
||||
"failures": 0,
|
||||
"failed_at": nil,
|
||||
"consumed_at": nil,
|
||||
}
|
||||
if exists {
|
||||
err = s.db.WithContext(ctx).Model(&row).Updates(updates).Error
|
||||
} else {
|
||||
err = s.db.WithContext(ctx).Create(&model.OTPCode{
|
||||
Username: username,
|
||||
Code: code,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
CooldownUntil: now.Add(cooldown),
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *DBOTPStore) Current(ctx context.Context, username string) (string, error) {
|
||||
var row model.OTPCode
|
||||
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if row.ConsumedAt != nil || time.Now().After(row.ExpiresAt) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
return row.Code, nil
|
||||
}
|
||||
|
||||
func (s *DBOTPStore) Verify(ctx context.Context, username, code string) (bool, error) {
|
||||
now := time.Now()
|
||||
var row model.OTPCode
|
||||
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if row.ConsumedAt != nil || now.After(row.ExpiresAt) {
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
// 失败计数窗口:超出窗口则重置计数
|
||||
if row.FailedAt != nil && now.Sub(*row.FailedAt) > s.failureWin {
|
||||
row.Failures = 0
|
||||
row.FailedAt = nil
|
||||
}
|
||||
if row.Failures >= s.maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
if row.Code != code {
|
||||
row.Failures++
|
||||
f := now
|
||||
row.FailedAt = &f
|
||||
_ = s.db.WithContext(ctx).Model(&row).Updates(map[string]any{"failures": row.Failures, "failed_at": row.FailedAt}).Error
|
||||
if row.Failures >= s.maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
consumed := now
|
||||
if err := s.db.WithContext(ctx).Model(&row).Update("consumed_at", &consumed).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *DBOTPStore) Failures(ctx context.Context, username string) (int, error) {
|
||||
var row model.OTPCode
|
||||
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return row.Failures, nil
|
||||
}
|
||||
|
||||
type otpEntry struct {
|
||||
@@ -44,7 +172,7 @@ type otpEntry struct {
|
||||
failures int
|
||||
}
|
||||
|
||||
// MemoryOTPStore 为单实例内存实现。
|
||||
// MemoryOTPStore 为单进程内存实现(测试/内嵌场景)。
|
||||
type MemoryOTPStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*otpEntry
|
||||
@@ -55,9 +183,10 @@ func NewMemoryOTPStore() *MemoryOTPStore {
|
||||
return &MemoryOTPStore{entries: make(map[string]*otpEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Send(username string, ttl, cooldown time.Duration) (string, error) {
|
||||
func (s *MemoryOTPStore) Send(ctx context.Context, username string, ttl, cooldown time.Duration) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_ = ctx
|
||||
|
||||
now := time.Now()
|
||||
if e, ok := s.entries[username]; ok && now.Before(e.cooldownAt) {
|
||||
@@ -76,9 +205,21 @@ func (s *MemoryOTPStore) Send(username string, ttl, cooldown time.Duration) (str
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
|
||||
func (s *MemoryOTPStore) Current(ctx context.Context, username string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_ = ctx
|
||||
e, ok := s.entries[username]
|
||||
if !ok || time.Now().After(e.expiresAt) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
return e.code, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Verify(ctx context.Context, username, code string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_ = ctx
|
||||
|
||||
e, ok := s.entries[username]
|
||||
if !ok {
|
||||
@@ -89,12 +230,12 @@ func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
|
||||
delete(s.entries, username)
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
if e.failures >= maxFailures {
|
||||
if e.failures >= DefaultMaxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
if e.code != code {
|
||||
e.failures++
|
||||
if e.failures >= maxFailures {
|
||||
if e.failures >= DefaultMaxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
return false, ErrInvalidCode
|
||||
@@ -103,9 +244,10 @@ func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Failures(username string) (int, error) {
|
||||
func (s *MemoryOTPStore) Failures(ctx context.Context, username string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_ = ctx
|
||||
if e, ok := s.entries[username]; ok {
|
||||
return e.failures, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user