Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
package admin
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// AuditLogHandler 操作审计日志管理接口。
|
|
// 审计日志仅管理员可见;不提供单条删除,仅支持带 TOTP 验证的全量清空。
|
|
type AuditLogHandler struct {
|
|
auditService *service.AuditLogService
|
|
totpService *service.TotpService
|
|
}
|
|
|
|
// NewAuditLogHandler 创建审计日志处理器。
|
|
func NewAuditLogHandler(auditService *service.AuditLogService, totpService *service.TotpService) *AuditLogHandler {
|
|
return &AuditLogHandler{
|
|
auditService: auditService,
|
|
totpService: totpService,
|
|
}
|
|
}
|
|
|
|
// List 分页查询审计日志。
|
|
// GET /api/v1/admin/audit-logs
|
|
func (h *AuditLogHandler) List(c *gin.Context) {
|
|
page, pageSize := response.ParsePagination(c)
|
|
if pageSize > 200 {
|
|
pageSize = 200
|
|
}
|
|
|
|
filter := &service.AuditLogFilter{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
ActorEmail: strings.TrimSpace(c.Query("actor_email")),
|
|
AuthMethod: strings.TrimSpace(c.Query("auth_method")),
|
|
Action: strings.TrimSpace(c.Query("action")),
|
|
Method: strings.TrimSpace(c.Query("method")),
|
|
ClientIP: strings.TrimSpace(c.Query("client_ip")),
|
|
Query: strings.TrimSpace(c.Query("q")),
|
|
}
|
|
|
|
if v := strings.TrimSpace(c.Query("actor_user_id")); v != "" {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid actor_user_id")
|
|
return
|
|
}
|
|
filter.ActorUserID = &id
|
|
}
|
|
if v := strings.TrimSpace(c.Query("start_time")); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid start_time, expect RFC3339")
|
|
return
|
|
}
|
|
filter.StartTime = &t
|
|
}
|
|
if v := strings.TrimSpace(c.Query("end_time")); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid end_time, expect RFC3339")
|
|
return
|
|
}
|
|
filter.EndTime = &t
|
|
}
|
|
if v := strings.TrimSpace(c.Query("success")); v != "" {
|
|
success := v == "true"
|
|
filter.Success = &success
|
|
}
|
|
|
|
result, err := h.auditService.List(c.Request.Context(), filter)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Paginated(c, result.Logs, int64(result.Total), result.Page, result.PageSize)
|
|
}
|
|
|
|
// Get 查询单条审计日志详情(含脱敏后的请求体)。
|
|
// GET /api/v1/admin/audit-logs/:id
|
|
func (h *AuditLogHandler) Get(c *gin.Context) {
|
|
id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
response.BadRequest(c, "Invalid audit log id")
|
|
return
|
|
}
|
|
item, err := h.auditService.GetByID(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, item)
|
|
}
|
|
|
|
type auditLogClearRequest struct {
|
|
TotpCode string `json:"totp_code" binding:"required"`
|
|
}
|
|
|
|
// Clear 全量清空审计日志。
|
|
// POST /api/v1/admin/audit-logs/clear
|
|
//
|
|
// 安全要求(与需求对齐):
|
|
// 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口)
|
|
// 2. 未启用 TOTP 的管理员不允许清空
|
|
// 3. admin API key(机器凭证)不允许清空
|
|
// 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数)
|
|
func (h *AuditLogHandler) Clear(c *gin.Context) {
|
|
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
|
response.ErrorWithDetails(c, http.StatusForbidden,
|
|
"Admin API key cannot clear audit logs; a two-factor verified admin session is required",
|
|
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
|
|
return
|
|
}
|
|
|
|
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
|
if !ok || subject.UserID <= 0 {
|
|
response.Unauthorized(c, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
var req auditLogClearRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.ErrorWithDetails(c, http.StatusBadRequest,
|
|
"TOTP code is required to clear audit logs", "TOTP_CODE_REQUIRED", nil)
|
|
return
|
|
}
|
|
|
|
// 校验 TOTP:未启用(ErrTotpNotSetup)、码错误、尝试过多均在此拦截。
|
|
if err := h.totpService.VerifyCode(c.Request.Context(), subject.UserID, strings.TrimSpace(req.TotpCode)); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
uid := subject.UserID
|
|
role, _ := middleware.GetUserRoleFromContext(c)
|
|
trace := &service.AuditLog{
|
|
ActorUserID: &uid,
|
|
ActorEmail: c.GetString(middleware.ContextKeyAuthEmail),
|
|
ActorRole: role,
|
|
AuthMethod: c.GetString("auth_method"),
|
|
CredentialMasked: middleware.MaskedRequestCredential(c),
|
|
Method: http.MethodPost,
|
|
Path: c.FullPath(),
|
|
ClientIP: middleware.SecurityClientIP(c),
|
|
UserAgent: c.Request.UserAgent(),
|
|
StatusCode: http.StatusOK,
|
|
}
|
|
if requestID := c.Writer.Header().Get("X-Request-ID"); requestID != "" {
|
|
trace.RequestID = requestID
|
|
}
|
|
|
|
deleted, err := h.auditService.ClearAll(c.Request.Context(), trace)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// 留痕记录已同步落库,跳过异步审计中间件的重复记录。
|
|
middleware.SkipAudit(c)
|
|
response.Success(c, gin.H{"deleted": deleted})
|
|
}
|