feat(M2): SSH 密钥管理 — 公钥上传/重命名/吊销、authorized_keys 原子同步与吊销即时失效
- KeyService:crypto/ssh 解析校验(单行/类型/长度/去重指纹,拒 ssh-dss 与 RSA<2048), Create/Rename/Revoke/List,变更后以 DB 状态全量重写 authorized_keys(同步失败回滚) - system 层:SyncAuthorizedKeys 完善 —— sudo 模式经白名单命令(mkdir/chown/chmod/install) 落位并修正属主(sshd StrictModes),direct 模式 root 时同样修正属主;dry-run 计划日志 - API:GET/POST /me/keys、PATCH/DELETE /me/keys/:id(user 会话)、GET /users/:id/keys(admin), 密钥操作带审计;deploy/sudoers.example 补充密钥同步白名单 - 版本 0.3.0-m2;测试:service 单元(校验/生命周期/回滚/权限)、system 直写落盘、 API 全流程集成;容器 E2E 32 项 PASS(真实 useradd/authorized_keys/吊销即时失效/禁用清空/删除回收)
This commit is contained in:
+159
-3
@@ -3,15 +3,20 @@ package api_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/api"
|
||||
@@ -37,7 +42,32 @@ func (m *recordingMailer) Send(_ context.Context, to, subject, body string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// testApp 完整组装的应用(SQLite 内存库 + dry-run 系统层)。
|
||||
// recordingSys 记录 authorized_keys 同步内容,用于断言上传/吊销/禁用即时生效。
|
||||
type recordingSys struct {
|
||||
mu sync.Mutex
|
||||
keys map[string][]system.Key // username -> 最后一次同步的密钥
|
||||
}
|
||||
|
||||
func newRecordingSys() *recordingSys { return &recordingSys{keys: map[string][]system.Key{}} }
|
||||
|
||||
func (s *recordingSys) CreateUser(_ context.Context, _ system.Account) error { return nil }
|
||||
func (s *recordingSys) RemoveUser(_ context.Context, _ string) error { return nil }
|
||||
func (s *recordingSys) SetLock(_ context.Context, _ string, _ bool) error { return nil }
|
||||
func (s *recordingSys) Exists(_ context.Context, _ string) (bool, error) { return true, nil }
|
||||
func (s *recordingSys) SyncAuthorizedKeys(_ context.Context, username string, keys []system.Key) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys[username] = keys
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSys) synced(username string) []system.Key {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.keys[username]
|
||||
}
|
||||
|
||||
// testApp 完整组装的应用(SQLite 内存库 + 可注入系统层)。
|
||||
type testApp struct {
|
||||
r http.Handler
|
||||
db *gorm.DB
|
||||
@@ -47,6 +77,11 @@ type testApp struct {
|
||||
}
|
||||
|
||||
func setupTestApp(t *testing.T) *testApp {
|
||||
return setupTestAppWithSys(t, system.New(config.Default().System, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
}
|
||||
|
||||
// setupTestAppWithSys 允许注入 system.Manager(观察 authorized_keys 同步等)。
|
||||
func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := model.Open("sqlite", ":memory:", false)
|
||||
@@ -59,9 +94,9 @@ func setupTestApp(t *testing.T) *testApp {
|
||||
cfg := config.Default()
|
||||
cfg.System.DryRun = true // 集成测试走 dry-run,不触碰真实系统账号
|
||||
|
||||
sys := system.New(cfg.System)
|
||||
adminSvc := service.NewAdminService(db)
|
||||
userSvc := service.NewUserService(db, sys, cfg)
|
||||
keySvc := service.NewKeyService(db, sys, cfg)
|
||||
auditSvc := service.NewAuditService(db)
|
||||
|
||||
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
|
||||
@@ -73,7 +108,7 @@ func setupTestApp(t *testing.T) *testApp {
|
||||
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)
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc)
|
||||
r := router.New(cfg, h, sessions, log)
|
||||
|
||||
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
|
||||
@@ -324,3 +359,124 @@ func TestAPIAdminForgotReset(t *testing.T) {
|
||||
func itoa(u uint) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
|
||||
func TestAPIKeyLifecycle(t *testing.T) {
|
||||
rec := newRecordingSys()
|
||||
app := setupTestAppWithSys(t, rec)
|
||||
|
||||
// 管理员创建外部用户
|
||||
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": "wangwu", "email": "ww@example.com"}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
userID := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64))
|
||||
|
||||
// 外部用户 OTP 登录
|
||||
cap, err := app.captchas.New()
|
||||
if err != nil {
|
||||
t.Fatalf("captcha new: %v", err)
|
||||
}
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]any{"username": "ext_wangwu", "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())
|
||||
}
|
||||
code, err := app.otps.Current(context.Background(), "ext_wangwu")
|
||||
if err != nil {
|
||||
t.Fatalf("otp current: %v", err)
|
||||
}
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "ext_wangwu", "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/keys → 401
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/me/keys", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth me/keys status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 上传公钥 → 同步到 authorized_keys
|
||||
pub := testSSHPubKey(t)
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/me/keys", map[string]any{"name": "workstation", "public_key": pub}, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create key status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
key := decodeBody(t, w)["data"].(map[string]any)
|
||||
keyID := uint(key["id"].(float64))
|
||||
if key["fingerprint"] == "" || key["status"] != "active" {
|
||||
t.Fatalf("key fields: %v", key)
|
||||
}
|
||||
if synced := rec.synced("ext_wangwu"); len(synced) != 1 {
|
||||
t.Fatalf("after create synced = %+v, want 1 key", synced)
|
||||
}
|
||||
|
||||
// 重复上传同一公钥 → 409
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/me/keys", map[string]any{"name": "dup", "public_key": pub}, userCk)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate key status = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 列表
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/me/keys", nil, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list keys status = %d", w.Code)
|
||||
}
|
||||
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
||||
t.Fatalf("list items = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
// 重命名
|
||||
w = app.doJSON(http.MethodPatch, "/api/v1/me/keys/"+itoa(keyID), map[string]any{"name": "home-laptop"}, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("rename status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if name := decodeBody(t, w)["data"].(map[string]any)["name"]; name != "home-laptop" {
|
||||
t.Fatalf("renamed = %v", name)
|
||||
}
|
||||
|
||||
// 管理员查看用户密钥;外部用户访问 admin 接口 → 403
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(userID)+"/keys", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin list keys status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
||||
t.Fatalf("admin items = %d, want 1", len(items))
|
||||
}
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(userID)+"/keys", nil, userCk)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("user access admin keys status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// 吊销 → authorized_keys 清空(立即失效),状态 revoked;再次吊销幂等
|
||||
w = app.doJSON(http.MethodDelete, "/api/v1/me/keys/"+itoa(keyID), nil, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("revoke status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "revoked" {
|
||||
t.Fatalf("revoked status = %v", status)
|
||||
}
|
||||
if synced := rec.synced("ext_wangwu"); len(synced) != 0 {
|
||||
t.Fatalf("after revoke synced = %+v, want empty", synced)
|
||||
}
|
||||
w = app.doJSON(http.MethodDelete, "/api/v1/me/keys/"+itoa(keyID), nil, userCk)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("revoke again status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// testSSHPubKey 生成一条合法的 ed25519 公钥行。
|
||||
func testSSHPubKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen ed25519: %v", err)
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh key: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Package api 为 HTTP handler 层(RESTful v1)。
|
||||
// M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD。
|
||||
// M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD;
|
||||
// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表)。
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -20,13 +21,14 @@ type Handler struct {
|
||||
Health *HealthHandler
|
||||
Auth *AuthHandler
|
||||
User *UserHandler
|
||||
Key *KeyHandler
|
||||
|
||||
authSvc *service.AuthService
|
||||
authSvc *service.AuthService
|
||||
auditSvc *service.AuditService
|
||||
}
|
||||
|
||||
// New 创建 handler 集合。
|
||||
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, auditSvc *service.AuditService) *Handler {
|
||||
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, auditSvc *service.AuditService) *Handler {
|
||||
h := &Handler{
|
||||
Health: &HealthHandler{startedAt: time.Now()},
|
||||
authSvc: authSvc,
|
||||
@@ -34,6 +36,7 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User
|
||||
}
|
||||
h.Auth = &AuthHandler{svc: authSvc, cfg: cfg}
|
||||
h.User = &UserHandler{svc: userSvc, cfg: cfg, h: h}
|
||||
h.Key = &KeyHandler{svc: keySvc, cfg: cfg, h: h}
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -60,7 +63,7 @@ type HealthHandler struct {
|
||||
func (h *HealthHandler) Healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"version": "0.2.0-m1",
|
||||
"version": "0.3.0-m2",
|
||||
"uptime": time.Since(h.startedAt).String(),
|
||||
"go": runtime.Version(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// KeyHandler SSH 公钥接口:外部用户自助管理(/me/keys)+ 管理员查看(/users/:id/keys)。
|
||||
// 密钥仅用户上传(管理员不代签);吊销后立即从 authorized_keys 移除。
|
||||
type KeyHandler struct {
|
||||
svc *service.KeyService
|
||||
cfg *config.Config
|
||||
h *Handler // 访问审计 helper
|
||||
}
|
||||
|
||||
// KeyCreateRequest 上传公钥。
|
||||
type KeyCreateRequest struct {
|
||||
Name string `json:"name" binding:"required"` // 显示名称,1~64 字符
|
||||
PublicKey string `json:"public_key" binding:"required"`
|
||||
}
|
||||
|
||||
// KeyRenameRequest 重命名。
|
||||
type KeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// currentUserID 从会话取当前外部用户 ID(/me/keys 均为 user 会话)。
|
||||
func (h *KeyHandler) currentUserID(c *gin.Context) uint {
|
||||
if sess := sessionFrom(c); sess != nil {
|
||||
return sess.RefID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ListMine GET /me/keys —— 我的密钥列表。
|
||||
func (h *KeyHandler) ListMine(c *gin.Context) {
|
||||
keys, err := h.svc.ListByUser(c.Request.Context(), h.currentUserID(c))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"items": keys})
|
||||
}
|
||||
|
||||
// Create POST /me/keys —— 上传公钥(类型/长度/重复校验 + 同步 authorized_keys)。
|
||||
func (h *KeyHandler) Create(c *gin.Context) {
|
||||
var req KeyCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
k, err := h.svc.Create(c.Request.Context(), uid, req.Name, req.PublicKey, uid)
|
||||
if err != nil {
|
||||
h.h.audit(c, "key.create", "ssh_key", "", map[string]any{"name": req.Name, "err": err.Error()}, model.ResultFailed)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrKeyInvalid):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrKeyDuplicate):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrUserNotActive):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "key.create", "ssh_key", strconv.FormatUint(uint64(k.ID), 10), map[string]any{"name": k.Name, "fingerprint": k.Fingerprint}, model.ResultSuccess)
|
||||
ok(c, k)
|
||||
}
|
||||
|
||||
// Rename PATCH /me/keys/:id —— 重命名(仅元数据,不影响 authorized_keys)。
|
||||
func (h *KeyHandler) Rename(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的密钥 ID")
|
||||
return
|
||||
}
|
||||
var req KeyRenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
k, err := h.svc.Rename(c.Request.Context(), uint(id), h.currentUserID(c), req.Name)
|
||||
if err != nil {
|
||||
h.h.audit(c, "key.rename", "ssh_key", c.Param("id"), map[string]any{"err": err.Error()}, model.ResultFailed)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrKeyNotFound):
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrKeyInvalid):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "key.rename", "ssh_key", c.Param("id"), map[string]any{"name": k.Name}, model.ResultSuccess)
|
||||
ok(c, k)
|
||||
}
|
||||
|
||||
// Revoke DELETE /me/keys/:id —— 吊销(从 authorized_keys 移除,立即失效)。
|
||||
func (h *KeyHandler) Revoke(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的密钥 ID")
|
||||
return
|
||||
}
|
||||
k, err := h.svc.Revoke(c.Request.Context(), uint(id), h.currentUserID(c))
|
||||
if err != nil {
|
||||
h.h.audit(c, "key.revoke", "ssh_key", c.Param("id"), map[string]any{"err": err.Error()}, model.ResultFailed)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrKeyNotFound):
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "key.revoke", "ssh_key", c.Param("id"), map[string]any{"fingerprint": k.Fingerprint}, model.ResultSuccess)
|
||||
ok(c, k)
|
||||
}
|
||||
|
||||
// ListForUser GET /users/:id/keys —— 管理员查看用户密钥。
|
||||
func (h *KeyHandler) ListForUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的用户 ID")
|
||||
return
|
||||
}
|
||||
keys, err := h.svc.ListByUser(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"items": keys})
|
||||
}
|
||||
Reference in New Issue
Block a user