fix: 独立审计日志并补齐令牌身份与失败记录
This commit is contained in:
@@ -193,6 +193,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
|||||||
Tokens: apiTokens,
|
Tokens: apiTokens,
|
||||||
Locks: loginLocks,
|
Locks: loginLocks,
|
||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
|
AuditLogger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})),
|
||||||
TrustedProxies: trustedNets,
|
TrustedProxies: trustedNets,
|
||||||
Identity: idApp,
|
Identity: idApp,
|
||||||
Groups: groupApp,
|
Groups: groupApp,
|
||||||
|
|||||||
@@ -935,6 +935,15 @@
|
|||||||
- 备选方案:改密失败不返回 401(与契约不符);未采用。W-01 排除改密 401 自动登出。
|
- 备选方案:改密失败不返回 401(与契约不符);未采用。W-01 排除改密 401 自动登出。
|
||||||
- 影响:连续 10 次旧密码错误后锁定;过期会话行在下次登录清除。
|
- 影响:连续 10 次旧密码错误后锁定;过期会话行在下次登录清除。
|
||||||
|
|
||||||
|
### 复审修复 H-02
|
||||||
|
|
||||||
|
- 日期:2026-09-30
|
||||||
|
- 原条款:PRD F17 每个改变状态的操作记日志;DEVELOPMENT §8 / admin-api 1.4 结构化操作日志;审查 #45。
|
||||||
|
- 实际做法:`Deps.AuditLogger` 独立 slog(始终 Info),`serve` 装配,不受 `log.level` 影响。审计增加 `token_id` 与不含秘密的 `detail`(enabled / code_changed / generated / members / failed / new_owner / 导入前 20 个编号加 total)。批量结果为 ok/partial/failed。登录失败、锁定、错误令牌写 `admin_auth_fail`(ip+reason)。令牌名称 1–64 字符。Cookie 未登录不记 `admin_auth_fail`。
|
||||||
|
- 原因:原先走全局 Info,warn/error 级别会丢全部审计;令牌可重名改名;批量恒 ok;失败登录无记录。
|
||||||
|
- 备选方案:独立 audit 表;未采用,保持 slog 采集。
|
||||||
|
- 影响:日志采集仍按 `admin_audit` 过滤,新字段只增不改;另需采集 `admin_auth_fail`。
|
||||||
|
|
||||||
## 后台网页 W
|
## 后台网页 W
|
||||||
|
|
||||||
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
||||||
|
|||||||
+81
-7
@@ -1,12 +1,86 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const importAuditIDCap = 20
|
||||||
|
|
||||||
|
func defaultAuditLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditRec struct {
|
||||||
|
Actor string
|
||||||
|
TokenID string
|
||||||
|
Action string
|
||||||
|
Object string
|
||||||
|
Result string
|
||||||
|
IP string
|
||||||
|
Detail any
|
||||||
|
}
|
||||||
|
|
||||||
// audit 写结构化操作日志;不写密码、令牌和正文。
|
// audit 写结构化操作日志;不写密码、令牌和正文。
|
||||||
func (h *Handler) audit(actor, action, object, result, ip string) {
|
func (h *Handler) audit(actor, action, object, result, ip string) {
|
||||||
h.log.Info("admin_audit",
|
h.auditRec(auditRec{Actor: actor, Action: action, Object: object, Result: result, IP: ip})
|
||||||
"actor", actor,
|
}
|
||||||
"action", action,
|
|
||||||
"object", object,
|
func (h *Handler) auditP(p principal, action, object, result, ip string) {
|
||||||
"result", result,
|
h.auditPD(p, action, object, result, ip, nil)
|
||||||
"ip", ip,
|
}
|
||||||
)
|
|
||||||
|
func (h *Handler) auditPD(p principal, action, object, result, ip string, detail any) {
|
||||||
|
rec := auditRec{
|
||||||
|
Actor: actorString(p),
|
||||||
|
Action: action,
|
||||||
|
Object: object,
|
||||||
|
Result: result,
|
||||||
|
IP: ip,
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
if p.Kind == authToken && p.TokenID != "" {
|
||||||
|
rec.TokenID = p.TokenID
|
||||||
|
}
|
||||||
|
h.auditRec(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) auditRec(rec auditRec) {
|
||||||
|
args := []any{
|
||||||
|
"actor", rec.Actor,
|
||||||
|
"action", rec.Action,
|
||||||
|
"object", rec.Object,
|
||||||
|
"result", rec.Result,
|
||||||
|
"ip", rec.IP,
|
||||||
|
}
|
||||||
|
if rec.TokenID != "" {
|
||||||
|
args = append(args, "token_id", rec.TokenID)
|
||||||
|
}
|
||||||
|
if rec.Detail != nil {
|
||||||
|
args = append(args, "detail", rec.Detail)
|
||||||
|
}
|
||||||
|
h.auditLog.Info("admin_audit", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) auditAuthFail(ip, reason string) {
|
||||||
|
h.auditLog.Info("admin_auth_fail", "ip", ip, "reason", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchAuditResult(okN, failN int) string {
|
||||||
|
switch {
|
||||||
|
case failN == 0:
|
||||||
|
return "ok"
|
||||||
|
case okN == 0:
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "partial"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importAuditDetail(ids []string) map[string]any {
|
||||||
|
shown := ids
|
||||||
|
if len(ids) > importAuditIDCap {
|
||||||
|
shown = ids[:importAuditIDCap]
|
||||||
|
}
|
||||||
|
return map[string]any{"ids": shown, "total": len(ids)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ func (h *Handler) authenticate(r *http.Request, ip string) (principal, string, s
|
|||||||
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
||||||
raw := strings.TrimSpace(authz[len("Bearer "):])
|
raw := strings.TrimSpace(authz[len("Bearer "):])
|
||||||
if raw == "" || !h.tokens.LooksLikeAPIToken(raw) {
|
if raw == "" || !h.tokens.LooksLikeAPIToken(raw) {
|
||||||
return authFail(h, ip)
|
return h.authFail(ip)
|
||||||
}
|
}
|
||||||
hash := h.tokens.HashToken(raw)
|
hash := h.tokens.HashToken(raw)
|
||||||
info, err := h.lookupAPIToken(r.Context(), hash)
|
info, err := h.lookupAPIToken(r.Context(), hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return authFail(h, ip)
|
return h.authFail(ip)
|
||||||
}
|
}
|
||||||
return principal{}, "internal", "内部错误", http.StatusInternalServerError
|
return principal{}, "internal", "内部错误", http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
if !info.Enabled {
|
if !info.Enabled {
|
||||||
return authFail(h, ip)
|
return h.authFail(ip)
|
||||||
}
|
}
|
||||||
h.touchLastUsed(r.Context(), info.ID)
|
h.touchLastUsed(r.Context(), info.ID)
|
||||||
return principal{Kind: authToken, TokenName: info.Name, TokenID: info.ID}, "", "", 0
|
return principal{Kind: authToken, TokenName: info.Name, TokenID: info.ID}, "", "", 0
|
||||||
@@ -124,10 +124,12 @@ func (h *Handler) authenticate(r *http.Request, ip string) (principal, string, s
|
|||||||
return principal{Kind: authCookie, Session: c.Value}, "", "", 0
|
return principal{Kind: authCookie, Session: c.Value}, "", "", 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFail(h *Handler, ip string) (principal, string, string, int) {
|
func (h *Handler) authFail(ip string) (principal, string, string, int) {
|
||||||
if locked, _ := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
if locked, _ := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
||||||
|
h.auditAuthFail(ip, "rate_limited")
|
||||||
return principal{}, "rate_limited", "登录已锁定,请稍后再试", http.StatusTooManyRequests
|
return principal{}, "rate_limited", "登录已锁定,请稍后再试", http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
|
h.auditAuthFail(ip, "invalid_token")
|
||||||
return principal{}, "unauthorized", "令牌无效", http.StatusUnauthorized
|
return principal{}, "unauthorized", "令牌无效", http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-45
@@ -225,7 +225,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
DefaultDelaySeconds *int64 `json:"default_delay_seconds"`
|
DefaultDelaySeconds *int64 `json:"default_delay_seconds"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "endpoint_create", "", "bad_request", ip)
|
h.auditP(p, "endpoint_create", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
delaySec = *req.DefaultDelaySeconds
|
delaySec = *req.DefaultDelaySeconds
|
||||||
}
|
}
|
||||||
if errMsg := validateEndpointFields(req.ID, req.Name, req.Remark, req.LoginPassword, req.TalkPassword, delaySec, h.maxScheduleSeconds()); errMsg != "" {
|
if errMsg := validateEndpointFields(req.ID, req.Name, req.Remark, req.LoginPassword, req.TalkPassword, delaySec, h.maxScheduleSeconds()); errMsg != "" {
|
||||||
h.audit(actorString(p), "endpoint_create", req.ID, "bad_request", ip)
|
h.auditP(p, "endpoint_create", req.ID, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", errMsg)
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if loginPW == "" {
|
if loginPW == "" {
|
||||||
pw, err := generateLoginPassword()
|
pw, err := generateLoginPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_create", id, "error", ip)
|
h.auditP(p, "endpoint_create", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
loginHash, err := h.hash.Hash(r.Context(), auth.PasswordLogin, loginPW)
|
loginHash, err := h.hash.Hash(r.Context(), auth.PasswordLogin, loginPW)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_create", id, "error", ip)
|
h.auditP(p, "endpoint_create", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.TalkPassword != "" {
|
if req.TalkPassword != "" {
|
||||||
th, hashErr := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword)
|
th, hashErr := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword)
|
||||||
if hashErr != nil {
|
if hashErr != nil {
|
||||||
h.audit(actorString(p), "endpoint_create", id, "error", ip)
|
h.auditP(p, "endpoint_create", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,15 +280,15 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueConstraint(err) {
|
if isUniqueConstraint(err) {
|
||||||
h.audit(actorString(p), "endpoint_create", id, "id_taken", ip)
|
h.auditP(p, "endpoint_create", id, "id_taken", ip)
|
||||||
httpx.WriteError(w, http.StatusConflict, "id_taken", "编号已占用")
|
httpx.WriteError(w, http.StatusConflict, "id_taken", "编号已占用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_create", id, "error", ip)
|
h.auditP(p, "endpoint_create", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_create", createdID, "ok", ip)
|
h.auditP(p, "endpoint_create", createdID, "ok", ip)
|
||||||
data := map[string]any{"id": createdID}
|
data := map[string]any{"id": createdID}
|
||||||
if pwGenerated {
|
if pwGenerated {
|
||||||
data[loginPasswordOnceKey] = loginPW
|
data[loginPasswordOnceKey] = loginPW
|
||||||
@@ -322,28 +322,28 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "bad_request", ip)
|
h.auditP(p, "endpoint_patch", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name == nil && req.Remark == nil && req.DefaultDelaySeconds == nil && req.Enabled == nil {
|
if req.Name == nil && req.Remark == nil && req.DefaultDelaySeconds == nil && req.Enabled == nil {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "bad_request", ip)
|
h.auditP(p, "endpoint_patch", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "无更新字段")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "无更新字段")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name != nil && !protocol.ValidName(*req.Name) {
|
if req.Name != nil && !protocol.ValidName(*req.Name) {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "bad_request", ip)
|
h.auditP(p, "endpoint_patch", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不合法")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不合法")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Remark != nil && utf8.RuneCountInString(*req.Remark) > maxRemarkChars {
|
if req.Remark != nil && utf8.RuneCountInString(*req.Remark) > maxRemarkChars {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "bad_request", ip)
|
h.auditP(p, "endpoint_patch", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "备注过长")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "备注过长")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.DefaultDelaySeconds != nil {
|
if req.DefaultDelaySeconds != nil {
|
||||||
if msg := validateDelaySeconds(*req.DefaultDelaySeconds, h.maxScheduleSeconds()); msg != "" {
|
if msg := validateDelaySeconds(*req.DefaultDelaySeconds, h.maxScheduleSeconds()); msg != "" {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "bad_request", ip)
|
h.auditP(p, "endpoint_patch", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", msg)
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,11 +361,11 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
wasEnabled, err = h.patchEndpoint(r.Context(), id, req.Name, req.Remark, req.DefaultDelaySeconds, patchEnabled)
|
wasEnabled, err = h.patchEndpoint(r.Context(), id, req.Name, req.Remark, req.DefaultDelaySeconds, patchEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "not_found", ip)
|
h.auditP(p, "endpoint_patch", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "error", ip)
|
h.auditP(p, "endpoint_patch", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -374,12 +374,12 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
var found bool
|
var found bool
|
||||||
found, err = h.setEndpointEnabled(r.Context(), id, *req.Enabled)
|
found, err = h.setEndpointEnabled(r.Context(), id, *req.Enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "error", ip)
|
h.auditP(p, "endpoint_patch", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "not_found", ip)
|
h.auditP(p, "endpoint_patch", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -391,11 +391,19 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
row, err := h.getEndpoint(r.Context(), id)
|
row, err := h.getEndpoint(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "error", ip)
|
h.auditP(p, "endpoint_patch", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "ok", ip)
|
detail := map[string]any{}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
detail["enabled"] = *req.Enabled
|
||||||
|
}
|
||||||
|
if len(detail) == 0 {
|
||||||
|
h.auditP(p, "endpoint_patch", id, "ok", ip)
|
||||||
|
} else {
|
||||||
|
h.auditPD(p, "endpoint_patch", id, "ok", ip, detail)
|
||||||
|
}
|
||||||
httpx.WriteOK(w, row.toAPI(h.endpointLoginLocked(row.ID), true))
|
httpx.WriteOK(w, row.toAPI(h.endpointLoginLocked(row.ID), true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,17 +414,17 @@ func (h *Handler) handleEndpointDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ok, err := h.deleteEndpointBasic(r.Context(), id)
|
ok, err := h.deleteEndpointBasic(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_delete", id, "error", ip)
|
h.auditP(p, "endpoint_delete", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.audit(actorString(p), "endpoint_delete", id, "not_found", ip)
|
h.auditP(p, "endpoint_delete", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.afterDeleteKick(r.Context(), id)
|
h.afterDeleteKick(r.Context(), id)
|
||||||
h.audit(actorString(p), "endpoint_delete", id, "ok", ip)
|
h.auditP(p, "endpoint_delete", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,14 +437,14 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil || len(req.IDs) == 0 {
|
if err := httpx.DecodeJSON(r, &req); err != nil || len(req.IDs) == 0 {
|
||||||
h.audit(actorString(p), "endpoint_batch", "", "bad_request", ip)
|
h.auditP(p, "endpoint_batch", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "disable", "enable", "delete":
|
case "disable", "enable", "delete":
|
||||||
default:
|
default:
|
||||||
h.audit(actorString(p), "endpoint_batch", "", "bad_request", ip)
|
h.auditP(p, "endpoint_batch", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "action 无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "action 无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -470,7 +478,10 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
okIDs = append(okIDs, id)
|
okIDs = append(okIDs, id)
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_batch_"+req.Action, strings.Join(okIDs, ","), "ok", ip)
|
h.auditPD(p, "endpoint_batch_"+req.Action, strings.Join(okIDs, ","), batchAuditResult(len(okIDs), len(failed)), ip, map[string]any{
|
||||||
|
"ok_ids": okIDs,
|
||||||
|
"failed": failed,
|
||||||
|
})
|
||||||
httpx.WriteOK(w, map[string]any{"ok_ids": okIDs, "failed": failed})
|
httpx.WriteOK(w, map[string]any{"ok_ids": okIDs, "failed": failed})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,22 +492,22 @@ func (h *Handler) handleEndpointKick(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
exists, err := h.endpointExists(r.Context(), id)
|
exists, err := h.endpointExists(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_kick", id, "error", ip)
|
h.auditP(p, "endpoint_kick", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
h.audit(actorString(p), "endpoint_kick", id, "not_found", ip)
|
h.auditP(p, "endpoint_kick", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kicked, err := h.kickEndpoint(r.Context(), id)
|
kicked, err := h.kickEndpoint(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_kick", id, "error", ip)
|
h.auditP(p, "endpoint_kick", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_kick", id, "ok", ip)
|
h.auditP(p, "endpoint_kick", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{"kicked": kicked})
|
httpx.WriteOK(w, map[string]any{"kicked": kicked})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,12 +520,12 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
|
|||||||
LoginPassword string `json:"login_password"`
|
LoginPassword string `json:"login_password"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "bad_request", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !protocol.ValidLoginPassword(req.LoginPassword) {
|
if !protocol.ValidLoginPassword(req.LoginPassword) {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "bad_request", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "登录密码不合法")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "登录密码不合法")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -523,7 +534,7 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
|
|||||||
if pw == "" {
|
if pw == "" {
|
||||||
gen, err := generateLoginPassword()
|
gen, err := generateLoginPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "error", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -531,23 +542,23 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
hash, err := h.hash.Hash(r.Context(), auth.PasswordLogin, pw)
|
hash, err := h.hash.Hash(r.Context(), auth.PasswordLogin, pw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "error", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, err := h.resetLoginPassword(r.Context(), id, hash)
|
ok, err := h.resetLoginPassword(r.Context(), id, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "error", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "not_found", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = h.passwordResetKick(r.Context(), id)
|
_, _ = h.passwordResetKick(r.Context(), id)
|
||||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "ok", ip)
|
h.auditP(p, "endpoint_reset_login_password", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
|
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,12 +571,12 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ
|
|||||||
TalkPassword string `json:"talk_password"`
|
TalkPassword string `json:"talk_password"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "bad_request", ip)
|
h.auditP(p, "endpoint_talk_password", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !protocol.ValidTalkPassword(req.TalkPassword) {
|
if !protocol.ValidTalkPassword(req.TalkPassword) {
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "bad_request", ip)
|
h.auditP(p, "endpoint_talk_password", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "对话密码不合法")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "对话密码不合法")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -574,7 +585,7 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ
|
|||||||
if req.TalkPassword != "" {
|
if req.TalkPassword != "" {
|
||||||
th, err := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword)
|
th, err := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "error", ip)
|
h.auditP(p, "endpoint_talk_password", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -582,16 +593,16 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
ok, err := h.setTalkPassword(r.Context(), id, talkHash)
|
ok, err := h.setTalkPassword(r.Context(), id, talkHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "error", ip)
|
h.auditP(p, "endpoint_talk_password", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "not_found", ip)
|
h.auditP(p, "endpoint_talk_password", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_talk_password", id, "ok", ip)
|
h.auditP(p, "endpoint_talk_password", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{"talk_password_set": talkHash.Valid})
|
httpx.WriteOK(w, map[string]any{"talk_password_set": talkHash.Valid})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,17 +613,17 @@ func (h *Handler) handleEndpointUnlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
exists, err := h.endpointExists(r.Context(), id)
|
exists, err := h.endpointExists(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "endpoint_unlock", id, "error", ip)
|
h.auditP(p, "endpoint_unlock", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
h.audit(actorString(p), "endpoint_unlock", id, "not_found", ip)
|
h.auditP(p, "endpoint_unlock", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.locks.ClearEndpoint(id)
|
h.locks.ClearEndpoint(id)
|
||||||
h.audit(actorString(p), "endpoint_unlock", id, "ok", ip)
|
h.auditP(p, "endpoint_unlock", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,18 +41,18 @@ func (h *Handler) handleEndpointImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
raw, err := readImportCSV(r)
|
raw, err := readImportCSV(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if httpx.IsBodyTooLarge(err) {
|
if httpx.IsBodyTooLarge(err) {
|
||||||
h.audit(actorString(p), "endpoint_import", "", "payload_too_large", ip)
|
h.auditP(p, "endpoint_import", "", "payload_too_large", ip)
|
||||||
httpx.WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", "请求体过大")
|
httpx.WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", "请求体过大")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_import", "", "bad_request", ip)
|
h.auditP(p, "endpoint_import", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", err.Error())
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, errs, fatal := h.validateImportCSV(r.Context(), raw)
|
prepared, errs, fatal := h.validateImportCSV(r.Context(), raw)
|
||||||
if fatal != nil {
|
if fatal != nil {
|
||||||
h.audit(actorString(p), "endpoint_import", "", "error", ip)
|
h.auditP(p, "endpoint_import", "", "error", ip)
|
||||||
if errors.Is(fatal, context.Canceled) || errors.Is(fatal, context.DeadlineExceeded) {
|
if errors.Is(fatal, context.Canceled) || errors.Is(fatal, context.DeadlineExceeded) {
|
||||||
httpx.WriteError(w, http.StatusServiceUnavailable, "busy", "哈希繁忙,请稍后再试")
|
httpx.WriteError(w, http.StatusServiceUnavailable, "busy", "哈希繁忙,请稍后再试")
|
||||||
return
|
return
|
||||||
@@ -61,7 +61,7 @@ func (h *Handler) handleEndpointImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
h.audit(actorString(p), "endpoint_import", "", "bad_request", ip)
|
h.auditP(p, "endpoint_import", "", "bad_request", ip)
|
||||||
writeCSVValidationError(w, errs)
|
writeCSVValidationError(w, errs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,15 +78,19 @@ func (h *Handler) handleEndpointImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := h.insertEndpointsBatch(r.Context(), rows); err != nil {
|
if err := h.insertEndpointsBatch(r.Context(), rows); err != nil {
|
||||||
if isUniqueConstraint(err) {
|
if isUniqueConstraint(err) {
|
||||||
h.audit(actorString(p), "endpoint_import", "", "id_taken", ip)
|
h.auditP(p, "endpoint_import", "", "id_taken", ip)
|
||||||
writeCSVConflictError(w, h.importUniqueLineErrors(r.Context(), prepared))
|
writeCSVConflictError(w, h.importUniqueLineErrors(r.Context(), prepared))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_import", "", "error", ip)
|
h.auditP(p, "endpoint_import", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "endpoint_import", strconv.Itoa(len(items)), "ok", ip)
|
ids := make([]string, 0, len(prepared))
|
||||||
|
for _, pRow := range prepared {
|
||||||
|
ids = append(ids, pRow.Insert.ID)
|
||||||
|
}
|
||||||
|
h.auditPD(p, "endpoint_import", strconv.Itoa(len(items)), "ok", ip, importAuditDetail(ids))
|
||||||
httpx.WriteOK(w, map[string]any{"items": items})
|
httpx.WriteOK(w, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-26
@@ -100,23 +100,23 @@ func (h *Handler) handleGroupCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
MemberIDs []string `json:"member_ids"`
|
MemberIDs []string `json:"member_ids"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
h.auditP(p, "group_create", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.ID) != "" {
|
if strings.TrimSpace(req.ID) != "" {
|
||||||
// AdminCreate 不接受自定义 id;与契约「留空则生成」一致时忽略非空会误导,故拒绝。
|
// AdminCreate 不接受自定义 id;与契约「留空则生成」一致时忽略非空会误导,故拒绝。
|
||||||
h.audit(actorString(p), "group_create", req.ID, "bad_request", ip)
|
h.auditP(p, "group_create", req.ID, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "后台创建群请留空 id,由服务器生成")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "后台创建群请留空 id,由服务器生成")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !protocol.ValidName(req.Name) || req.Name == "" {
|
if !protocol.ValidName(req.Name) || req.Name == "" {
|
||||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
h.auditP(p, "group_create", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不合法")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不合法")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.OwnerID == "" {
|
if req.OwnerID == "" {
|
||||||
h.audit(actorString(p), "group_create", "", "bad_request", ip)
|
h.auditP(p, "group_create", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "缺少 owner_id")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "缺少 owner_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func (h *Handler) handleGroupCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeGroupErr(w, p, "group_create", req.OwnerID, ip, err)
|
h.writeGroupErr(w, p, "group_create", req.OwnerID, ip, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_create", res.ID, "ok", ip)
|
h.auditP(p, "group_create", res.ID, "ok", ip)
|
||||||
failed := res.Failed
|
failed := res.Failed
|
||||||
if failed == nil {
|
if failed == nil {
|
||||||
failed = []group.MemberFail{}
|
failed = []group.MemberFail{}
|
||||||
@@ -227,18 +227,18 @@ func (h *Handler) handleGroupRename(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "group_rename", id, "bad_request", ip)
|
h.auditP(p, "group_rename", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
owner, err := h.groupOwner(r.Context(), id)
|
owner, err := h.groupOwner(r.Context(), id)
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
h.audit(actorString(p), "group_rename", id, "not_found", ip)
|
h.auditP(p, "group_rename", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "group_rename", id, "error", ip)
|
h.auditP(p, "group_rename", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,11 +252,11 @@ func (h *Handler) handleGroupRename(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
summary, err := h.groupSummary(r.Context(), id)
|
summary, err := h.groupSummary(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "group_rename", id, "error", ip)
|
h.auditP(p, "group_rename", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_rename", id, "ok", ip)
|
h.auditP(p, "group_rename", id, "ok", ip)
|
||||||
httpx.WriteOK(w, summary)
|
httpx.WriteOK(w, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,12 +270,12 @@ func (h *Handler) handleGroupDissolve(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
owner, err := h.groupOwner(r.Context(), id)
|
owner, err := h.groupOwner(r.Context(), id)
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
h.audit(actorString(p), "group_dissolve", id, "not_found", ip)
|
h.auditP(p, "group_dissolve", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "group_dissolve", id, "error", ip)
|
h.auditP(p, "group_dissolve", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ func (h *Handler) handleGroupDissolve(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeGroupErr(w, p, "group_dissolve", id, ip, err)
|
h.writeGroupErr(w, p, "group_dissolve", id, ip, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_dissolve", id, "ok", ip)
|
h.auditP(p, "group_dissolve", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,16 +302,16 @@ func (h *Handler) handleGroupAddMembers(w http.ResponseWriter, r *http.Request)
|
|||||||
MemberIDs []string `json:"member_ids"`
|
MemberIDs []string `json:"member_ids"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "group_add_members", id, "bad_request", ip)
|
h.auditP(p, "group_add_members", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.groupOwner(r.Context(), id); isNoRows(err) {
|
if _, err := h.groupOwner(r.Context(), id); isNoRows(err) {
|
||||||
h.audit(actorString(p), "group_add_members", id, "not_found", ip)
|
h.auditP(p, "group_add_members", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
h.audit(actorString(p), "group_add_members", id, "error", ip)
|
h.auditP(p, "group_add_members", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,14 @@ func (h *Handler) handleGroupAddMembers(w http.ResponseWriter, r *http.Request)
|
|||||||
if failed == nil {
|
if failed == nil {
|
||||||
failed = []group.MemberFail{}
|
failed = []group.MemberFail{}
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_add_members", id, "ok", ip)
|
okN := len(req.MemberIDs) - len(failed)
|
||||||
|
if okN < 0 {
|
||||||
|
okN = 0
|
||||||
|
}
|
||||||
|
h.auditPD(p, "group_add_members", id, batchAuditResult(okN, len(failed)), ip, map[string]any{
|
||||||
|
"members": req.MemberIDs,
|
||||||
|
"failed": failed,
|
||||||
|
})
|
||||||
httpx.WriteOK(w, map[string]any{"failed": failed})
|
httpx.WriteOK(w, map[string]any{"failed": failed})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +347,12 @@ func (h *Handler) handleGroupRemoveMember(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
owner, err := h.groupOwner(r.Context(), id)
|
owner, err := h.groupOwner(r.Context(), id)
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
h.audit(actorString(p), "group_remove_member", id, "not_found", ip)
|
h.auditP(p, "group_remove_member", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "group_remove_member", id, "error", ip)
|
h.auditP(p, "group_remove_member", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -357,7 +364,7 @@ func (h *Handler) handleGroupRemoveMember(w http.ResponseWriter, r *http.Request
|
|||||||
h.writeGroupErr(w, p, "group_remove_member", id, ip, err)
|
h.writeGroupErr(w, p, "group_remove_member", id, ip, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_remove_member", id+"/"+endpointID, "ok", ip)
|
h.auditP(p, "group_remove_member", id+"/"+endpointID, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,18 +380,18 @@ func (h *Handler) handleGroupTransfer(w http.ResponseWriter, r *http.Request) {
|
|||||||
EndpointID string `json:"endpoint_id"`
|
EndpointID string `json:"endpoint_id"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "group_transfer", id, "bad_request", ip)
|
h.auditP(p, "group_transfer", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
owner, err := h.groupOwner(r.Context(), id)
|
owner, err := h.groupOwner(r.Context(), id)
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
h.audit(actorString(p), "group_transfer", id, "not_found", ip)
|
h.auditP(p, "group_transfer", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "群不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "group_transfer", id, "error", ip)
|
h.auditP(p, "group_transfer", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -396,7 +403,7 @@ func (h *Handler) handleGroupTransfer(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeGroupErr(w, p, "group_transfer", id, ip, err)
|
h.writeGroupErr(w, p, "group_transfer", id, ip, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "group_transfer", id, "ok", ip)
|
h.auditPD(p, "group_transfer", id, "ok", ip, map[string]any{"new_owner": req.EndpointID})
|
||||||
httpx.WriteOK(w, map[string]any{"owner_id": req.EndpointID})
|
httpx.WriteOK(w, map[string]any{"owner_id": req.EndpointID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,11 +447,11 @@ func (h *Handler) writeGroupErr(w http.ResponseWriter, p principal, action, obje
|
|||||||
case protocol.CodeBusy:
|
case protocol.CodeBusy:
|
||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), action, object, pe.Code, ip)
|
h.auditP(p, action, object, pe.Code, ip)
|
||||||
httpx.WriteError(w, status, pe.Code, pe.Message)
|
httpx.WriteError(w, status, pe.Code, pe.Message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), action, object, "error", ip)
|
h.auditP(p, action, object, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package admin_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.asio.asia/nixevol/NixMsg/internal/admin"
|
||||||
|
"git.asio.asia/nixevol/NixMsg/internal/app/group"
|
||||||
|
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||||
|
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||||
|
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupH02(t *testing.T) (warnBuf, auditBuf *bytes.Buffer, db *store.DB, srv *httptest.Server, client *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
prev := slog.Default()
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
warnBuf = &bytes.Buffer{}
|
||||||
|
auditBuf = &bytes.Buffer{}
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(warnBuf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||||
|
auditLog := slog.New(slog.NewJSONHandler(auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
var err error
|
||||||
|
db, err = store.Open(filepath.Join(dir, "data"), "FULL")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
hash := auth.NewStubHashPool()
|
||||||
|
if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil {
|
||||||
|
t.Fatal(seedErr)
|
||||||
|
}
|
||||||
|
gApp := group.New(group.Config{DB: db, MaxGroupMembers: 100})
|
||||||
|
h := admin.New(admin.Deps{
|
||||||
|
DB: db,
|
||||||
|
Hash: hash,
|
||||||
|
Tokens: admin.NewRandomAPITokens(),
|
||||||
|
Locks: admin.NewMemoryLoginLocks(),
|
||||||
|
Logger: slog.Default(),
|
||||||
|
AuditLogger: auditLog,
|
||||||
|
Groups: gApp,
|
||||||
|
Config: config.Default(),
|
||||||
|
})
|
||||||
|
srv = httptest.NewServer(h)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client = &http.Client{Jar: jar}
|
||||||
|
return warnBuf, auditBuf, db, srv, client
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSlogJSON(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, line := range bytes.Split(buf.Bytes(), []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(line, &m); err != nil {
|
||||||
|
t.Fatalf("slog json: %v line=%s", err, line)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastAuditByAction(t *testing.T, recs []map[string]any, action string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var last map[string]any
|
||||||
|
for _, r := range recs {
|
||||||
|
if r["msg"] == "admin_audit" && r["action"] == action {
|
||||||
|
last = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
t.Fatalf("missing admin_audit action=%s recs=%v", action, recs)
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAuditCore(t *testing.T, rec map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
for _, k := range []string{"actor", "action", "result", "ip"} {
|
||||||
|
v, _ := rec[k].(string)
|
||||||
|
if v == "" {
|
||||||
|
t.Fatalf("missing %s in %v", k, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoSecrets(t *testing.T, raw string, secrets ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range secrets {
|
||||||
|
if s != "" && strings.Contains(raw, s) {
|
||||||
|
t.Fatalf("secret %q leaked in logs:\n%s", s, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH02AuditIndependentOfWarnLevel(t *testing.T) {
|
||||||
|
warnBuf, auditBuf, _, srv, client := setupH02(t)
|
||||||
|
login(t, client, srv.URL)
|
||||||
|
|
||||||
|
recs := parseSlogJSON(t, auditBuf)
|
||||||
|
loginRec := lastAuditByAction(t, recs, "login")
|
||||||
|
assertAuditCore(t, loginRec)
|
||||||
|
if loginRec["actor"] != "admin" {
|
||||||
|
t.Fatalf("actor=%v", loginRec["actor"])
|
||||||
|
}
|
||||||
|
if _, ok := loginRec["token_id"]; ok {
|
||||||
|
t.Fatalf("cookie login should omit token_id: %v", loginRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
warnRaw := warnBuf.String()
|
||||||
|
if strings.Contains(warnRaw, `"msg":"admin_audit"`) || strings.Contains(warnRaw, `"msg":"admin_auth_fail"`) {
|
||||||
|
t.Fatalf("warn logger should drop audit: %s", warnRaw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(auditBuf.String(), `"msg":"admin_audit"`) {
|
||||||
|
t.Fatalf("audit logger missing admin_audit: %s", auditBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH02AuthFailAndNoSecrets(t *testing.T) {
|
||||||
|
_, auditBuf, _, srv, client := setupH02(t)
|
||||||
|
wrong := "wrong-password-h02!!"
|
||||||
|
res := postJSON(t, client, srv.URL+"/api/admin/login",
|
||||||
|
`{"username":"admin","password":"`+wrong+`"}`, nil)
|
||||||
|
env := decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("want 401 got %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = doReq(t, client, http.MethodGet, srv.URL+"/api/admin/me", "",
|
||||||
|
map[string]string{"Authorization": "Bearer nxm_deadbeefdeadbeefdeadbeefdeadbeef"})
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad token want 401 got %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := parseSlogJSON(t, auditBuf)
|
||||||
|
var badPass, badTok bool
|
||||||
|
for _, r := range recs {
|
||||||
|
if r["msg"] != "admin_auth_fail" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r["reason"] == "bad_password" {
|
||||||
|
badPass = true
|
||||||
|
}
|
||||||
|
if r["reason"] == "invalid_token" {
|
||||||
|
badTok = true
|
||||||
|
}
|
||||||
|
if r["ip"] == "" {
|
||||||
|
t.Fatalf("admin_auth_fail missing ip: %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !badPass || !badTok {
|
||||||
|
t.Fatalf("want bad_password and invalid_token, recs=%v", recs)
|
||||||
|
}
|
||||||
|
assertNoSecrets(t, auditBuf.String(), wrong, testPassword, "nxm_deadbeefdeadbeefdeadbeefdeadbeef")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH02TokenFieldsDetailsAndNameLength(t *testing.T) {
|
||||||
|
_, auditBuf, db, srv, client := setupH02(t)
|
||||||
|
login(t, client, srv.URL)
|
||||||
|
insertEndpoint(t, db, "ep-h02", "admin", true, false)
|
||||||
|
|
||||||
|
res := postJSON(t, client, srv.URL+"/api/admin/tokens",
|
||||||
|
`{"name":"`+strings.Repeat("名", 65)+`"}`, csrfHeaders())
|
||||||
|
env := decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("65-char name want 400 got %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = postJSON(t, client, srv.URL+"/api/admin/tokens",
|
||||||
|
`{"name":"`+strings.Repeat("a", 64)+`"}`, csrfHeaders())
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("64-char name: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokClient := &http.Client{}
|
||||||
|
hdr := map[string]string{"Authorization": "Bearer " + created.Token}
|
||||||
|
res = doReq(t, tokClient, http.MethodPatch, srv.URL+"/api/admin/endpoints/ep-h02",
|
||||||
|
`{"enabled":false}`, hdr)
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("token patch: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = doReq(t, client, http.MethodPatch, srv.URL+"/api/admin/tokens/"+created.ID,
|
||||||
|
`{"enabled":false}`,
|
||||||
|
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("disable token: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
regCode := "h02-reg-code-secret"
|
||||||
|
res = doReq(t, client, http.MethodPut, srv.URL+"/api/admin/registration",
|
||||||
|
`{"enabled":true,"code":"`+regCode+`"}`,
|
||||||
|
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("registration: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := parseSlogJSON(t, auditBuf)
|
||||||
|
patch := lastAuditByAction(t, recs, "endpoint_patch")
|
||||||
|
assertAuditCore(t, patch)
|
||||||
|
if patch["token_id"] != created.ID {
|
||||||
|
t.Fatalf("token_id=%v want %s", patch["token_id"], created.ID)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(patch["actor"].(string), "token:") {
|
||||||
|
t.Fatalf("actor=%v", patch["actor"])
|
||||||
|
}
|
||||||
|
detail, _ := patch["detail"].(map[string]any)
|
||||||
|
if detail == nil || detail["enabled"] != false {
|
||||||
|
t.Fatalf("endpoint_patch detail=%v", patch["detail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
tokUpd := lastAuditByAction(t, recs, "token_update")
|
||||||
|
td, _ := tokUpd["detail"].(map[string]any)
|
||||||
|
if td == nil || td["enabled"] != false {
|
||||||
|
t.Fatalf("token_update detail=%v", tokUpd["detail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := lastAuditByAction(t, recs, "registration_update")
|
||||||
|
rd, _ := reg["detail"].(map[string]any)
|
||||||
|
if rd == nil || rd["enabled"] != true || rd["code_changed"] != true {
|
||||||
|
t.Fatalf("registration detail=%v", reg["detail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoSecrets(t, auditBuf.String(), created.Token, regCode, testPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH02BatchPartialImportAndGroups(t *testing.T) {
|
||||||
|
_, auditBuf, db, srv, client := setupH02(t)
|
||||||
|
login(t, client, srv.URL)
|
||||||
|
insertEndpoint(t, db, "keep-1", "admin", true, false)
|
||||||
|
insertEndpoint(t, db, "alice", "admin", true, false)
|
||||||
|
insertEndpoint(t, db, "bob", "admin", true, false)
|
||||||
|
|
||||||
|
res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/endpoints/batch",
|
||||||
|
`{"ids":["keep-1","missing-ep"],"action":"disable"}`,
|
||||||
|
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
|
||||||
|
env := decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("batch: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBody := "" +
|
||||||
|
"id,name,login_password,talk_password,default_delay_seconds,remark\n" +
|
||||||
|
"imp-1,甲,csv-pass-secret-1,,0,\n" +
|
||||||
|
"imp-2,乙,csv-pass-secret-2,,0,\n"
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/admin/endpoints/import", strings.NewReader(csvBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "text/csv")
|
||||||
|
req.Header.Set("X-Nixmsg-Request", "1")
|
||||||
|
res, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("import: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups",
|
||||||
|
`{"name":"一组","owner_id":"alice","member_ids":["bob"]}`, csrf())
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("group create: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members",
|
||||||
|
`{"member_ids":["keep-1","ghost-ep"]}`, csrf())
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("add members: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/transfer",
|
||||||
|
`{"endpoint_id":"bob"}`, csrf())
|
||||||
|
env = decodeEnv(t, res)
|
||||||
|
if res.StatusCode != http.StatusOK || !env.OK {
|
||||||
|
t.Fatalf("transfer: %d %+v", res.StatusCode, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := parseSlogJSON(t, auditBuf)
|
||||||
|
batch := lastAuditByAction(t, recs, "endpoint_batch_disable")
|
||||||
|
assertAuditCore(t, batch)
|
||||||
|
if batch["result"] != "partial" {
|
||||||
|
t.Fatalf("batch result=%v rec=%v", batch["result"], batch)
|
||||||
|
}
|
||||||
|
bd, _ := batch["detail"].(map[string]any)
|
||||||
|
if bd == nil {
|
||||||
|
t.Fatalf("batch missing detail: %v", batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
imp := lastAuditByAction(t, recs, "endpoint_import")
|
||||||
|
id, _ := imp["detail"].(map[string]any)
|
||||||
|
if id == nil {
|
||||||
|
t.Fatalf("import missing detail: %v", imp)
|
||||||
|
}
|
||||||
|
if id["total"] != float64(2) {
|
||||||
|
t.Fatalf("import total=%v", id["total"])
|
||||||
|
}
|
||||||
|
ids, _ := id["ids"].([]any)
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Fatalf("import ids=%v", id["ids"])
|
||||||
|
}
|
||||||
|
|
||||||
|
add := lastAuditByAction(t, recs, "group_add_members")
|
||||||
|
ad, _ := add["detail"].(map[string]any)
|
||||||
|
if ad == nil {
|
||||||
|
t.Fatalf("add members missing detail: %v", add)
|
||||||
|
}
|
||||||
|
if add["result"] != "partial" && add["result"] != "failed" && add["result"] != "ok" {
|
||||||
|
t.Fatalf("add members result=%v", add["result"])
|
||||||
|
}
|
||||||
|
if _, ok := ad["members"]; !ok {
|
||||||
|
t.Fatalf("add members missing members: %v", ad)
|
||||||
|
}
|
||||||
|
if _, ok := ad["failed"]; !ok {
|
||||||
|
t.Fatalf("add members missing failed: %v", ad)
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := lastAuditByAction(t, recs, "group_transfer")
|
||||||
|
td, _ := tr["detail"].(map[string]any)
|
||||||
|
if td == nil || td["new_owner"] != "bob" {
|
||||||
|
t.Fatalf("transfer detail=%v", tr["detail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoSecrets(t, auditBuf.String(), "csv-pass-secret-1", "csv-pass-secret-2", testPassword)
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ type Deps struct {
|
|||||||
Tokens auth.APITokens
|
Tokens auth.APITokens
|
||||||
Locks auth.LoginLocks
|
Locks auth.LoginLocks
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
// AuditLogger 独立审计日志,始终 Info,不受 log.level 影响;nil 时使用独立 JSON Info 处理器。
|
||||||
|
AuditLogger *slog.Logger
|
||||||
|
|
||||||
// TrustedProxies 受信任代理网段。
|
// TrustedProxies 受信任代理网段。
|
||||||
TrustedProxies []*net.IPNet
|
TrustedProxies []*net.IPNet
|
||||||
@@ -74,6 +76,7 @@ type Handler struct {
|
|||||||
tokens auth.APITokens
|
tokens auth.APITokens
|
||||||
locks auth.LoginLocks
|
locks auth.LoginLocks
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
auditLog *slog.Logger
|
||||||
trusted []*net.IPNet
|
trusted []*net.IPNet
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
forceSec bool
|
forceSec bool
|
||||||
@@ -98,6 +101,9 @@ func New(d Deps) *Handler {
|
|||||||
if d.Logger == nil {
|
if d.Logger == nil {
|
||||||
d.Logger = slog.Default()
|
d.Logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
if d.AuditLogger == nil {
|
||||||
|
d.AuditLogger = defaultAuditLogger()
|
||||||
|
}
|
||||||
if d.Locks == nil {
|
if d.Locks == nil {
|
||||||
d.Locks = NewMemoryLoginLocks()
|
d.Locks = NewMemoryLoginLocks()
|
||||||
}
|
}
|
||||||
@@ -119,6 +125,7 @@ func New(d Deps) *Handler {
|
|||||||
tokens: d.Tokens,
|
tokens: d.Tokens,
|
||||||
locks: d.Locks,
|
locks: d.Locks,
|
||||||
log: d.Logger,
|
log: d.Logger,
|
||||||
|
auditLog: d.AuditLogger,
|
||||||
trusted: d.TrustedProxies,
|
trusted: d.TrustedProxies,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
forceSec: d.SecureCookies,
|
forceSec: d.SecureCookies,
|
||||||
|
|||||||
+14
-11
@@ -26,6 +26,7 @@ func (h *Handler) setSessionCookie(w http.ResponseWriter, r *http.Request, value
|
|||||||
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := httpx.ClientIP(r, h.trusted)
|
ip := httpx.ClientIP(r, h.trusted)
|
||||||
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
||||||
|
h.auditAuthFail(ip, "rate_limited")
|
||||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||||
return
|
return
|
||||||
@@ -78,10 +79,12 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) failLogin(w http.ResponseWriter, ip string) {
|
func (h *Handler) failLogin(w http.ResponseWriter, ip string) {
|
||||||
locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
||||||
if locked {
|
if locked {
|
||||||
|
h.auditAuthFail(ip, "rate_limited")
|
||||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditAuthFail(ip, "bad_password")
|
||||||
httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "用户名或密码错误")
|
httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "用户名或密码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = h.deleteSession(r.Context(), hashSessionHex(p.Session))
|
_ = h.deleteSession(r.Context(), hashSessionHex(p.Session))
|
||||||
}
|
}
|
||||||
h.setSessionCookie(w, r, "", -1)
|
h.setSessionCookie(w, r, "", -1)
|
||||||
h.audit(actorString(p), "logout", "", "ok", ip)
|
h.auditP(p, "logout", "", "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
||||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||||
h.audit(actorString(p), "password_change", "", "rate_limited", ip)
|
h.auditP(p, "password_change", "", "rate_limited", ip)
|
||||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,19 +127,19 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "password_change", "", "bad_request", ip)
|
h.auditP(p, "password_change", "", "bad_request", ip)
|
||||||
writeDecodeError(w, err)
|
writeDecodeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(req.NewPassword) < minPasswordLen {
|
if utf8.RuneCountInString(req.NewPassword) < minPasswordLen {
|
||||||
h.audit(actorString(p), "password_change", "", "bad_request", ip)
|
h.auditP(p, "password_change", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
phc, err := h.getAdminPasswordHash(r.Context())
|
phc, err := h.getAdminPasswordHash(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
h.auditP(p, "password_change", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,34 +148,34 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
||||||
if locked {
|
if locked {
|
||||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||||
h.audit(actorString(p), "password_change", "", "rate_limited", ip)
|
h.auditP(p, "password_change", "", "rate_limited", ip)
|
||||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "password_change", "", "unauthorized", ip)
|
h.auditP(p, "password_change", "", "unauthorized", ip)
|
||||||
httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误")
|
httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip})
|
||||||
newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword)
|
newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
h.auditP(p, "password_change", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.setAdminPasswordHash(r.Context(), newPHC); err != nil {
|
if err := h.setAdminPasswordHash(r.Context(), newPHC); err != nil {
|
||||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
h.auditP(p, "password_change", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 保留当前会话,作废其它会话;失败则返回 500,避免其它会话继续有效。
|
// 保留当前会话,作废其它会话;失败则返回 500,避免其它会话继续有效。
|
||||||
if p.Session != "" {
|
if p.Session != "" {
|
||||||
if err := h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)); err != nil {
|
if err := h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)); err != nil {
|
||||||
h.audit(actorString(p), "password_change", "", "error", ip)
|
h.auditP(p, "password_change", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "password_change", "", "ok", ip)
|
h.auditP(p, "password_change", "", "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ func (h *Handler) handleRegistrationPut(w http.ResponseWriter, r *http.Request)
|
|||||||
Generate bool `json:"generate"`
|
Generate bool `json:"generate"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "registration_update", "", "bad_request", ip)
|
h.auditP(p, "registration_update", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Enabled == nil && req.Code == nil && !req.Generate {
|
if req.Enabled == nil && req.Code == nil && !req.Generate {
|
||||||
h.audit(actorString(p), "registration_update", "", "bad_request", ip)
|
h.auditP(p, "registration_update", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "至少提供一项更新")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "至少提供一项更新")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,23 +108,33 @@ func (h *Handler) handleRegistrationPut(w http.ResponseWriter, r *http.Request)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
var br badRequestError
|
var br badRequestError
|
||||||
if errors.As(err, &br) {
|
if errors.As(err, &br) {
|
||||||
h.audit(actorString(p), "registration_update", "", "bad_request", ip)
|
h.auditP(p, "registration_update", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", string(br))
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", string(br))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "registration_update", "", "error", ip)
|
h.auditP(p, "registration_update", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.loadRegistration(r.Context())
|
data, err := h.loadRegistration(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "registration_update", "", "error", ip)
|
h.auditP(p, "registration_update", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 审计不写安全码明文
|
// 审计不写安全码明文
|
||||||
h.audit(actorString(p), "registration_update", "", "ok", ip)
|
detail := map[string]any{}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
detail["enabled"] = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.Generate {
|
||||||
|
detail["generated"] = true
|
||||||
|
}
|
||||||
|
if req.Code != nil {
|
||||||
|
detail["code_changed"] = true
|
||||||
|
}
|
||||||
|
h.auditPD(p, "registration_update", "", "ok", ip, detail)
|
||||||
httpx.WriteOK(w, data)
|
httpx.WriteOK(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-18
@@ -7,10 +7,21 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minTokenNameChars = 1
|
||||||
|
maxTokenNameChars = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
func validTokenName(name string) bool {
|
||||||
|
n := utf8.RuneCountInString(name)
|
||||||
|
return n >= minTokenNameChars && n <= maxTokenNameChars
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleTokenList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleTokenList(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := h.listAPITokens(r.Context())
|
items, err := h.listAPITokens(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,32 +56,37 @@ func (h *Handler) handleTokenCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil || strings.TrimSpace(req.Name) == "" {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "token_create", "", "bad_request", ip)
|
h.auditP(p, "token_create", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if !validTokenName(name) {
|
||||||
|
h.auditP(p, "token_create", "", "bad_request", ip)
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称须为 1–64 字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
plain, hash, err := h.tokens.Issue(r.Context())
|
plain, hash, err := h.tokens.Issue(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "token_create", "", "error", ip)
|
h.auditP(p, "token_create", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := newTokenID()
|
id, err := newTokenID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "token_create", "", "error", ip)
|
h.auditP(p, "token_create", "", "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created, err := h.insertAPIToken(r.Context(), id, name, hash)
|
created, err := h.insertAPIToken(r.Context(), id, name, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "token_create", id, "error", ip)
|
h.auditP(p, "token_create", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "token_create", id, "ok", ip)
|
h.auditP(p, "token_create", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{
|
httpx.WriteOK(w, map[string]any{
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -89,20 +105,20 @@ func (h *Handler) handleTokenPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||||
h.audit(actorString(p), "token_update", id, "bad_request", ip)
|
h.auditP(p, "token_update", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name == nil && req.Enabled == nil {
|
if req.Name == nil && req.Enabled == nil {
|
||||||
h.audit(actorString(p), "token_update", id, "bad_request", ip)
|
h.auditP(p, "token_update", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "无更新字段")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "无更新字段")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
n := strings.TrimSpace(*req.Name)
|
n := strings.TrimSpace(*req.Name)
|
||||||
if n == "" {
|
if !validTokenName(n) {
|
||||||
h.audit(actorString(p), "token_update", id, "bad_request", ip)
|
h.auditP(p, "token_update", id, "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称须为 1–64 字符")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Name = &n
|
req.Name = &n
|
||||||
@@ -110,21 +126,28 @@ func (h *Handler) handleTokenPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err := h.updateAPIToken(r.Context(), id, req.Name, req.Enabled); err != nil {
|
if err := h.updateAPIToken(r.Context(), id, req.Name, req.Enabled); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.audit(actorString(p), "token_update", id, "not_found", ip)
|
h.auditP(p, "token_update", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "token_update", id, "error", ip)
|
h.auditP(p, "token_update", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := h.getAPITokenByID(r.Context(), id)
|
row, err := h.getAPITokenByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.audit(actorString(p), "token_update", id, "error", ip)
|
h.auditP(p, "token_update", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "token_update", id, "ok", ip)
|
detail := map[string]any{}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
detail["enabled"] = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
detail["name_changed"] = true
|
||||||
|
}
|
||||||
|
h.auditPD(p, "token_update", id, "ok", ip, detail)
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"name": row.Name,
|
"name": row.Name,
|
||||||
@@ -144,15 +167,15 @@ func (h *Handler) handleTokenDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if err := h.deleteAPIToken(r.Context(), id); err != nil {
|
if err := h.deleteAPIToken(r.Context(), id); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.audit(actorString(p), "token_delete", id, "not_found", ip)
|
h.auditP(p, "token_delete", id, "not_found", ip)
|
||||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在")
|
httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "token_delete", id, "error", ip)
|
h.auditP(p, "token_delete", id, "error", ip)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.audit(actorString(p), "token_delete", id, "ok", ip)
|
h.auditP(p, "token_delete", id, "ok", ip)
|
||||||
httpx.WriteOK(w, map[string]any{})
|
httpx.WriteOK(w, map[string]any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user