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:
2026-08-29 23:40:20 +08:00
parent ae45aba607
commit 630d240dc0
32 changed files with 2923 additions and 188 deletions
+14 -3
View File
@@ -10,8 +10,10 @@ import (
"gorm.io/gorm"
"ws_usernode/internal/api"
"ws_usernode/internal/auth"
"ws_usernode/internal/config"
"ws_usernode/internal/cron"
"ws_usernode/internal/mail"
"ws_usernode/internal/model"
"ws_usernode/internal/router"
"ws_usernode/internal/server"
@@ -82,9 +84,18 @@ func cmdServe(args []string) error {
sys := system.New(cfg.System)
adminSvc := service.NewAdminService(db)
userSvc := service.NewUserService(db, sys)
userSvc := service.NewUserService(db, sys, cfg)
auditSvc := service.NewAuditService(db)
// 认证依赖:DB OTP/会话/重置令牌存储 + 内存图形验证码/登录限速器 + 邮件
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
otps := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin)
sessions := auth.NewDBSessionStore(db)
resets := auth.NewDBResetTokenStore(db)
limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration)
mailer := mail.New(cfg.SMTP, log)
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
// 启动前自动迁移(骨架阶段保证表结构就绪;M5 部署建议显式 migrate)
if err := model.Migrate(db); err != nil {
return fmt.Errorf("数据库迁移: %w", err)
@@ -97,8 +108,8 @@ func cmdServe(args []string) error {
sched.Start()
defer sched.Stop()
h := api.New(adminSvc, userSvc, auditSvc)
r := router.New(cfg, h, log)
h := api.New(cfg, authSvc, userSvc, auditSvc)
r := router.New(cfg, h, sessions, log)
srv := server.New(cfg.Server.Listen, r, log)
if err := srv.Run(); err != nil {
+18 -17
View File
@@ -25,8 +25,8 @@ func cmdUser(args []string) error {
}
}
// userOTP 获取外部用户 OTP 验证码与邮件通道共用同一存储与限速
// (同一验证码、同一 10 分钟有效期、同一 60s 冷却与失败限速)。
// userOTP 获取外部用户 OTP 验证码与邮件通道共用同一 DB 存储与限速
// 已有有效验证码时直接复用(同一验证码),无则生成(受同一 60s 冷却约束)。
func userOTP(args []string) error {
fs := flag.NewFlagSet("user otp", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
@@ -48,27 +48,28 @@ func userOTP(args []string) error {
}
// 校验用户存在(不存在时返回友好错误,避免暴露账号是否存在的枚举)
if _, err := service.NewUserService(db, system.New(cfg.System)).GetByUsername(context.Background(), name); err != nil {
if _, err := service.NewUserService(db, system.New(cfg.System), cfg).GetByUsername(context.Background(), name); err != nil {
return fmt.Errorf("用户不存在或不可用: %w", err)
}
// M0 骨架:CLI 独立生成(内存 store 与运行中服务不共享)。
// 生产对齐(同一验证码/冷却/限速跨通道生效)需 OTP 落 DB,M1 实现
// auth.OTPStore 的 DB 实现后,CLI 与邮件通道读写同一存储。
store := auth.NewMemoryOTPStore()
code, err := store.Send(name, cfg.Policy.OTPTTL, cfg.Policy.OTPCooldown)
store := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin)
ctx := context.Background()
code, err := store.Current(ctx, name)
if err != nil {
if err == auth.ErrCooldown {
return fmt.Errorf("发送冷却中,请稍后重试(冷却 %s)", cfg.Policy.OTPCooldown)
// 无有效验证码:生成(先到先得,覆盖旧码;冷却期内返回 ErrCooldown
code, err = store.Send(ctx, name, cfg.Policy.OTPTTL, cfg.Policy.OTPCooldown)
if err != nil {
if err == auth.ErrCooldown {
return fmt.Errorf("发送冷却中,请稍后重试(冷却 %s)", cfg.Policy.OTPCooldown)
}
return err
}
return err
log.Info("otp generated", "username", name, "valid_for", cfg.Policy.OTPTTL.String())
} else {
log.Info("otp reused (与邮件通道同一验证码)", "username", name)
}
log.Info("otp generated",
"username", name,
"valid_for", cfg.Policy.OTPTTL.String(),
"expires_at", time.Now().Add(cfg.Policy.OTPTTL).Format(time.RFC3339),
"hint", "与邮件通道为同一验证码,登录后立即失效",
)
fmt.Printf("OTP for %s: %s\n", name, code)
fmt.Printf("有效期至 %s,登录后立即失效;如需重发请等待冷却 %s 或稍后在网页重新请求。\n",
time.Now().Add(cfg.Policy.OTPTTL).Format(time.RFC3339), cfg.Policy.OTPCooldown)
return nil
}