FileCodeBox Go 重写版 v2.5.6(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// Package audit 提供上传/下载审计日志服务(需求 ③):
|
||||
// 记录操作时间/IP/UA/设备解析/动作/结果/字节数/耗时,落库 Postgres。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/model"
|
||||
)
|
||||
|
||||
// Service 审计日志服务。
|
||||
type Service struct {
|
||||
sink Sink
|
||||
}
|
||||
|
||||
// Sink 审计落库抽象(生产为 Postgres,测试为内存实现)。
|
||||
type Sink interface {
|
||||
// Save 批量落库。
|
||||
Save(ctx context.Context, logs []model.AuditLog) error
|
||||
}
|
||||
|
||||
// DBSink 基于 GORM 的落库实现。
|
||||
type DBSink struct{ db *gorm.DB }
|
||||
|
||||
// NewDBSink 构造数据库落库实现。
|
||||
func NewDBSink(db *gorm.DB) *DBSink { return &DBSink{db: db} }
|
||||
|
||||
// Save 批量插入审计记录。
|
||||
func (s *DBSink) Save(ctx context.Context, logs []model.AuditLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).CreateInBatches(&logs, 200).Error
|
||||
}
|
||||
|
||||
// NewService 构造审计服务。
|
||||
func NewService(sink Sink) *Service {
|
||||
return &Service{sink: sink}
|
||||
}
|
||||
|
||||
// Entry 一次待落库的审计事件。
|
||||
type Entry struct {
|
||||
Action string // upload | download
|
||||
FileCode string // 取件码
|
||||
FileName string // 原始文件名
|
||||
SizeBytes int64 // 文件总字节数
|
||||
TransferredBytes int64 // 实际传输字节数
|
||||
IP string // 客户端 IP
|
||||
UserAgent string // User-Agent
|
||||
DeviceOS string // 操作系统
|
||||
DeviceBrowser string // 浏览器
|
||||
DeviceType string // desktop/mobile/tablet/bot/other
|
||||
Actor string // admin | guest
|
||||
Result string // success | denied | failed
|
||||
ErrorMsg string // 失败原因
|
||||
Duration time.Duration // 耗时
|
||||
}
|
||||
|
||||
// Record 异步写入一条审计日志:先尝试同步落库,失败时进入内存缓冲等待重试,
|
||||
// 避免审计失败影响主请求,也避免高峰期阻塞。
|
||||
func (s *Service) Record(entry Entry) {
|
||||
record := model.AuditLog{
|
||||
Action: entry.Action,
|
||||
FileCode: truncate(entry.FileCode, 64),
|
||||
FileName: truncate(entry.FileName, 255),
|
||||
SizeBytes: entry.SizeBytes,
|
||||
TransferredBytes: entry.TransferredBytes,
|
||||
IP: truncate(entry.IP, 64),
|
||||
UserAgent: truncate(entry.UserAgent, 512),
|
||||
DeviceOS: truncate(entry.DeviceOS, 64),
|
||||
DeviceBrowser: truncate(entry.DeviceBrowser, 64),
|
||||
DeviceType: truncate(entry.DeviceType, 32),
|
||||
Actor: truncate(entry.Actor, 64),
|
||||
Result: normalizeResult(entry.Result),
|
||||
ErrorMsg: truncate(entry.ErrorMsg, 512),
|
||||
DurationMs: entry.Duration.Milliseconds(),
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.sink.Save(ctx, []model.AuditLog{record}); err != nil {
|
||||
log.Printf("[audit] 审计日志落库失败,进入重试队列: %v", err)
|
||||
s.enqueue(record)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// retryBuf 落库失败时的内存重试缓冲。
|
||||
var retryBuf struct {
|
||||
sync.Mutex
|
||||
items []model.AuditLog
|
||||
}
|
||||
|
||||
const maxRetryBuffer = 10000
|
||||
|
||||
// enqueue 入队;超出上限时丢弃最旧的,防止内存无限增长。
|
||||
func (s *Service) enqueue(record model.AuditLog) {
|
||||
retryBuf.Lock()
|
||||
if len(retryBuf.items) >= maxRetryBuffer {
|
||||
retryBuf.items = retryBuf.items[1:]
|
||||
}
|
||||
retryBuf.items = append(retryBuf.items, record)
|
||||
retryBuf.Unlock()
|
||||
}
|
||||
|
||||
// FlushRetry 将缓冲中的审计日志重新落库;由后台定时任务调用。
|
||||
func (s *Service) FlushRetry() {
|
||||
retryBuf.Lock()
|
||||
if len(retryBuf.items) == 0 {
|
||||
retryBuf.Unlock()
|
||||
return
|
||||
}
|
||||
items := retryBuf.items
|
||||
retryBuf.items = nil
|
||||
retryBuf.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.sink.Save(ctx, items); err != nil {
|
||||
log.Printf("[audit] 重试队列落库失败: %v", err)
|
||||
// 失败则放回队首
|
||||
retryBuf.Lock()
|
||||
retryBuf.items = append(items, retryBuf.items...)
|
||||
if len(retryBuf.items) > maxRetryBuffer {
|
||||
retryBuf.items = retryBuf.items[:maxRetryBuffer]
|
||||
}
|
||||
retryBuf.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// StartRetryLoop 启动后台重试循环。
|
||||
func (s *Service) StartRetryLoop(stop <-chan struct{}) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
s.FlushRetry()
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.FlushRetry()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Query 按条件分页查询审计日志(管理端使用)。
|
||||
// action/ip/result 为可选过滤;begin/end 为创建时间范围(可选)。
|
||||
func (s *Service) Query(page, pageSize int, action, ip, result string, begin, end *time.Time) ([]model.AuditLog, int64, error) {
|
||||
dbSink, ok := s.sink.(*DBSink)
|
||||
if !ok {
|
||||
return nil, 0, errors.New("audit: 当前 sink 不支持查询")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
q := dbSink.db.Model(&model.AuditLog{})
|
||||
if action != "" {
|
||||
q = q.Where("action = ?", action)
|
||||
}
|
||||
if ip != "" {
|
||||
q = q.Where("ip = ?", ip)
|
||||
}
|
||||
if result != "" {
|
||||
q = q.Where("result = ?", result)
|
||||
}
|
||||
if begin != nil {
|
||||
q = q.Where("created_at >= ?", *begin)
|
||||
}
|
||||
if end != nil {
|
||||
q = q.Where("created_at <= ?", *end)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var logs []model.AuditLog
|
||||
err := q.Order("id DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func normalizeResult(r string) string {
|
||||
switch strings.TrimSpace(r) {
|
||||
case model.AuditResultSuccess, model.AuditResultDenied, model.AuditResultFailed:
|
||||
return strings.TrimSpace(r)
|
||||
case "":
|
||||
return model.AuditResultFailed
|
||||
default:
|
||||
return model.AuditResultFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeviceInfo 从 User-Agent 解析出的设备信息。
|
||||
type DeviceInfo struct {
|
||||
OS string // Windows/macOS/Android/iOS/Linux/Unknown
|
||||
Browser string // Chrome/Firefox/Safari/Edge/Other
|
||||
Type string // desktop/mobile/tablet/bot/other
|
||||
}
|
||||
|
||||
// 动作常量。
|
||||
const (
|
||||
ActionUpload = "upload"
|
||||
ActionDownload = "download"
|
||||
// ActionAdmin 管理端敏感操作(登录/登出/配置/密码/引擎切换/文件删除等),
|
||||
// L5:纳入审计以便追溯登录失败与配置变更。
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
// 角色常量。
|
||||
const (
|
||||
ActorAdmin = "admin"
|
||||
ActorGuest = "guest"
|
||||
)
|
||||
|
||||
// botKeywords 常见爬虫/机器人标识。
|
||||
var botKeywords = []string{"bot", "spider", "crawl", "slurp", "curl/", "wget", "python-requests", "go-http-client"}
|
||||
|
||||
// ParseUserAgent 解析 User-Agent 为设备信息(轻量规则,避免引入重依赖)。
|
||||
func ParseUserAgent(ua string) DeviceInfo {
|
||||
ua = strings.TrimSpace(ua)
|
||||
lower := strings.ToLower(ua)
|
||||
if ua == "" {
|
||||
return DeviceInfo{OS: "Unknown", Browser: "Other", Type: "other"}
|
||||
}
|
||||
for _, kw := range botKeywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return DeviceInfo{OS: "Unknown", Browser: "Other", Type: "bot"}
|
||||
}
|
||||
}
|
||||
|
||||
info := DeviceInfo{OS: "Unknown", Browser: "Other", Type: "desktop"}
|
||||
|
||||
// 操作系统
|
||||
switch {
|
||||
case strings.Contains(lower, "windows"):
|
||||
info.OS = "Windows"
|
||||
case strings.Contains(lower, "iphone"), strings.Contains(lower, "ipod"):
|
||||
info.OS = "iOS"
|
||||
info.Type = "mobile"
|
||||
case strings.Contains(lower, "ipad"):
|
||||
info.OS = "iOS"
|
||||
info.Type = "tablet"
|
||||
case strings.Contains(lower, "mac os x"), strings.Contains(lower, "macintosh"):
|
||||
info.OS = "macOS"
|
||||
case strings.Contains(lower, "android"):
|
||||
info.OS = "Android"
|
||||
info.Type = "mobile"
|
||||
if strings.Contains(lower, "tablet") || !strings.Contains(lower, "mobile") {
|
||||
info.Type = "tablet"
|
||||
}
|
||||
case strings.Contains(lower, "linux"), strings.Contains(lower, "ubuntu"), strings.Contains(lower, "fedora"):
|
||||
info.OS = "Linux"
|
||||
}
|
||||
|
||||
// 浏览器(顺序重要:Edge/OPR 必须在 Chrome 之前判断)
|
||||
switch {
|
||||
case strings.Contains(lower, "edg/"), strings.Contains(lower, "edge/"):
|
||||
info.Browser = "Edge"
|
||||
case strings.Contains(lower, "opr/"), strings.Contains(lower, "opera"):
|
||||
info.Browser = "Opera"
|
||||
case strings.Contains(lower, "chrome/"), strings.Contains(lower, "crios/"):
|
||||
info.Browser = "Chrome"
|
||||
case strings.Contains(lower, "firefox/"), strings.Contains(lower, "fxios/"):
|
||||
info.Browser = "Firefox"
|
||||
case strings.Contains(lower, "safari/"):
|
||||
info.Browser = "Safari"
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package audit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUserAgent(t *testing.T) {
|
||||
cases := []struct {
|
||||
ua string
|
||||
os string
|
||||
browser string
|
||||
typ string
|
||||
}{
|
||||
{
|
||||
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
os: "Windows", browser: "Chrome", typ: "desktop",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
os: "iOS", browser: "Safari", typ: "mobile",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0",
|
||||
os: "macOS", browser: "Edge", typ: "desktop",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
os: "Android", browser: "Chrome", typ: "mobile",
|
||||
},
|
||||
{
|
||||
ua: "curl/8.4.0",
|
||||
os: "Unknown", browser: "Other", typ: "bot",
|
||||
},
|
||||
{
|
||||
ua: "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
os: "Linux", browser: "Firefox", typ: "desktop",
|
||||
},
|
||||
{ua: "", os: "Unknown", browser: "Other", typ: "other"},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
got := ParseUserAgent(tc.ua)
|
||||
if got.OS != tc.os || got.Browser != tc.browser || got.Type != tc.typ {
|
||||
t.Errorf("case %d: ParseUserAgent(%q) = %+v, want os=%s browser=%s type=%s",
|
||||
i, tc.ua, got, tc.os, tc.browser, tc.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user