Files
usernode/internal/api/key.go
T
cao.wangrenbo a5f501dba4 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/吊销即时失效/禁用清空/删除回收)
2026-08-29 23:55:39 +08:00

151 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}