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:
+71
-22
@@ -4,15 +4,19 @@
|
||||
// 执行 useradd/usermod/userdel/passwd 等固定命令并做参数强校验。未来多节点
|
||||
// agent 模式只需新增远程实现替换本地实现(PLAN §5.1)。
|
||||
//
|
||||
// 权限模型:节点以专有用户(如 usernode)运行,经 sudo -n 提权执行白名单
|
||||
// 命令;开发环境 config system.sudo=false 进入 dry-run(只打印计划不执行),
|
||||
// 避免在开发机上直接操作系统账号。系统命令的真实系统效果在 M1 用测试用户/
|
||||
// 容器验证,不在生产直接跑 useradd。
|
||||
// 执行模式(config system.*):
|
||||
// - dry_run=true:只打印计划命令不执行(开发演练);
|
||||
// - dry_run=false + sudo=false:直接执行(容器/测试用户验证真实建号);
|
||||
// - dry_run=false + sudo=true:经 sudo -n 提权执行白名单命令(生产,
|
||||
// 需配置 deploy/sudoers)。真实系统账号操作只在测试用户/容器中验证,
|
||||
// 不在生产直接跑 useradd。
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -45,8 +49,10 @@ type Manager interface {
|
||||
RemoveUser(ctx context.Context, username string) error
|
||||
// SetLock 锁定/解锁系统账号口令(passwd -l / -u)。
|
||||
SetLock(ctx context.Context, username string, locked bool) error
|
||||
// Exists 检查系统账号是否存在(读 /etc/passwd,无需提权)。
|
||||
Exists(ctx context.Context, username string) (bool, error)
|
||||
// SyncAuthorizedKeys 以 DB 状态全量重写 authorized_keys(原子写 + 并发锁),
|
||||
// 吊销密钥即从文件移除、立即失效。M0 提供 dry-run 实现,M2 完成生产路径。
|
||||
// 吊销密钥即从文件移除、立即失效。
|
||||
SyncAuthorizedKeys(ctx context.Context, username string, keys []Key) error
|
||||
}
|
||||
|
||||
@@ -69,20 +75,32 @@ func New(cfg config.SystemConfig) Manager {
|
||||
return &localManager{cfg: cfg}
|
||||
}
|
||||
|
||||
// run 执行白名单命令:sudo -n <cmd> <args...>,参数在调用处强校验。
|
||||
// dry-run 模式下只返回将执行的命令文本,不真正执行。
|
||||
// run 执行白名单命令。参数在调用处强校验。
|
||||
// 模式:dry-run 只返回命令文本;direct(sudo=false)直接执行;
|
||||
// sudo 经 sudo -n 提权执行(对应 deploy/sudoers 白名单)。
|
||||
func (m *localManager) run(ctx context.Context, cmd string, args ...string) (string, error) {
|
||||
if !allowedCommands[cmd] {
|
||||
return "", errors.New("system: command not allowed: " + cmd)
|
||||
}
|
||||
argv := append([]string{"-n", cmd}, args...)
|
||||
cmdline := strings.Join(append([]string{"sudo", "-n", cmd}, args...), " ")
|
||||
if !m.cfg.Sudo {
|
||||
return cmdline, nil // dry-run
|
||||
cmdline := strings.Join(append([]string{cmd}, args...), " ")
|
||||
if m.cfg.DryRun {
|
||||
return cmdline, nil
|
||||
}
|
||||
var (
|
||||
out []byte
|
||||
err error
|
||||
)
|
||||
if m.cfg.Sudo {
|
||||
cmdline = "sudo -n " + cmdline
|
||||
out, err = exec.CommandContext(ctx, "sudo", append([]string{"-n", cmd}, args...)...).CombinedOutput()
|
||||
} else {
|
||||
out, err = exec.CommandContext(ctx, cmd, args...).CombinedOutput()
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "sudo", argv...).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), err
|
||||
if len(out) > 0 {
|
||||
return cmdline, fmt.Errorf("%s: %s: %w", cmdline, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return cmdline, fmt.Errorf("%s: %w", cmdline, err)
|
||||
}
|
||||
return cmdline, nil
|
||||
}
|
||||
@@ -109,11 +127,11 @@ func (m *localManager) CreateUser(ctx context.Context, acc Account) error {
|
||||
home = filepath.Join(m.cfg.HomeBase, username)
|
||||
}
|
||||
if _, err := m.run(ctx, "useradd", "-m", "-d", home, "-s", shell, "-g", m.cfg.Group, username); err != nil {
|
||||
return errors.New("system: useradd: " + err.Error())
|
||||
return err
|
||||
}
|
||||
// 锁定口令,仅密钥登录
|
||||
if _, err := m.run(ctx, "passwd", "-l", username); err != nil {
|
||||
return errors.New("system: passwd -l: " + err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -124,7 +142,7 @@ func (m *localManager) RemoveUser(ctx context.Context, username string) error {
|
||||
return err
|
||||
}
|
||||
if _, err := m.run(ctx, "userdel", "-r", username); err != nil {
|
||||
return errors.New("system: userdel: " + err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -139,20 +157,51 @@ func (m *localManager) SetLock(ctx context.Context, username string, locked bool
|
||||
flag = "-l"
|
||||
}
|
||||
if _, err := m.run(ctx, "passwd", flag, username); err != nil {
|
||||
return errors.New("system: passwd: " + err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// passwdPath 为系统账号数据库路径(测试可覆盖为临时文件)。
|
||||
var passwdPath = "/etc/passwd"
|
||||
|
||||
// Exists 检查系统账号是否存在。直接读 /etc/passwd(世界可读,无需提权)。
|
||||
func (m *localManager) Exists(ctx context.Context, username string) (bool, error) {
|
||||
username = m.sysName(username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_ = ctx
|
||||
f, err := os.Open(passwdPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(line, ":", 2)
|
||||
if len(fields) > 0 && fields[0] == username {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SyncAuthorizedKeys 全量重写 authorized_keys:
|
||||
//
|
||||
// 1. 以 <lockBase>/<username>.lock 文件锁串行化并发写(flock);
|
||||
// 2. 写临时文件(0600),再 rename 原子替换;
|
||||
// 3. 无有效密钥时写入空文件(SSH 行为一致,避免文件缺失导致的歧义)。
|
||||
//
|
||||
// 生产模式(sudo=true)下节点进程需具备对家目录 .ssh 的写权限——部署时经
|
||||
// sudoers 白名单授予固定命令/受控脚本实现(M2 细化);当前实现直接做文件
|
||||
// 操作并假设权限已配置,dry-run 模式打印计划命令。
|
||||
// dry-run 模式只打印计划;真实写路径(direct/sudo)需要进程具备对家目录
|
||||
// .ssh 的写权限——生产部署经 sudoers 白名单授予受控脚本实现(M2 细化)。
|
||||
func (m *localManager) SyncAuthorizedKeys(ctx context.Context, username string, keys []Key) error {
|
||||
username = m.sysName(username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
@@ -169,13 +218,13 @@ func (m *localManager) SyncAuthorizedKeys(ctx context.Context, username string,
|
||||
}
|
||||
}
|
||||
|
||||
if !m.cfg.Sudo {
|
||||
if m.cfg.DryRun {
|
||||
var plan strings.Builder
|
||||
plan.WriteString("mkdir -p " + sshDir + " (0700)\n")
|
||||
plan.WriteString("flock " + lockPath + "\n")
|
||||
plan.WriteString("write " + filepath.Join(sshDir, "authorized_keys") + " (0600)\n")
|
||||
plan.WriteString(content.String())
|
||||
// 骨架阶段:仅日志输出计划,不落盘
|
||||
// 演练模式:仅日志输出计划,不落盘
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
)
|
||||
|
||||
func testCfg() config.SystemConfig {
|
||||
cfg := config.Default()
|
||||
return cfg.System
|
||||
}
|
||||
|
||||
func TestDryRunDoesNotExecute(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.DryRun = true
|
||||
m := New(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// dry-run 下创建/删除不应报错(只打印计划)
|
||||
if err := m.CreateUser(ctx, Account{Username: "ext_zhangsan"}); err != nil {
|
||||
t.Fatalf("dry-run create: %v", err)
|
||||
}
|
||||
if err := m.RemoveUser(ctx, "ext_zhangsan"); err != nil {
|
||||
t.Fatalf("dry-run remove: %v", err)
|
||||
}
|
||||
if err := m.SetLock(ctx, "ext_zhangsan", true); err != nil {
|
||||
t.Fatalf("dry-run lock: %v", err)
|
||||
}
|
||||
if err := m.SyncAuthorizedKeys(ctx, "ext_zhangsan", nil); err != nil {
|
||||
t.Fatalf("dry-run keys: %v", err)
|
||||
}
|
||||
// 参数校验在 dry-run 下仍然生效
|
||||
if err := m.CreateUser(ctx, Account{Username: "ext_..bad"}); err == nil {
|
||||
t.Fatal("expected validation error for illegal account name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistsParsesPasswd(t *testing.T) {
|
||||
// 用临时 passwd 文件验证解析逻辑
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "passwd")
|
||||
content := "root:x:0:0:root:/root:/bin/sh\n" +
|
||||
"ext_zhangsan:x:1001:1001::/home/ext_zhangsan:/bin/sh\n"
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := passwdPath
|
||||
passwdPath = p
|
||||
t.Cleanup(func() { passwdPath = old })
|
||||
|
||||
m := New(testCfg())
|
||||
ctx := context.Background()
|
||||
// 已带前缀与未带前缀都会命中同一账号
|
||||
for _, name := range []string{"ext_zhangsan", "zhangsan"} {
|
||||
ok, err := m.Exists(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("exists(%q): %v", name, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("user %q should exist", name)
|
||||
}
|
||||
}
|
||||
// 不存在的账号返回 false
|
||||
if ok, err := m.Exists(ctx, "ghost_xyz"); err != nil || ok {
|
||||
t.Fatalf("exists(ghost) = %v/%v, want false/nil", ok, err)
|
||||
}
|
||||
// 非法账号名直接报错
|
||||
if _, err := m.Exists(ctx, "bad..name"); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixNormalization(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
lm := &localManager{cfg: cfg}
|
||||
// sysName 逻辑:已带前缀不重复加
|
||||
if got := lm.sysName("ext_x"); got != "ext_x" {
|
||||
t.Fatalf("sysName(ext_x) = %q", got)
|
||||
}
|
||||
if got := lm.sysName("x"); got != "ext_x" {
|
||||
t.Fatalf("sysName(x) = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user