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:
+176
-13
@@ -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_keys,SSH 立即失效)。
|
||||
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"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user