应对管理员访问凭证失守导致的数据外泄风险,新增三层防护: 审计日志(admin-only 可见,用户不可见) - 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理 - 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类 请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取) - 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url 保留以便追责);非 JSON body 不入库 - 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许, 清空后同步写入留痕记录 会话 IP/UA 绑定(默认开启,可在系统设置关闭) - JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求 重新登录;旧 token 无指纹时放行以平滑升级 敏感操作 step-up 2FA(sudo 窗口 15 分钟) - 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试 - API key 查看按需求暂不加强管控 前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、 step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
238 lines
5.7 KiB
Go
238 lines
5.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
auditLogQueueCapacity = 4096
|
|
auditLogBatchSize = 100
|
|
auditLogFlushInterval = time.Second
|
|
|
|
auditRetentionCheckInterval = 24 * time.Hour
|
|
auditRetentionStartupDelay = 5 * time.Minute
|
|
auditRetentionBatchSize = 5000
|
|
)
|
|
|
|
// AuditLogService 管理面操作审计日志服务。
|
|
// 写入端为非阻塞异步批量落库(不拖慢管理请求);
|
|
// 读取端提供分页查询;清空端点由 handler 层做 TOTP 强校验后调用 ClearAll。
|
|
type AuditLogService struct {
|
|
repo AuditLogRepository
|
|
settingService *SettingService
|
|
|
|
queue chan *AuditLog
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
|
|
droppedCount uint64
|
|
writeFailed uint64
|
|
writtenCount uint64
|
|
}
|
|
|
|
func NewAuditLogService(repo AuditLogRepository, settingService *SettingService) *AuditLogService {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &AuditLogService{
|
|
repo: repo,
|
|
settingService: settingService,
|
|
queue: make(chan *AuditLog, auditLogQueueCapacity),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start 启动异步写入与保留期清理协程。
|
|
func (s *AuditLogService) Start() {
|
|
if s == nil || s.repo == nil {
|
|
return
|
|
}
|
|
s.wg.Add(2)
|
|
go s.runWriter()
|
|
go s.runRetentionLoop()
|
|
}
|
|
|
|
// Stop 停止服务并尽量落盘队列中剩余记录。
|
|
func (s *AuditLogService) Stop() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.cancel()
|
|
s.wg.Wait()
|
|
}
|
|
|
|
// Record 非阻塞入队一条审计记录;队列打满时丢弃并计数(管理面流量下几乎不可能发生)。
|
|
func (s *AuditLogService) Record(entry *AuditLog) {
|
|
if s == nil || entry == nil {
|
|
return
|
|
}
|
|
if entry.CreatedAt.IsZero() {
|
|
entry.CreatedAt = time.Now().UTC()
|
|
}
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
select {
|
|
case s.queue <- entry:
|
|
default:
|
|
atomic.AddUint64(&s.droppedCount, 1)
|
|
}
|
|
}
|
|
|
|
// List 分页查询审计日志。
|
|
func (s *AuditLogService) List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error) {
|
|
return s.repo.List(ctx, filter)
|
|
}
|
|
|
|
// GetByID 查询单条详情。
|
|
func (s *AuditLogService) GetByID(ctx context.Context, id int64) (*AuditLog, error) {
|
|
return s.repo.GetByID(ctx, id)
|
|
}
|
|
|
|
// ClearAll 全量清空审计日志并写入留痕记录。
|
|
// 调用方(handler)必须先完成 TOTP 验证;本方法负责:
|
|
// 1. 统计并清空全表
|
|
// 2. 同步写入一条 "audit_log.clear" 留痕记录(绕过异步队列,保证落库)
|
|
func (s *AuditLogService) ClearAll(ctx context.Context, trace *AuditLog) (int64, error) {
|
|
deleted, err := s.repo.Count(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count audit logs: %w", err)
|
|
}
|
|
if err := s.repo.TruncateAll(ctx); err != nil {
|
|
return 0, fmt.Errorf("truncate audit logs: %w", err)
|
|
}
|
|
|
|
if trace != nil {
|
|
trace.Action = AuditActionAuditLogClear
|
|
if trace.CreatedAt.IsZero() {
|
|
trace.CreatedAt = time.Now().UTC()
|
|
}
|
|
if trace.Extra == nil {
|
|
trace.Extra = map[string]any{}
|
|
}
|
|
trace.Extra["deleted_rows"] = deleted
|
|
if err := s.repo.Insert(ctx, trace); err != nil {
|
|
// 留痕失败必须显式暴露:清空已发生,但审计链断裂。
|
|
return deleted, fmt.Errorf("audit logs cleared (%d rows) but failed to persist clear-trace record: %w", deleted, err)
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func (s *AuditLogService) runWriter() {
|
|
defer s.wg.Done()
|
|
|
|
ticker := time.NewTicker(auditLogFlushInterval)
|
|
defer ticker.Stop()
|
|
|
|
batch := make([]*AuditLog, 0, auditLogBatchSize)
|
|
flush := func() {
|
|
if len(batch) == 0 {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
inserted, err := s.repo.BatchInsert(ctx, batch)
|
|
cancel()
|
|
if err != nil {
|
|
atomic.AddUint64(&s.writeFailed, uint64(len(batch)))
|
|
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"audit log flush failed\" err=%v batch=%d\n",
|
|
time.Now().Format(time.RFC3339Nano), err, len(batch))
|
|
} else {
|
|
atomic.AddUint64(&s.writtenCount, uint64(inserted))
|
|
}
|
|
batch = batch[:0]
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
// 停机前排空队列。
|
|
for {
|
|
select {
|
|
case item := <-s.queue:
|
|
if item == nil {
|
|
continue
|
|
}
|
|
batch = append(batch, item)
|
|
if len(batch) >= auditLogBatchSize {
|
|
flush()
|
|
}
|
|
default:
|
|
flush()
|
|
return
|
|
}
|
|
}
|
|
case item := <-s.queue:
|
|
if item == nil {
|
|
continue
|
|
}
|
|
batch = append(batch, item)
|
|
if len(batch) >= auditLogBatchSize {
|
|
flush()
|
|
}
|
|
case <-ticker.C:
|
|
flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// runRetentionLoop 按保留期定期删除过期审计日志。
|
|
// 删除操作幂等,多实例并发执行无害,因此无需选主。
|
|
func (s *AuditLogService) runRetentionLoop() {
|
|
defer s.wg.Done()
|
|
|
|
startupTimer := time.NewTimer(auditRetentionStartupDelay)
|
|
defer startupTimer.Stop()
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-startupTimer.C:
|
|
}
|
|
|
|
ticker := time.NewTicker(auditRetentionCheckInterval)
|
|
defer ticker.Stop()
|
|
|
|
s.runRetentionOnce()
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.runRetentionOnce()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AuditLogService) runRetentionOnce() {
|
|
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Minute)
|
|
defer cancel()
|
|
|
|
days := 0
|
|
if s.settingService != nil {
|
|
days = s.settingService.GetAuditLogRetentionDays(ctx)
|
|
}
|
|
if days <= 0 {
|
|
return // 0 或负值表示永久保留,仅支持手动清空
|
|
}
|
|
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
|
for {
|
|
deleted, err := s.repo.DeleteBefore(ctx, cutoff, auditRetentionBatchSize)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"audit log retention cleanup failed\" err=%v\n",
|
|
time.Now().Format(time.RFC3339Nano), err)
|
|
return
|
|
}
|
|
if deleted == 0 {
|
|
return
|
|
}
|
|
}
|
|
}
|