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
+176 -13
View File
@@ -1,17 +1,24 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"strconv"
"time"
"github.com/gin-gonic/gin"
"ws_usernode/internal/config"
"ws_usernode/internal/model"
"ws_usernode/internal/service"
)
// UserHandler 外部用户接口(列表/详情/创建等,M1 填充 CRUD 与系统操作)。
// UserHandler 外部用户接口(列表/详情/创建/更新/禁用/启用/延期/删除,admin)。
type UserHandler struct {
svc *service.UserService
cfg *config.Config
h *Handler // 访问审计 helper
}
// UserCreateRequest 管理员创建外部用户请求。
@@ -25,31 +32,187 @@ type UserCreateRequest struct {
// Create 管理员创建外部用户(自动建系统账号)。
func (h *UserHandler) Create(c *gin.Context) {
if h.svc == nil {
fail(c, http.StatusNotImplemented, "用户服务未初始化")
return
}
var req UserCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
u, err := h.svc.Create(c.Request.Context(), req.Username, req.Email, req.Supervisor, req.Purpose, req.TTLDays*86400)
createdBy := uint(0)
if sess := sessionFrom(c); sess != nil {
createdBy = sess.RefID
}
u, err := h.svc.Create(c.Request.Context(), req.Username, req.Email, req.Supervisor, req.Purpose,
time.Duration(req.TTLDays)*24*time.Hour, createdBy)
if err != nil {
h.h.audit(c, "user.create", "user", "", map[string]any{"username": req.Username, "err": err.Error()}, model.ResultFailed)
switch {
case strings.Contains(err.Error(), "用户名"):
case errors.Is(err, service.ErrUserExists):
fail(c, http.StatusConflict, err.Error())
default:
fail(c, http.StatusBadRequest, err.Error())
case strings.Contains(err.Error(), "已存在"):
}
return
}
h.h.audit(c, "user.create", "user", strconv.FormatUint(uint64(u.ID), 10), map[string]any{"username": u.Username}, model.ResultSuccess)
ok(c, gin.H{"id": u.ID, "username": u.Username, "status": u.Status, "expire_at": u.ExpireAt})
}
// List 用户列表(分页/筛选:status、supervisor)。
func (h *UserHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
users, total, err := h.svc.List(c.Request.Context(), service.UserFilter{
Status: c.Query("status"),
Supervisor: c.Query("supervisor"),
Page: page,
PageSize: pageSize,
})
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, gin.H{"total": total, "items": users})
}
// Get 用户详情。
func (h *UserHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的用户 ID")
return
}
u, err := h.svc.GetByID(c.Request.Context(), uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
ok(c, u)
}
// UserUpdateRequest 更新外部用户信息(仅更新提供的字段;邮箱仅管理员可改)。
type UserUpdateRequest struct {
Email *string `json:"email"`
Supervisor *string `json:"supervisor"`
Purpose *string `json:"purpose"`
}
// Update PATCH /users/:id。
func (h *UserHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的用户 ID")
return
}
var req UserUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
u, err := h.svc.Update(c.Request.Context(), uint(id), req.Email, req.Supervisor, req.Purpose)
if err != nil {
h.h.audit(c, "user.update", "user", c.Param("id"), map[string]any{"err": err.Error()}, model.ResultFailed)
if errors.Is(err, service.ErrUserNotFound) {
fail(c, http.StatusNotFound, err.Error())
return
}
fail(c, http.StatusBadRequest, err.Error())
return
}
h.h.audit(c, "user.update", "user", c.Param("id"), map[string]any{"email": req.Email, "supervisor": req.Supervisor, "purpose": req.Purpose}, model.ResultSuccess)
ok(c, u)
}
// Disable POST /users/:id/disable —— 禁用(清空 authorized_keysSSH 立即失效)。
func (h *UserHandler) Disable(c *gin.Context) {
h.setStatus(c, "user.disable", model.UserStatusDisabled, h.svc.Disable)
}
// Enable POST /users/:id/enable —— 启用(按 DB 密钥状态恢复)。
func (h *UserHandler) Enable(c *gin.Context) {
h.setStatus(c, "user.enable", model.UserStatusActive, h.svc.Enable)
}
// setStatus 复用禁用/启用的公共流程(解析 ID、调 service、审计)。
func (h *UserHandler) setStatus(c *gin.Context, action, wantStatus string, fn func(ctx context.Context, id uint) error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的用户 ID")
return
}
if err := fn(c, uint(id)); err != nil {
h.h.audit(c, action, "user", c.Param("id"), map[string]any{"err": err.Error()}, model.ResultFailed)
switch {
case errors.Is(err, service.ErrUserNotFound):
fail(c, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrUserExpired):
fail(c, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrSystemAccountMissing):
fail(c, http.StatusConflict, err.Error())
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
ok(c, gin.H{"id": u.ID, "username": u.Username, "status": u.Status})
h.h.audit(c, action, "user", c.Param("id"), map[string]any{"status": wantStatus}, model.ResultSuccess)
ok(c, gin.H{"status": wantStatus})
}
// List 用户列表(M1 实现分页筛选)
func (h *UserHandler) List(c *gin.Context) {
fail(c, http.StatusNotImplemented, "用户列表将在 M1 实现")
// ExtendRequest 延期请求
type ExtendRequest struct {
Days int `json:"days"` // 0 表示用配置默认(90 天)
}
// Extend POST /users/:id/extend —— 延长有效期;已过期用户在回收期内可恢复。
func (h *UserHandler) Extend(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的用户 ID")
return
}
var req ExtendRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
u, err := h.svc.GetByID(c.Request.Context(), uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
if err := h.svc.Extend(c.Request.Context(), uint(id), req.Days); err != nil {
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "err": err.Error()}, model.ResultFailed)
fail(c, http.StatusInternalServerError, err.Error())
return
}
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "old_status": u.Status}, model.ResultSuccess)
ok(c, gin.H{"expire_at": time.Now().Add(h.extendTTL(req.Days)).UTC()})
}
// extendTTL 计算新的有效期(与 service 保持一致:days<=0 用默认)。
func (h *UserHandler) extendTTL(days int) time.Duration {
if days <= 0 {
return h.cfg.Policy.DefaultTTL
}
return time.Duration(days) * 24 * time.Hour
}
// Delete DELETE /users/:id —— 删除并回收(系统账号 + 家目录 + 密钥,保留审计)。
func (h *UserHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的用户 ID")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil {
h.h.audit(c, "user.delete", "user", c.Param("id"), map[string]any{"err": err.Error()}, model.ResultFailed)
switch {
case errors.Is(err, service.ErrUserNotFound):
fail(c, http.StatusNotFound, err.Error())
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
h.h.audit(c, "user.delete", "user", c.Param("id"), nil, model.ResultSuccess)
ok(c, gin.H{"status": "deleted"})
}