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:
@@ -1,53 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// AdminHandler 管理员账号相关接口(M1 完成登录;create/reset 走 CLI)。
|
||||
type AdminHandler struct {
|
||||
svc *service.AdminService
|
||||
}
|
||||
|
||||
// AdminCreateRequest 管理员创建请求。
|
||||
type AdminCreateRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
// Create 创建管理员(仅初始引导用,M1 前可经此接口快速建号)。
|
||||
func (h *AdminHandler) Create(c *gin.Context) {
|
||||
if h.svc == nil {
|
||||
fail(c, http.StatusNotImplemented, "管理员服务未初始化")
|
||||
return
|
||||
}
|
||||
var req AdminCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
adm, err := h.svc.Create(c.Request.Context(), req.Username, req.Password, req.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "已存在"):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
case strings.Contains(err.Error(), "过弱"):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": adm.ID, "username": adm.Username})
|
||||
}
|
||||
|
||||
// Me 返回当前管理员(M1 接入会话后启用)。
|
||||
func (h *AdminHandler) Me(c *gin.Context) {
|
||||
fail(c, http.StatusNotImplemented, "会话尚未接入(M1)")
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/api"
|
||||
"ws_usernode/internal/auth"
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/router"
|
||||
"ws_usernode/internal/service"
|
||||
"ws_usernode/internal/system"
|
||||
)
|
||||
|
||||
// recordingMailer 捕获邮件,用于从重置邮件提取 token 等。
|
||||
type recordingMailer struct {
|
||||
lastTo string
|
||||
lastSubject string
|
||||
lastBody string
|
||||
}
|
||||
|
||||
func (m *recordingMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
m.lastTo = to
|
||||
m.lastSubject = subject
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
// testApp 完整组装的应用(SQLite 内存库 + dry-run 系统层)。
|
||||
type testApp struct {
|
||||
r http.Handler
|
||||
db *gorm.DB
|
||||
captchas auth.CaptchaStore
|
||||
otps auth.OTPStore
|
||||
mailer *recordingMailer
|
||||
}
|
||||
|
||||
func setupTestApp(t *testing.T) *testApp {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := model.Open("sqlite", ":memory:", false)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := model.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.System.DryRun = true // 集成测试走 dry-run,不触碰真实系统账号
|
||||
|
||||
sys := system.New(cfg.System)
|
||||
adminSvc := service.NewAdminService(db)
|
||||
userSvc := service.NewUserService(db, sys, cfg)
|
||||
auditSvc := service.NewAuditService(db)
|
||||
|
||||
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 := &recordingMailer{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
|
||||
|
||||
h := api.New(cfg, authSvc, userSvc, auditSvc)
|
||||
r := router.New(cfg, h, sessions, log)
|
||||
|
||||
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
return &testApp{r: r, db: db, captchas: captchas, otps: otps, mailer: mailer}
|
||||
}
|
||||
|
||||
// doJSON 发起 JSON 请求,返回 recorder。
|
||||
func (a *testApp) doJSON(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("decode response %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == api.SessionCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("no session cookie in response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAPICaptcha(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/auth/captcha", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("captcha status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)
|
||||
data := m["data"].(map[string]any)
|
||||
if data["captcha_id"] == "" || data["image"] == "" {
|
||||
t.Fatalf("captcha response missing fields: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAdminLoginRequiresSession(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
// 未登录访问 /users 应 401
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/users", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth users status = %d, want 401", w.Code)
|
||||
}
|
||||
// 错误密码 401
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "bad"})
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad login status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAdminUserLifecycle(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 管理员登录
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin login status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
ck := sessionCookie(t, w)
|
||||
|
||||
// me
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("me status = %d", w.Code)
|
||||
}
|
||||
if m := decodeBody(t, w); m["data"].(map[string]any)["username"] != "root" {
|
||||
t.Fatalf("me body = %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 创建用户(dry-run 系统层)
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
||||
"username": "zhangsan", "email": "zs@example.com", "supervisor": "prof.li", "purpose": "科研", "ttl_days": 90,
|
||||
}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
created := decodeBody(t, w)["data"].(map[string]any)
|
||||
id := uint(created["id"].(float64))
|
||||
if created["username"] != "ext_zhangsan" {
|
||||
t.Fatalf("created username = %v", created["username"])
|
||||
}
|
||||
|
||||
// 列表
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/users?status=active&page=1&page_size=10", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d", w.Code)
|
||||
}
|
||||
list := decodeBody(t, w)["data"].(map[string]any)
|
||||
if list["total"].(float64) != 1 {
|
||||
t.Fatalf("list total = %v, want 1", list["total"])
|
||||
}
|
||||
|
||||
// 详情
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(id), nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 更新(改邮箱)
|
||||
email := "zs-new@example.com"
|
||||
w = app.doJSON(http.MethodPatch, "/api/v1/users/"+itoa(id), map[string]any{"email": email}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 禁用 → 启用 → 延期
|
||||
for _, action := range []string{"disable", "enable", "extend"} {
|
||||
body := any(nil)
|
||||
if action == "extend" {
|
||||
body = map[string]any{"days": 30}
|
||||
}
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/users/"+itoa(id)+"/"+action, body, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body=%s", action, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
w = app.doJSON(http.MethodDelete, "/api/v1/users/"+itoa(id), nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 审计应已写入
|
||||
var n int64
|
||||
if err := app.db.Model(&model.AuditLog{}).Count(&n).Error; err != nil {
|
||||
t.Fatalf("audit count: %v", err)
|
||||
}
|
||||
if n < 7 {
|
||||
t.Fatalf("audit entries = %d, want >= 7", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserOTPLogin(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 管理员登录并创建外部用户
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{"username": "lisi", "email": "ls@example.com"}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 生成图形验证码(直接经 store,模拟用户看到验证码)
|
||||
cap, err := app.captchas.New()
|
||||
if err != nil {
|
||||
t.Fatalf("captcha new: %v", err)
|
||||
}
|
||||
|
||||
// 发送 OTP
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]any{
|
||||
"username": "ext_lisi", "captcha_id": cap.ID, "captcha_code": cap.Text,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("otp send status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// CLI 通道取同一验证码
|
||||
code, err := app.otps.Current(context.Background(), "ext_lisi")
|
||||
if err != nil {
|
||||
t.Fatalf("otp current: %v", err)
|
||||
}
|
||||
|
||||
// OTP 登录
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "ext_lisi", "code": code})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("otp login status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
userCk := sessionCookie(t, w)
|
||||
|
||||
// 外部用户 me
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("user me status = %d", w.Code)
|
||||
}
|
||||
if m := decodeBody(t, w); m["data"].(map[string]any)["user_type"] != "user" {
|
||||
t.Fatalf("user me body = %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 外部用户访问 admin 路由应 403
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/users", nil, userCk)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("user access admin status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// 登出
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/logout", nil, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("logout status = %d", w.Code)
|
||||
}
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAdminForgotReset(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/forgot", map[string]string{"username": "root"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("forgot status = %d", w.Code)
|
||||
}
|
||||
if app.mailer.lastTo != "root@example.com" {
|
||||
t.Fatalf("reset mail to = %q", app.mailer.lastTo)
|
||||
}
|
||||
idx := bytes.Index([]byte(app.mailer.lastBody), []byte("token="))
|
||||
if idx < 0 {
|
||||
t.Fatalf("reset link missing token: %s", app.mailer.lastBody)
|
||||
}
|
||||
token := app.mailer.lastBody[idx+6:]
|
||||
token = token[:bytes.IndexByte([]byte(token), '\n')]
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/reset", map[string]string{"token": token, "new_password": "NewPassw0rd"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 新密码可登录
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "NewPassw0rd"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login with new password status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(u uint) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/auth"
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// AuthHandler 认证接口:图形验证码、OTP 双通道登录、管理员登录、密码重置、会话。
|
||||
type AuthHandler struct {
|
||||
svc *service.AuthService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// Captcha GET /auth/captcha —— 获取图形验证码(id + base64 PNG)。
|
||||
func (h *AuthHandler) Captcha(c *gin.Context) {
|
||||
id, png, err := h.svc.NewCaptcha()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "验证码生成失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{
|
||||
"captcha_id": id,
|
||||
"image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
|
||||
})
|
||||
}
|
||||
|
||||
// OTPSendRequest 外部用户请求 OTP。
|
||||
type OTPSendRequest struct {
|
||||
Username string `json:"username" binding:"required"` // 含或不含 ext_ 前缀
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
// OTPSend POST /auth/otp/send —— 图形验证码前置,生成 OTP 并发邮件(失败不阻断)。
|
||||
func (h *AuthHandler) OTPSend(c *gin.Context) {
|
||||
var req OTPSendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := h.svc.UserOTPSend(c.Request.Context(), req.Username, req.CaptchaID, req.CaptchaCode, c.ClientIP())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCaptchaFailed):
|
||||
fail(c, http.StatusBadRequest, "图形验证码错误")
|
||||
case errors.Is(err, service.ErrUserUnavailable):
|
||||
fail(c, http.StatusNotFound, "用户不存在或不可用")
|
||||
case errors.Is(err, auth.ErrCooldown):
|
||||
fail(c, http.StatusTooManyRequests, "发送冷却中,请稍后重试")
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": "sent"})
|
||||
}
|
||||
|
||||
// OTPLoginRequest 外部用户 OTP 登录。
|
||||
type OTPLoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Code string `json:"code" binding:"required"` // 6 位 OTP
|
||||
}
|
||||
|
||||
// OTPLogin POST /auth/otp/login —— OTP 校验并建立 cookie 会话。
|
||||
func (h *AuthHandler) OTPLogin(c *gin.Context) {
|
||||
var req OTPLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
sid, err := h.svc.UserOTPLogin(c.Request.Context(), req.Username, req.Code, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUserUnavailable):
|
||||
fail(c, http.StatusNotFound, "用户不存在或不可用")
|
||||
case errors.Is(err, auth.ErrInvalidCode):
|
||||
fail(c, http.StatusUnauthorized, "验证码错误或已过期")
|
||||
case errors.Is(err, auth.ErrTooManyFails):
|
||||
fail(c, http.StatusTooManyRequests, "失败次数过多,请稍后再试")
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, sid, h.cfg.Server.SessionTTL, h.cfg.App.Env == "production")
|
||||
ok(c, gin.H{"session": "created"})
|
||||
}
|
||||
|
||||
// AdminLoginRequest 管理员登录。
|
||||
type AdminLoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminLogin POST /auth/admin/login —— 管理员用户名+口令登录(含失败限速)。
|
||||
func (h *AuthHandler) AdminLogin(c *gin.Context) {
|
||||
var req AdminLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
sid, err := h.svc.AdminLogin(c.Request.Context(), req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrRateLimited):
|
||||
fail(c, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
|
||||
case errors.Is(err, service.ErrBadCredentials):
|
||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, sid, h.cfg.Server.SessionTTL, h.cfg.App.Env == "production")
|
||||
ok(c, gin.H{"session": "created"})
|
||||
}
|
||||
|
||||
// AdminForgotRequest 管理员忘记密码。
|
||||
type AdminForgotRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminForgot POST /auth/admin/forgot —— 发送密码重置邮件(用户不存在也返回成功,防枚举)。
|
||||
func (h *AuthHandler) AdminForgot(c *gin.Context) {
|
||||
var req AdminForgotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.AdminForgot(c.Request.Context(), req.Username, c.ClientIP()); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": "sent"})
|
||||
}
|
||||
|
||||
// AdminResetRequest 通过令牌重置密码。
|
||||
type AdminResetRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminReset POST /auth/admin/reset —— 校验令牌并重置密码。
|
||||
func (h *AuthHandler) AdminReset(c *gin.Context) {
|
||||
var req AdminResetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := h.svc.AdminReset(c.Request.Context(), req.Token, req.NewPassword, c.ClientIP())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrResetTokenInvalid):
|
||||
fail(c, http.StatusBadRequest, "重置令牌无效或已过期")
|
||||
case errors.Is(err, service.ErrWeakPassword):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": "reset"})
|
||||
}
|
||||
|
||||
// Logout POST /auth/logout —— 登出(会话删除 + cookie 清除)。
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
sess := sessionFrom(c)
|
||||
if sess != nil {
|
||||
_ = h.svc.Logout(c.Request.Context(), sess.ID)
|
||||
}
|
||||
setSessionCookie(c, "", 0, h.cfg.App.Env == "production")
|
||||
ok(c, gin.H{"status": "logged_out"})
|
||||
}
|
||||
|
||||
// Me GET /auth/me —— 当前会话主体信息。
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
sess := sessionFrom(c)
|
||||
if sess == nil {
|
||||
fail(c, http.StatusUnauthorized, "未登录")
|
||||
return
|
||||
}
|
||||
info, err := h.svc.Me(c.Request.Context(), sess.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "会话失效或已过期")
|
||||
return
|
||||
}
|
||||
ok(c, info)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SessionCookieName 会话 cookie 名称。
|
||||
const SessionCookieName = "usernode_session"
|
||||
|
||||
// SessionContextKey 会话在 gin context 中的键(router 中间件注入)。
|
||||
const SessionContextKey = "auth_session"
|
||||
|
||||
// setSessionCookie 写入会话 cookie(HttpOnly/SameSite=Lax;maxAge<=0 时清除)。
|
||||
func setSessionCookie(c *gin.Context, sid string, ttl time.Duration, secure bool) {
|
||||
maxAge := int(ttl.Seconds())
|
||||
if sid == "" {
|
||||
maxAge = -1
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sid,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
Secure: secure,
|
||||
})
|
||||
}
|
||||
|
||||
// SessionIDFromCookie 从请求 cookie 读取会话 ID。
|
||||
func SessionIDFromCookie(c *gin.Context) string {
|
||||
v, err := c.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
+41
-10
@@ -1,36 +1,57 @@
|
||||
// Package api 为 HTTP handler 层(RESTful v1)。
|
||||
// M0 提供健康检查与模块路由骨架;各模块 handler 在对应里程碑填充。
|
||||
// M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/auth"
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// Handler 聚合各模块 handler,作为路由注册的挂载点。
|
||||
type Handler struct {
|
||||
Health *HealthHandler
|
||||
Admin *AdminHandler
|
||||
Auth *AuthHandler
|
||||
User *UserHandler
|
||||
// Auth / Keys / Approval / Audit / Settings 等模块在 M1~M4 填充
|
||||
|
||||
authSvc *service.AuthService
|
||||
auditSvc *service.AuditService
|
||||
}
|
||||
|
||||
// New 创建 handler 集合。M0 阶段部分服务可为 nil,路由只挂已实现模块。
|
||||
func New(adminSvc *service.AdminService, userSvc *service.UserService, auditSvc *service.AuditService) *Handler {
|
||||
// New 创建 handler 集合。
|
||||
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, auditSvc *service.AuditService) *Handler {
|
||||
h := &Handler{
|
||||
Health: &HealthHandler{startedAt: time.Now()},
|
||||
Admin: &AdminHandler{svc: adminSvc},
|
||||
User: &UserHandler{svc: userSvc},
|
||||
Health: &HealthHandler{startedAt: time.Now()},
|
||||
authSvc: authSvc,
|
||||
auditSvc: auditSvc,
|
||||
}
|
||||
_ = auditSvc
|
||||
h.Auth = &AuthHandler{svc: authSvc, cfg: cfg}
|
||||
h.User = &UserHandler{svc: userSvc, cfg: cfg, h: h}
|
||||
return h
|
||||
}
|
||||
|
||||
// audit 记录管理操作审计(append-only)。actor 来自会话中间件。
|
||||
func (h *Handler) audit(c *gin.Context, action, resourceType, resourceID string, detail any, result string) {
|
||||
var actorID uint
|
||||
var actorName string
|
||||
if sess := sessionFrom(c); sess != nil {
|
||||
actorID = sess.RefID
|
||||
if info, err := h.authSvc.Me(c.Request.Context(), sess.ID); err == nil {
|
||||
actorName = info.Username
|
||||
} else {
|
||||
actorName = sess.UserType + "#" + strconv.FormatUint(uint64(sess.RefID), 10)
|
||||
}
|
||||
}
|
||||
_ = h.auditSvc.Record(c.Request.Context(), actorID, actorName, action, resourceType, resourceID, detail, c.ClientIP(), result)
|
||||
}
|
||||
|
||||
// HealthHandler 健康检查。
|
||||
type HealthHandler struct {
|
||||
startedAt time.Time
|
||||
@@ -39,13 +60,23 @@ type HealthHandler struct {
|
||||
func (h *HealthHandler) Healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"version": "0.1.0-m0",
|
||||
"version": "0.2.0-m1",
|
||||
"uptime": time.Since(h.startedAt).String(),
|
||||
"go": runtime.Version(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// sessionFrom 返回会话中间件注入的会话(未登录时为 nil)。
|
||||
func sessionFrom(c *gin.Context) *auth.Session {
|
||||
if v, ok := c.Get(SessionContextKey); ok {
|
||||
if s, ok := v.(*auth.Session); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ok 统一成功响应。
|
||||
func ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
|
||||
+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