From b7c8b6ffd683d5ace543abfeaec6934dcb1b1e5d Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 15:31:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=8B=AC=E7=AB=8B=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=B9=B6=E8=A1=A5=E9=BD=90=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=A4=B1=E8=B4=A5=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/nixmsg/serve.go | 1 + docs/DEVIATIONS.md | 9 + internal/admin/audit.go | 88 +++++++- internal/admin/auth.go | 10 +- internal/admin/endpoints.go | 101 +++++---- internal/admin/endpoints_csv.go | 18 +- internal/admin/groups.go | 59 ++--- internal/admin/h02_test.go | 366 ++++++++++++++++++++++++++++++++ internal/admin/handler.go | 7 + internal/admin/login.go | 25 ++- internal/admin/registration.go | 22 +- internal/admin/tokens.go | 59 +++-- 12 files changed, 641 insertions(+), 124 deletions(-) create mode 100644 internal/admin/h02_test.go diff --git a/cmd/nixmsg/serve.go b/cmd/nixmsg/serve.go index 6dd0985..ebadc52 100644 --- a/cmd/nixmsg/serve.go +++ b/cmd/nixmsg/serve.go @@ -193,6 +193,7 @@ func runServe(ctx context.Context, cfg config.Config) error { Tokens: apiTokens, Locks: loginLocks, Logger: slog.Default(), + AuditLogger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})), TrustedProxies: trustedNets, Identity: idApp, Groups: groupApp, diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 00a7592..292b714 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -935,6 +935,15 @@ - 备选方案:改密失败不返回 401(与契约不符);未采用。W-01 排除改密 401 自动登出。 - 影响:连续 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 1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`** diff --git a/internal/admin/audit.go b/internal/admin/audit.go index 41dcf6e..079f55e 100644 --- a/internal/admin/audit.go +++ b/internal/admin/audit.go @@ -1,12 +1,86 @@ 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 写结构化操作日志;不写密码、令牌和正文。 func (h *Handler) audit(actor, action, object, result, ip string) { - h.log.Info("admin_audit", - "actor", actor, - "action", action, - "object", object, - "result", result, - "ip", ip, - ) + h.auditRec(auditRec{Actor: actor, Action: action, Object: object, Result: result, IP: ip}) +} + +func (h *Handler) auditP(p principal, action, object, result, ip string) { + h.auditPD(p, action, object, result, ip, nil) +} + +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)} } diff --git a/internal/admin/auth.go b/internal/admin/auth.go index 37c7c5b..6d01639 100644 --- a/internal/admin/auth.go +++ b/internal/admin/auth.go @@ -92,18 +92,18 @@ func (h *Handler) authenticate(r *http.Request, ip string) (principal, string, s if strings.HasPrefix(strings.ToLower(authz), "bearer ") { raw := strings.TrimSpace(authz[len("Bearer "):]) if raw == "" || !h.tokens.LooksLikeAPIToken(raw) { - return authFail(h, ip) + return h.authFail(ip) } hash := h.tokens.HashToken(raw) info, err := h.lookupAPIToken(r.Context(), hash) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return authFail(h, ip) + return h.authFail(ip) } return principal{}, "internal", "内部错误", http.StatusInternalServerError } if !info.Enabled { - return authFail(h, ip) + return h.authFail(ip) } h.touchLastUsed(r.Context(), info.ID) 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 } -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 { + h.auditAuthFail(ip, "rate_limited") return principal{}, "rate_limited", "登录已锁定,请稍后再试", http.StatusTooManyRequests } + h.auditAuthFail(ip, "invalid_token") return principal{}, "unauthorized", "令牌无效", http.StatusUnauthorized } diff --git a/internal/admin/endpoints.go b/internal/admin/endpoints.go index 5e466cd..342b3cd 100644 --- a/internal/admin/endpoints.go +++ b/internal/admin/endpoints.go @@ -225,7 +225,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) { DefaultDelaySeconds *int64 `json:"default_delay_seconds"` } 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", "请求体无效") return } @@ -234,7 +234,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) { delaySec = *req.DefaultDelaySeconds } 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) return } @@ -245,7 +245,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) { if loginPW == "" { pw, err := generateLoginPassword() 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", "内部错误") 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) 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", "内部错误") return } @@ -262,7 +262,7 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) { if req.TalkPassword != "" { th, hashErr := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword) 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", "内部错误") return } @@ -280,15 +280,15 @@ func (h *Handler) handleEndpointCreate(w http.ResponseWriter, r *http.Request) { }) if err != nil { 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", "编号已占用") return } - h.audit(actorString(p), "endpoint_create", id, "error", ip) + h.auditP(p, "endpoint_create", id, "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } - h.audit(actorString(p), "endpoint_create", createdID, "ok", ip) + h.auditP(p, "endpoint_create", createdID, "ok", ip) data := map[string]any{"id": createdID} if pwGenerated { data[loginPasswordOnceKey] = loginPW @@ -322,28 +322,28 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) { Enabled *bool `json:"enabled"` } 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", "请求体无效") return } 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", "无更新字段") return } 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", "名称不合法") return } 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", "备注过长") return } if req.DefaultDelaySeconds != nil { 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) 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) if err != nil { 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", "端不存在") return } - h.audit(actorString(p), "endpoint_patch", id, "error", ip) + h.auditP(p, "endpoint_patch", id, "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } @@ -374,12 +374,12 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) { var found bool found, err = h.setEndpointEnabled(r.Context(), id, *req.Enabled) 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", "内部错误") return } 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", "端不存在") return } @@ -391,11 +391,19 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) { } row, err := h.getEndpoint(r.Context(), id) 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", "内部错误") 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)) } @@ -406,17 +414,17 @@ func (h *Handler) handleEndpointDelete(w http.ResponseWriter, r *http.Request) { ok, err := h.deleteEndpointBasic(r.Context(), id) 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", "内部错误") return } 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", "端不存在") return } 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{}) } @@ -429,14 +437,14 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) { Action string `json:"action"` } 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", "请求体无效") return } switch req.Action { case "disable", "enable", "delete": 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 无效") return } @@ -470,7 +478,10 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) { } 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}) } @@ -481,22 +492,22 @@ func (h *Handler) handleEndpointKick(w http.ResponseWriter, r *http.Request) { exists, err := h.endpointExists(r.Context(), id) 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", "内部错误") return } 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", "端不存在") return } kicked, err := h.kickEndpoint(r.Context(), id) 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", "内部错误") 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}) } @@ -509,12 +520,12 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt LoginPassword string `json:"login_password"` } 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", "请求体无效") return } 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", "登录密码不合法") return } @@ -523,7 +534,7 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt if pw == "" { gen, err := generateLoginPassword() 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", "内部错误") return } @@ -531,23 +542,23 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt } hash, err := h.hash.Hash(r.Context(), auth.PasswordLogin, pw) 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", "内部错误") return } ok, err := h.resetLoginPassword(r.Context(), id, hash) 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", "内部错误") return } 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", "端不存在") return } _, _ = 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}) } @@ -560,12 +571,12 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ TalkPassword string `json:"talk_password"` } 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", "请求体无效") return } 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", "对话密码不合法") return } @@ -574,7 +585,7 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ if req.TalkPassword != "" { th, err := h.hash.Hash(r.Context(), auth.PasswordTalk, req.TalkPassword) 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", "内部错误") return } @@ -582,16 +593,16 @@ func (h *Handler) handleEndpointTalkPassword(w http.ResponseWriter, r *http.Requ } ok, err := h.setTalkPassword(r.Context(), id, talkHash) 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", "内部错误") return } 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", "端不存在") 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}) } @@ -602,17 +613,17 @@ func (h *Handler) handleEndpointUnlock(w http.ResponseWriter, r *http.Request) { exists, err := h.endpointExists(r.Context(), id) 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", "内部错误") return } 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", "端不存在") return } 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{}) } diff --git a/internal/admin/endpoints_csv.go b/internal/admin/endpoints_csv.go index bf9844f..88677ba 100644 --- a/internal/admin/endpoints_csv.go +++ b/internal/admin/endpoints_csv.go @@ -41,18 +41,18 @@ func (h *Handler) handleEndpointImport(w http.ResponseWriter, r *http.Request) { raw, err := readImportCSV(r) if err != nil { 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", "请求体过大") 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()) return } prepared, errs, fatal := h.validateImportCSV(r.Context(), raw) 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) { httpx.WriteError(w, http.StatusServiceUnavailable, "busy", "哈希繁忙,请稍后再试") return @@ -61,7 +61,7 @@ func (h *Handler) handleEndpointImport(w http.ResponseWriter, r *http.Request) { return } if len(errs) > 0 { - h.audit(actorString(p), "endpoint_import", "", "bad_request", ip) + h.auditP(p, "endpoint_import", "", "bad_request", ip) writeCSVValidationError(w, errs) 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 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)) return } - h.audit(actorString(p), "endpoint_import", "", "error", ip) + h.auditP(p, "endpoint_import", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") 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}) } diff --git a/internal/admin/groups.go b/internal/admin/groups.go index c8eb577..778b6e6 100644 --- a/internal/admin/groups.go +++ b/internal/admin/groups.go @@ -100,23 +100,23 @@ func (h *Handler) handleGroupCreate(w http.ResponseWriter, r *http.Request) { MemberIDs []string `json:"member_ids"` } 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", "请求体无效") return } if strings.TrimSpace(req.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,由服务器生成") return } 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", "名称不合法") return } 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") 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) return } - h.audit(actorString(p), "group_create", res.ID, "ok", ip) + h.auditP(p, "group_create", res.ID, "ok", ip) failed := res.Failed if failed == nil { failed = []group.MemberFail{} @@ -227,18 +227,18 @@ func (h *Handler) handleGroupRename(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` } 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", "请求体无效") return } owner, err := h.groupOwner(r.Context(), id) 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", "群不存在") return } 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", "内部错误") return } @@ -252,11 +252,11 @@ func (h *Handler) handleGroupRename(w http.ResponseWriter, r *http.Request) { } summary, err := h.groupSummary(r.Context(), id) 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", "内部错误") return } - h.audit(actorString(p), "group_rename", id, "ok", ip) + h.auditP(p, "group_rename", id, "ok", ip) 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) 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", "群不存在") return } 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", "内部错误") return } @@ -286,7 +286,7 @@ func (h *Handler) handleGroupDissolve(w http.ResponseWriter, r *http.Request) { h.writeGroupErr(w, p, "group_dissolve", id, ip, err) return } - h.audit(actorString(p), "group_dissolve", id, "ok", ip) + h.auditP(p, "group_dissolve", id, "ok", ip) 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"` } 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", "请求体无效") return } 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", "群不存在") return } 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", "内部错误") return } @@ -325,7 +325,14 @@ func (h *Handler) handleGroupAddMembers(w http.ResponseWriter, r *http.Request) if failed == nil { 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}) } @@ -340,12 +347,12 @@ func (h *Handler) handleGroupRemoveMember(w http.ResponseWriter, r *http.Request owner, err := h.groupOwner(r.Context(), id) 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", "群不存在") return } 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", "内部错误") 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) 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{}) } @@ -373,18 +380,18 @@ func (h *Handler) handleGroupTransfer(w http.ResponseWriter, r *http.Request) { EndpointID string `json:"endpoint_id"` } 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", "请求体无效") return } owner, err := h.groupOwner(r.Context(), id) 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", "群不存在") return } 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", "内部错误") return } @@ -396,7 +403,7 @@ func (h *Handler) handleGroupTransfer(w http.ResponseWriter, r *http.Request) { h.writeGroupErr(w, p, "group_transfer", id, ip, err) 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}) } @@ -440,11 +447,11 @@ func (h *Handler) writeGroupErr(w http.ResponseWriter, p principal, action, obje case protocol.CodeBusy: 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) return } - h.audit(actorString(p), action, object, "error", ip) + h.auditP(p, action, object, "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") } diff --git a/internal/admin/h02_test.go b/internal/admin/h02_test.go new file mode 100644 index 0000000..7f6d9af --- /dev/null +++ b/internal/admin/h02_test.go @@ -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) +} diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 6787226..2a181f5 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -41,6 +41,8 @@ type Deps struct { Tokens auth.APITokens Locks auth.LoginLocks Logger *slog.Logger + // AuditLogger 独立审计日志,始终 Info,不受 log.level 影响;nil 时使用独立 JSON Info 处理器。 + AuditLogger *slog.Logger // TrustedProxies 受信任代理网段。 TrustedProxies []*net.IPNet @@ -74,6 +76,7 @@ type Handler struct { tokens auth.APITokens locks auth.LoginLocks log *slog.Logger + auditLog *slog.Logger trusted []*net.IPNet ttl time.Duration forceSec bool @@ -98,6 +101,9 @@ func New(d Deps) *Handler { if d.Logger == nil { d.Logger = slog.Default() } + if d.AuditLogger == nil { + d.AuditLogger = defaultAuditLogger() + } if d.Locks == nil { d.Locks = NewMemoryLoginLocks() } @@ -119,6 +125,7 @@ func New(d Deps) *Handler { tokens: d.Tokens, locks: d.Locks, log: d.Logger, + auditLog: d.AuditLogger, trusted: d.TrustedProxies, ttl: ttl, forceSec: d.SecureCookies, diff --git a/internal/admin/login.go b/internal/admin/login.go index 6f8140f..9927aa4 100644 --- a/internal/admin/login.go +++ b/internal/admin/login.go @@ -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) { ip := httpx.ClientIP(r, h.trusted) 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)) httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") return @@ -78,10 +79,12 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { func (h *Handler) failLogin(w http.ResponseWriter, ip string) { locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) if locked { + h.auditAuthFail(ip, "rate_limited") w.Header().Set("Retry-After", formatRetryAfter(retry)) httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") return } + h.auditAuthFail(ip, "bad_password") 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.setSessionCookie(w, r, "", -1) - h.audit(actorString(p), "logout", "", "ok", ip) + h.auditP(p, "logout", "", "ok", ip) 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 { 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", "登录已锁定,请稍后再试") return } @@ -124,19 +127,19 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { NewPassword string `json:"new_password"` } 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) return } 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 位") return } phc, err := h.getAdminPasswordHash(r.Context()) if err != nil { - h.audit(actorString(p), "password_change", "", "error", ip) + h.auditP(p, "password_change", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") 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}) if locked { 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", "登录已锁定,请稍后再试") return } - h.audit(actorString(p), "password_change", "", "unauthorized", ip) + h.auditP(p, "password_change", "", "unauthorized", ip) httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误") return } h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword) if err != nil { - h.audit(actorString(p), "password_change", "", "error", ip) + h.auditP(p, "password_change", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } 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", "内部错误") return } // 保留当前会话,作废其它会话;失败则返回 500,避免其它会话继续有效。 if p.Session != "" { 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", "内部错误") return } } - h.audit(actorString(p), "password_change", "", "ok", ip) + h.auditP(p, "password_change", "", "ok", ip) httpx.WriteOK(w, map[string]any{}) } diff --git a/internal/admin/registration.go b/internal/admin/registration.go index c71415f..22104f5 100644 --- a/internal/admin/registration.go +++ b/internal/admin/registration.go @@ -40,12 +40,12 @@ func (h *Handler) handleRegistrationPut(w http.ResponseWriter, r *http.Request) Generate bool `json:"generate"` } 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", "请求体无效") return } 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", "至少提供一项更新") return } @@ -108,23 +108,33 @@ func (h *Handler) handleRegistrationPut(w http.ResponseWriter, r *http.Request) if err != nil { var br badRequestError 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)) return } - h.audit(actorString(p), "registration_update", "", "error", ip) + h.auditP(p, "registration_update", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } data, err := h.loadRegistration(r.Context()) if err != nil { - h.audit(actorString(p), "registration_update", "", "error", ip) + h.auditP(p, "registration_update", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") 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) } diff --git a/internal/admin/tokens.go b/internal/admin/tokens.go index 68dbde9..c8871d4 100644 --- a/internal/admin/tokens.go +++ b/internal/admin/tokens.go @@ -7,10 +7,21 @@ import ( "errors" "net/http" "strings" + "unicode/utf8" "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) { items, err := h.listAPITokens(r.Context()) if err != nil { @@ -45,32 +56,37 @@ func (h *Handler) handleTokenCreate(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } - if err := httpx.DecodeJSON(r, &req); err != nil || strings.TrimSpace(req.Name) == "" { - h.audit(actorString(p), "token_create", "", "bad_request", ip) + if err := httpx.DecodeJSON(r, &req); err != nil { + h.auditP(p, "token_create", "", "bad_request", ip) httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空") return } 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()) if err != nil { - h.audit(actorString(p), "token_create", "", "error", ip) + h.auditP(p, "token_create", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } id, err := newTokenID() if err != nil { - h.audit(actorString(p), "token_create", "", "error", ip) + h.auditP(p, "token_create", "", "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } created, err := h.insertAPIToken(r.Context(), id, name, hash) 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", "内部错误") return } - h.audit(actorString(p), "token_create", id, "ok", ip) + h.auditP(p, "token_create", id, "ok", ip) httpx.WriteOK(w, map[string]any{ "id": id, "name": name, @@ -89,20 +105,20 @@ func (h *Handler) handleTokenPatch(w http.ResponseWriter, r *http.Request) { Enabled *bool `json:"enabled"` } 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", "请求体无效") return } 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", "无更新字段") return } if req.Name != nil { n := strings.TrimSpace(*req.Name) - if n == "" { - h.audit(actorString(p), "token_update", id, "bad_request", ip) - httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空") + if !validTokenName(n) { + h.auditP(p, "token_update", id, "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称须为 1–64 字符") return } 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 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", "令牌不存在") return } - h.audit(actorString(p), "token_update", id, "error", ip) + h.auditP(p, "token_update", id, "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } row, err := h.getAPITokenByID(r.Context(), id) 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", "内部错误") 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{ "id": row.ID, "name": row.Name, @@ -144,15 +167,15 @@ func (h *Handler) handleTokenDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.deleteAPIToken(r.Context(), id); err != nil { 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", "令牌不存在") return } - h.audit(actorString(p), "token_delete", id, "error", ip) + h.auditP(p, "token_delete", id, "error", ip) httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") return } - h.audit(actorString(p), "token_delete", id, "ok", ip) + h.auditP(p, "token_delete", id, "ok", ip) httpx.WriteOK(w, map[string]any{}) }