Files
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

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})
}