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
+16
View File
@@ -31,6 +31,12 @@ func TestLoadDefault(t *testing.T) {
if cfg.System.UserPrefix != "ext_" {
t.Errorf("default user_prefix = %q", cfg.System.UserPrefix)
}
if cfg.Auth.MaxLoginFailures != 5 || cfg.Auth.LockDuration != 15*time.Minute {
t.Errorf("default auth = %+v", cfg.Auth)
}
if !cfg.System.DryRun {
t.Error("default dry_run should be true (安全默认)")
}
}
func TestLoadFileOverrides(t *testing.T) {
@@ -62,7 +68,11 @@ func TestEnvOverrides(t *testing.T) {
t.Setenv("USERNODE_DATABASE_DSN", "u:p@tcp(h:3306)/db")
t.Setenv("USERNODE_POLICY_OTPTTL", "5m")
t.Setenv("USERNODE_SYSTEM_SUDO", "true")
t.Setenv("USERNODE_SYSTEM_DRY_RUN", "false")
t.Setenv("USERNODE_SERVER_TRUSTED_PROXIES", "10.0.0.1, 10.0.0.2")
t.Setenv("USERNODE_AUTH_MAX_LOGIN_FAILURES", "3")
t.Setenv("USERNODE_AUTH_LOCK_DURATION", "5m")
t.Setenv("USERNODE_APP_BASE_URL", "https://un.example.com")
cfg, err := LoadDefault()
if err != nil {
@@ -77,6 +87,12 @@ func TestEnvOverrides(t *testing.T) {
if !cfg.System.Sudo {
t.Error("system.sudo should be true")
}
if cfg.Auth.MaxLoginFailures != 3 || cfg.Auth.LockDuration != 5*time.Minute {
t.Errorf("auth = %+v", cfg.Auth)
}
if cfg.App.BaseURL != "https://un.example.com" {
t.Errorf("base_url = %q", cfg.App.BaseURL)
}
if len(cfg.Server.TrustedProxies) != 2 || cfg.Server.TrustedProxies[0] != "10.0.0.1" {
t.Errorf("trusted_proxies = %v", cfg.Server.TrustedProxies)
}