fix: 独立审计日志并补齐令牌身份与失败记录

This commit is contained in:
Nixevol
2026-09-30 16:22:49 +08:00
parent eb4e2db918
commit b7c8b6ffd6
12 changed files with 641 additions and 124 deletions
+81 -7
View File
@@ -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)}
}
+6 -4
View File
@@ -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
}
+56 -45
View File
@@ -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{})
}
+11 -7
View File
@@ -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})
}
+33 -26
View File
@@ -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", "内部错误")
}
+366
View File
@@ -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)
}
+7
View File
@@ -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,
+14 -11
View File
@@ -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{})
}
+16 -6
View File
@@ -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)
}
+41 -18
View File
@@ -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{})
}