diff --git a/backend/cmd/jwtgen/main.go b/backend/cmd/jwtgen/main.go index fc8a00c41f..5663c9b174 100644 --- a/backend/cmd/jwtgen/main.go +++ b/backend/cmd/jwtgen/main.go @@ -48,7 +48,7 @@ func main() { log.Fatalf("failed to resolve admin user: %v", err) } - token, err := authService.GenerateToken(user) + token, err := authService.GenerateToken(ctx, user) if err != nil { log.Fatalf("failed to generate token: %v", err) } diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index c5f7e315a9..f5baee9da1 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -104,6 +104,7 @@ func provideCleanup( channelMonitorRunner *service.ChannelMonitorRunner, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, + auditLog *service.AuditLogService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -134,6 +135,12 @@ func provideCleanup( } return nil }}, + {"AuditLogService", func() error { + if auditLog != nil { + auditLog.Stop() + } + return nil + }}, {"OpsAlertEvaluatorService", func() error { if opsAlertEvaluator != nil { opsAlertEvaluator.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a58ac974b1..b937c3a02e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -251,7 +251,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService) complianceHandler := admin.NewComplianceHandler(settingService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler, upstreamBillingProbeService) + auditLogRepository := repository.NewAuditLogRepository(db) + auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) + auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -273,10 +276,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) - jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) - adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) + jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) + adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) - engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient) + auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService) + stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) @@ -291,7 +296,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService) application := &Application{ Server: httpServer, Cleanup: v, @@ -352,6 +357,7 @@ func provideCleanup( channelMonitorRunner *service.ChannelMonitorRunner, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, + auditLog *service.AuditLogService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -363,6 +369,12 @@ func provideCleanup( } parallelSteps := []cleanupStep{ + {"AuditLogService", func() error { + if auditLog != nil { + auditLog.Stop() + } + return nil + }}, {"OpsScheduledReportService", func() error { if opsScheduledReport != nil { opsScheduledReport.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index e1a16dc098..4343dbb729 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -84,6 +84,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // channelMonitorRunner nil, // quotaFlusher nil, // upstreamBillingProbe + nil, // auditLog ) require.NotPanics(t, func() { diff --git a/backend/internal/handler/admin/audit_log_handler.go b/backend/internal/handler/admin/audit_log_handler.go new file mode 100644 index 0000000000..67d528a1e5 --- /dev/null +++ b/backend/internal/handler/admin/audit_log_handler.go @@ -0,0 +1,170 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "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: ip.GetTrustedClientIP(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}) +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 1f09e16a5e..296b27044a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -123,6 +123,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + SessionBindingEnabled: settings.SessionBindingEnabled, + AuditLogRetentionDays: settings.AuditLogRetentionDays, LoginAgreementEnabled: settings.LoginAgreementEnabled, LoginAgreementMode: settings.LoginAgreementMode, LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 8718ff2c7a..707af4a666 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -26,7 +26,9 @@ type UpdateSettingsRequest struct { PasswordResetEnabled bool `json:"password_reset_enabled"` FrontendURL string `json:"frontend_url"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` @@ -1179,6 +1181,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, + SessionBindingEnabled: req.SessionBindingEnabled, + AuditLogRetentionDays: req.AuditLogRetentionDays, LoginAgreementEnabled: req.LoginAgreementEnabled, LoginAgreementMode: loginAgreementMode, LoginAgreementUpdatedAt: loginAgreementUpdatedAt, @@ -1705,6 +1709,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + SessionBindingEnabled: updatedSettings.SessionBindingEnabled, + AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays, LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, LoginAgreementMode: updatedSettings.LoginAgreementMode, LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 592a0d82d6..2467feaee8 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -107,7 +107,7 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) { if err != nil { slog.Error("failed to generate token pair", "error", err, "user_id", user.ID) // 回退到只返回Access Token - token, tokenErr := h.authService.GenerateToken(user) + token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user) if tokenErr != nil { response.InternalError(c, "Failed to generate token") return diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go index 1de9a132a4..2f1e73c4a8 100644 --- a/backend/internal/handler/auth_linuxdo_oauth_test.go +++ b/backend/internal/handler/auth_linuxdo_oauth_test.go @@ -282,7 +282,7 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) { Save(context.Background()) require.NoError(t, err) - token, err := handler.authService.GenerateToken(&service.User{ + token, err := handler.authService.GenerateToken(context.Background(), &service.User{ ID: user.ID, Email: user.Email, Username: user.Username, diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 53360036a2..4549fc1291 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -3360,6 +3360,14 @@ func (s *oauthPendingFlowTotpCacheStub) ClearVerifyAttempts(_ context.Context, u return nil } +func (s *oauthPendingFlowTotpCacheStub) SetStepUpGrant(_ context.Context, _ int64, _ string, _ time.Duration) error { + return nil +} + +func (s *oauthPendingFlowTotpCacheStub) HasStepUpGrant(_ context.Context, _ int64, _ string) (bool, error) { + return false, nil +} + type oauthPendingFlowTotpEncryptorStub struct{} func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, error) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index f1fcd057a7..bb2a125157 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -36,6 +36,8 @@ type SystemSettings struct { InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 58c524889c..3605a6a6dc 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -38,6 +38,7 @@ type AdminHandlers struct { Payment *admin.PaymentHandler Affiliate *admin.AffiliateHandler Compliance *admin.ComplianceHandler + AuditLog *admin.AuditLogHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/totp_handler.go b/backend/internal/handler/totp_handler.go index f9151dabd4..9d5527ca7e 100644 --- a/backend/internal/handler/totp_handler.go +++ b/backend/internal/handler/totp_handler.go @@ -179,3 +179,42 @@ func (h *TotpHandler) SendVerifyCode(c *gin.Context) { response.Success(c, gin.H{"success": true}) } + +// TotpStepUpRequest represents the request to verify a step-up TOTP code +type TotpStepUpRequest struct { + Code string `json:"code" binding:"required"` +} + +// TotpStepUpResponse represents the step-up verification response +type TotpStepUpResponse struct { + Verified bool `json:"verified"` + ExpiresIn int64 `json:"expires_in"` // 授权剩余有效期(秒) +} + +// StepUp 敏感操作二次验证:校验 TOTP 码并为当前会话授予一段时间的 step-up 权限。 +// POST /api/v1/user/totp/step-up +func (h *TotpHandler) StepUp(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req TotpStepUpRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "TOTP code is required") + return + } + + sessionKey := middleware2.StepUpSessionKey(c, subject.UserID) + ttl, err := h.totpService.VerifyStepUp(c.Request.Context(), subject.UserID, sessionKey, req.Code) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, TotpStepUpResponse{ + Verified: true, + ExpiresIn: int64(ttl.Seconds()), + }) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 82004c5119..101b2f1bd8 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -41,6 +41,7 @@ func ProvideAdminHandlers( paymentHandler *admin.PaymentHandler, affiliateHandler *admin.AffiliateHandler, complianceHandler *admin.ComplianceHandler, + auditLogHandler *admin.AuditLogHandler, upstreamBillingProbe *service.UpstreamBillingProbeService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) @@ -77,6 +78,7 @@ func ProvideAdminHandlers( Payment: paymentHandler, Affiliate: affiliateHandler, Compliance: complianceHandler, + AuditLog: auditLogHandler, } } @@ -195,6 +197,7 @@ var ProviderSet = wire.NewSet( admin.NewPaymentHandler, admin.NewAffiliateHandler, admin.NewComplianceHandler, + admin.NewAuditLogHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/repository/audit_log_repo.go b/backend/internal/repository/audit_log_repo.go new file mode 100644 index 0000000000..7ad4390cd9 --- /dev/null +++ b/backend/internal/repository/audit_log_repo.go @@ -0,0 +1,368 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +// auditLogRepository 审计日志仓储(raw SQL,append-only)。 +// 刻意不实现单条删除:审计日志只允许追加、按保留期批量清理、以及带 2FA 的全量清空。 +type auditLogRepository struct { + db *sql.DB +} + +// NewAuditLogRepository 创建审计日志仓储。 +func NewAuditLogRepository(db *sql.DB) service.AuditLogRepository { + return &auditLogRepository{db: db} +} + +const auditLogInsertColumns = `created_at, actor_user_id, actor_email, actor_role, auth_method, +credential_masked, action, method, path, request_id, client_ip, user_agent, +request_body, status_code, latency_ms, extra` + +func auditLogInsertValues(log *service.AuditLog) []any { + createdAt := log.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + extraJSON := "{}" + if len(log.Extra) > 0 { + if encoded, err := json.Marshal(log.Extra); err == nil { + extraJSON = string(encoded) + } + } + return []any{ + createdAt.UTC(), + nullInt64Ptr(log.ActorUserID), + truncateString(log.ActorEmail, 255), + truncateString(log.ActorRole, 32), + truncateString(log.AuthMethod, 32), + truncateString(log.CredentialMasked, 160), + truncateString(log.Action, 128), + truncateString(log.Method, 16), + truncateString(log.Path, 512), + truncateString(log.RequestID, 64), + truncateString(log.ClientIP, 64), + truncateString(log.UserAgent, 512), + log.RequestBody, + log.StatusCode, + log.LatencyMs, + extraJSON, + } +} + +func (r *auditLogRepository) BatchInsert(ctx context.Context, logs []*service.AuditLog) (int64, error) { + if r == nil || r.db == nil { + return 0, fmt.Errorf("nil audit log repository") + } + if len(logs) == 0 { + return 0, nil + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + stmt, err := tx.PrepareContext(ctx, pq.CopyIn( + "audit_logs", + "created_at", "actor_user_id", "actor_email", "actor_role", "auth_method", + "credential_masked", "action", "method", "path", "request_id", "client_ip", "user_agent", + "request_body", "status_code", "latency_ms", "extra", + )) + if err != nil { + _ = tx.Rollback() + return 0, err + } + + var inserted int64 + for _, log := range logs { + if log == nil { + continue + } + if _, err := stmt.ExecContext(ctx, auditLogInsertValues(log)...); err != nil { + _ = stmt.Close() + _ = tx.Rollback() + return inserted, err + } + inserted++ + } + + if _, err := stmt.ExecContext(ctx); err != nil { + _ = stmt.Close() + _ = tx.Rollback() + return inserted, err + } + if err := stmt.Close(); err != nil { + _ = tx.Rollback() + return inserted, err + } + if err := tx.Commit(); err != nil { + return inserted, err + } + return inserted, nil +} + +func (r *auditLogRepository) Insert(ctx context.Context, log *service.AuditLog) error { + if r == nil || r.db == nil { + return fmt.Errorf("nil audit log repository") + } + if log == nil { + return fmt.Errorf("nil audit log") + } + query := `INSERT INTO audit_logs (` + auditLogInsertColumns + `) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)` + _, err := r.db.ExecContext(ctx, query, auditLogInsertValues(log)...) + return err +} + +func buildAuditLogsWhere(filter *service.AuditLogFilter) (string, []any) { + clauses := make([]string, 0, 10) + args := make([]any, 0, 10) + clauses = append(clauses, "1=1") + + if filter.StartTime != nil { + args = append(args, filter.StartTime.UTC()) + clauses = append(clauses, "l.created_at >= $"+itoa(len(args))) + } + if filter.EndTime != nil { + args = append(args, filter.EndTime.UTC()) + clauses = append(clauses, "l.created_at <= $"+itoa(len(args))) + } + if filter.ActorUserID != nil { + args = append(args, *filter.ActorUserID) + clauses = append(clauses, "l.actor_user_id = $"+itoa(len(args))) + } + if v := strings.TrimSpace(filter.ActorEmail); v != "" { + args = append(args, "%"+escapeLikePattern(v)+"%") + clauses = append(clauses, "l.actor_email ILIKE $"+itoa(len(args))) + } + if v := strings.TrimSpace(filter.AuthMethod); v != "" { + args = append(args, v) + clauses = append(clauses, "l.auth_method = $"+itoa(len(args))) + } + if v := strings.TrimSpace(filter.Action); v != "" { + args = append(args, "%"+escapeLikePattern(v)+"%") + clauses = append(clauses, "l.action ILIKE $"+itoa(len(args))) + } + if v := strings.TrimSpace(filter.Method); v != "" { + args = append(args, strings.ToUpper(v)) + clauses = append(clauses, "l.method = $"+itoa(len(args))) + } + if v := strings.TrimSpace(filter.ClientIP); v != "" { + args = append(args, v) + clauses = append(clauses, "l.client_ip = $"+itoa(len(args))) + } + if filter.Success != nil { + if *filter.Success { + clauses = append(clauses, "l.status_code < 400") + } else { + clauses = append(clauses, "l.status_code >= 400") + } + } + if v := strings.TrimSpace(filter.Query); v != "" { + args = append(args, "%"+escapeLikePattern(v)+"%") + idx := itoa(len(args)) + clauses = append(clauses, "(l.path ILIKE $"+idx+" OR l.action ILIKE $"+idx+" OR l.actor_email ILIKE $"+idx+")") + } + + return "WHERE " + strings.Join(clauses, " AND "), args +} + +const auditLogSelectColumns = ` + l.id, + l.created_at, + l.actor_user_id, + COALESCE(l.actor_email, ''), + COALESCE(l.actor_role, ''), + COALESCE(l.auth_method, ''), + COALESCE(l.credential_masked, ''), + COALESCE(l.action, ''), + COALESCE(l.method, ''), + COALESCE(l.path, ''), + COALESCE(l.request_id, ''), + COALESCE(l.client_ip, ''), + COALESCE(l.user_agent, ''), + COALESCE(l.request_body, ''), + l.status_code, + l.latency_ms, + COALESCE(l.extra::text, '{}')` + +func scanAuditLogRow(scan func(dest ...any) error) (*service.AuditLog, error) { + item := &service.AuditLog{} + var actorUserID sql.NullInt64 + var extraRaw string + if err := scan( + &item.ID, + &item.CreatedAt, + &actorUserID, + &item.ActorEmail, + &item.ActorRole, + &item.AuthMethod, + &item.CredentialMasked, + &item.Action, + &item.Method, + &item.Path, + &item.RequestID, + &item.ClientIP, + &item.UserAgent, + &item.RequestBody, + &item.StatusCode, + &item.LatencyMs, + &extraRaw, + ); err != nil { + return nil, err + } + if actorUserID.Valid { + v := actorUserID.Int64 + item.ActorUserID = &v + } + extraRaw = strings.TrimSpace(extraRaw) + if extraRaw != "" && extraRaw != "null" && extraRaw != "{}" { + extra := make(map[string]any) + if err := json.Unmarshal([]byte(extraRaw), &extra); err == nil { + item.Extra = extra + } + } + return item, nil +} + +func (r *auditLogRepository) List(ctx context.Context, filter *service.AuditLogFilter) (*service.AuditLogList, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("nil audit log repository") + } + if filter == nil { + filter = &service.AuditLogFilter{} + } + + page := filter.Page + if page <= 0 { + page = 1 + } + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + + where, args := buildAuditLogsWhere(filter) + countSQL := "SELECT COUNT(*) FROM audit_logs l " + where + var total int + if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil { + return nil, err + } + + offset := (page - 1) * pageSize + argsWithLimit := append(args, pageSize, offset) + query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l\n" + where + ` +ORDER BY l.created_at DESC, l.id DESC +LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) + + rows, err := r.db.QueryContext(ctx, query, argsWithLimit...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + logs := make([]*service.AuditLog, 0, pageSize) + for rows.Next() { + item, err := scanAuditLogRow(rows.Scan) + if err != nil { + return nil, err + } + // 列表页不返回 body,降低载荷;详情接口返回完整记录。 + item.RequestBody = "" + logs = append(logs, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &service.AuditLogList{ + Logs: logs, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (r *auditLogRepository) GetByID(ctx context.Context, id int64) (*service.AuditLog, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("nil audit log repository") + } + query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l WHERE l.id = $1" + row := r.db.QueryRowContext(ctx, query, id) + item, err := scanAuditLogRow(row.Scan) + if err != nil { + if err == sql.ErrNoRows { + return nil, service.ErrAuditLogNotFound + } + return nil, err + } + return item, nil +} + +func (r *auditLogRepository) Count(ctx context.Context) (int64, error) { + if r == nil || r.db == nil { + return 0, fmt.Errorf("nil audit log repository") + } + var total int64 + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM audit_logs").Scan(&total); err != nil { + return 0, err + } + return total, nil +} + +func (r *auditLogRepository) TruncateAll(ctx context.Context) error { + if r == nil || r.db == nil { + return fmt.Errorf("nil audit log repository") + } + _, err := r.db.ExecContext(ctx, "TRUNCATE TABLE audit_logs") + return err +} + +func (r *auditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) { + if r == nil || r.db == nil { + return 0, fmt.Errorf("nil audit log repository") + } + if batchSize <= 0 { + batchSize = 5000 + } + res, err := r.db.ExecContext(ctx, ` +WITH batch AS ( + SELECT id FROM audit_logs WHERE created_at < $1 ORDER BY id LIMIT $2 +) +DELETE FROM audit_logs WHERE id IN (SELECT id FROM batch)`, cutoff.UTC(), batchSize) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func nullInt64Ptr(v *int64) any { + if v == nil || *v <= 0 { + return nil + } + return *v +} + +func truncateString(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + // 按字节截断可能切断多字节字符,按 rune 处理。 + runes := []rune(s) + for len(string(runes)) > max && len(runes) > 0 { + runes = runes[:len(runes)-1] + } + return string(runes) +} diff --git a/backend/internal/repository/totp_cache.go b/backend/internal/repository/totp_cache.go index 2f4a8ab2b3..d8057d84e8 100644 --- a/backend/internal/repository/totp_cache.go +++ b/backend/internal/repository/totp_cache.go @@ -15,6 +15,7 @@ const ( totpSetupKeyPrefix = "totp:setup:" totpLoginKeyPrefix = "totp:login:" totpAttemptsKeyPrefix = "totp:attempts:" + totpStepUpKeyPrefix = "totp:stepup:" totpAttemptsTTL = 15 * time.Minute ) @@ -147,3 +148,24 @@ func (c *TotpCache) ClearVerifyAttempts(ctx context.Context, userID int64) error key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID) return c.rdb.Del(ctx, key).Err() } + +func totpStepUpKey(userID int64, sessionKey string) string { + return fmt.Sprintf("%s%d:%s", totpStepUpKeyPrefix, userID, sessionKey) +} + +// SetStepUpGrant 记录一次 step-up 验证通过(sudo 窗口),绑定用户+会话。 +func (c *TotpCache) SetStepUpGrant(ctx context.Context, userID int64, sessionKey string, ttl time.Duration) error { + return c.rdb.Set(ctx, totpStepUpKey(userID, sessionKey), "1", ttl).Err() +} + +// HasStepUpGrant 检查 step-up 授权是否仍在有效期内。 +func (c *TotpCache) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) { + _, err := c.rdb.Get(ctx, totpStepUpKey(userID, sessionKey)).Result() + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, fmt.Errorf("get step-up grant: %w", err) + } + return true, nil +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 0ee91d146b..3b9e8fb3e9 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -84,6 +84,7 @@ var ProviderSet = wire.NewSet( NewDashboardAggregationRepository, NewSettingRepository, NewOpsRepository, + NewAuditLogRepository, NewUserSubscriptionRepository, NewUserAttributeDefinitionRepository, NewUserAttributeValueRepository, diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index aa7888b739..89ab9bf48d 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -33,6 +33,8 @@ func ProvideRouter( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.APIKeyAuthMiddleware, + auditLog middleware2.AuditLogMiddleware, + stepUpAuth middleware2.StepUpAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, opsService *service.OpsService, @@ -94,7 +96,7 @@ func ProvideRouter( service.SetWebSearchManager(websearch.NewManager(configs, redisClient)) }) - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/middleware/admin_auth.go b/backend/internal/server/middleware/admin_auth.go index 6f294ff0d6..2075691335 100644 --- a/backend/internal/server/middleware/admin_auth.go +++ b/backend/internal/server/middleware/admin_auth.go @@ -16,8 +16,9 @@ func NewAdminAuthMiddleware( authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, + auditService *service.AuditLogService, ) AdminAuthMiddleware { - return AdminAuthMiddleware(adminAuth(authService, userService, settingService)) + return AdminAuthMiddleware(adminAuth(authService, userService, settingService, auditService)) } // adminAuth 管理员认证中间件实现 @@ -28,6 +29,7 @@ func adminAuth( authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, + auditService *service.AuditLogService, ) gin.HandlerFunc { return func(c *gin.Context) { // WebSocket upgrade requests cannot set Authorization headers in browsers. @@ -36,7 +38,7 @@ func adminAuth( // Sec-WebSocket-Protocol: sub2api-admin, jwt. if isWebSocketUpgradeRequest(c) { if token := extractJWTFromWebSocketSubprotocol(c); token != "" { - if !validateJWTForAdmin(c, token, authService, userService) { + if !validateJWTForAdmin(c, token, authService, userService, settingService, auditService) { return } c.Next() @@ -64,7 +66,7 @@ func adminAuth( AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") return } - if !validateJWTForAdmin(c, token, authService, userService) { + if !validateJWTForAdmin(c, token, authService, userService, settingService, auditService) { return } c.Next() @@ -146,6 +148,7 @@ func validateAdminAPIKey( Concurrency: admin.Concurrency, }) c.Set(string(ContextKeyUserRole), admin.Role) + c.Set(ContextKeyAuthEmail, admin.Email) c.Set("auth_method", "admin_api_key") return true } @@ -156,6 +159,8 @@ func validateJWTForAdmin( token string, authService *service.AuthService, userService *service.UserService, + settingService *service.SettingService, + auditService *service.AuditLogService, ) bool { // 验证 JWT token claims, err := authService.ValidateToken(token) @@ -187,6 +192,11 @@ func validateJWTForAdmin( return false } + // 会话绑定校验:IP/UA 任一变化即撤销会话(功能可在系统设置中关闭) + if !enforceSessionBinding(c, authService, settingService, auditService, claims) { + return false + } + // 检查管理员权限 if !user.IsAdmin() { AbortWithError(c, 403, "FORBIDDEN", "Admin access required") @@ -198,6 +208,8 @@ func validateJWTForAdmin( Concurrency: user.Concurrency, }) c.Set(string(ContextKeyUserRole), user.Role) + c.Set(ContextKeyAuthEmail, user.Email) + c.Set(ContextKeySessionID, claims.SessionID) c.Set("auth_method", "jwt") return true diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 3110c6c104..af24f42e1f 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -43,13 +43,13 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) { userService := service.NewUserService(userRepo, nil, nil, nil) router := gin.New() - router.Use(gin.HandlerFunc(NewAdminAuthMiddleware(authService, userService, nil))) + router.Use(gin.HandlerFunc(NewAdminAuthMiddleware(authService, userService, nil, nil))) router.GET("/t", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) t.Run("token_version_mismatch_rejected", func(t *testing.T) { - token, err := authService.GenerateToken(&service.User{ + token, err := authService.GenerateToken(context.Background(), &service.User{ ID: admin.ID, Email: admin.Email, Role: admin.Role, @@ -67,7 +67,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) { }) t.Run("token_version_match_allows", func(t *testing.T) { - token, err := authService.GenerateToken(&service.User{ + token, err := authService.GenerateToken(context.Background(), &service.User{ ID: admin.ID, Email: admin.Email, Role: admin.Role, @@ -84,7 +84,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) { }) t.Run("websocket_token_version_mismatch_rejected", func(t *testing.T) { - token, err := authService.GenerateToken(&service.User{ + token, err := authService.GenerateToken(context.Background(), &service.User{ ID: admin.ID, Email: admin.Email, Role: admin.Role, @@ -104,7 +104,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) { }) t.Run("websocket_token_version_match_allows", func(t *testing.T) { - token, err := authService.GenerateToken(&service.User{ + token, err := authService.GenerateToken(context.Background(), &service.User{ ID: admin.ID, Email: admin.Email, Role: admin.Role, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go new file mode 100644 index 0000000000..6541e841a4 --- /dev/null +++ b/backend/internal/server/middleware/audit_log.go @@ -0,0 +1,248 @@ +package middleware + +import ( + "bytes" + "io" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// AuditLogMiddleware 管理面操作审计中间件类型(用于 wire 注入区分)。 +type AuditLogMiddleware gin.HandlerFunc + +// 审计相关 gin context 覆写键:handler / 认证中间件可通过这些键补充审计信息。 +const ( + auditCtxKeyAction = "audit_action" + auditCtxKeyActorID = "audit_actor_id" + auditCtxKeyActorEmail = "audit_actor_email" + auditCtxKeySkip = "audit_skip" + // ContextKeyAuthEmail 认证中间件写入的用户邮箱(审计用)。 + ContextKeyAuthEmail = "auth_email" + // ContextKeySessionID 认证中间件写入的会话 ID(refresh token family)。 + ContextKeySessionID = "session_id" +) + +// SetAuditAction 允许 handler / 中间件为当前请求指定审计动作名(覆盖自动推导)。 +func SetAuditAction(c *gin.Context, action string) { + c.Set(auditCtxKeyAction, action) +} + +// SetAuditActor 允许 handler 在认证上下文缺失时(如登录接口)补充操作者身份。 +func SetAuditActor(c *gin.Context, userID int64, email string) { + if userID > 0 { + c.Set(auditCtxKeyActorID, userID) + } + if email != "" { + c.Set(auditCtxKeyActorEmail, email) + } +} + +// SkipAudit 跳过当前请求的审计记录。 +func SkipAudit(c *gin.Context) { + c.Set(auditCtxKeySkip, true) +} + +// auditSensitiveReads 需要审计的敏感 GET 读取(method+FullPath → 动作名)。 +var auditSensitiveReads = map[string]string{ + "GET /api/v1/admin/accounts/data": "admin.accounts.export", + "GET /api/v1/admin/proxies/data": "admin.proxies.export", + "GET /api/v1/admin/redeem-codes/export": "admin.redeem_codes.export", + "GET /api/v1/admin/backups/:id/download-url": "admin.backups.download", + "GET /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.read", + "GET /api/v1/admin/users/:id/api-keys": "admin.users.api_keys.read", + "GET /api/v1/admin/groups/:id/api-keys": "admin.groups.api_keys.read", + "GET /api/v1/admin/backups/s3-config": "admin.backups.s3_config.read", + "GET /api/v1/admin/data-management/s3/config": "admin.data_management.s3_config.read", +} + +// auditActionOverrides 变更类请求的动作名精确映射(未命中时自动推导)。 +var auditActionOverrides = map[string]string{ + "POST /api/v1/auth/login": service.AuditActionLogin, + "POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA, + "POST /api/v1/auth/register": service.AuditActionRegister, + "POST /api/v1/auth/refresh": service.AuditActionTokenRefresh, + "POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify, + "POST /api/v1/admin/audit-logs/clear": service.AuditActionAuditLogClear, + "POST /api/v1/admin/accounts/data": "admin.accounts.import", + "POST /api/v1/admin/backups": "admin.backups.create", + "POST /api/v1/admin/backups/:id/restore": "admin.backups.restore", + "DELETE /api/v1/admin/backups/:id": "admin.backups.delete", + "PUT /api/v1/admin/backups/s3-config": "admin.backups.s3_config.update", + "POST /api/v1/admin/settings/admin-api-key/regenerate": "admin.admin_api_key.regenerate", + "DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete", +} + +// NewAuditLogMiddleware 创建审计中间件。 +// 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。 +// 该中间件应挂在认证中间件之前,以便同时记录认证失败的尝试。 +func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddleware { + return AuditLogMiddleware(func(c *gin.Context) { + routeKey := c.Request.Method + " " + c.FullPath() + + record := false + action := "" + switch c.Request.Method { + case "POST", "PUT", "PATCH", "DELETE": + record = true + if v, ok := auditActionOverrides[routeKey]; ok { + action = v + } + case "GET": + if v, ok := auditSensitiveReads[routeKey]; ok { + record = true + action = v + } + } + if !record { + c.Next() + return + } + + // 捕获请求体(读出后回填,避免影响后续 ShouldBindJSON)。 + var bodyRedacted string + if c.Request.Body != nil && c.Request.Method != "GET" { + raw, err := io.ReadAll(c.Request.Body) + if err == nil { + c.Request.Body = io.NopCloser(bytes.NewReader(raw)) + bodyRedacted = service.RedactAuditBody(raw, c.GetHeader("Content-Type")) + } + } + + start := time.Now() + c.Next() + + if c.GetBool(auditCtxKeySkip) { + return + } + + status := c.Writer.Status() + // token 刷新成功属于高频常规操作,只记录失败(潜在攻击信号)。 + if routeKey == "POST /api/v1/auth/refresh" && status < 400 { + return + } + + entry := &service.AuditLog{ + CreatedAt: time.Now().UTC(), + Action: action, + Method: c.Request.Method, + Path: c.FullPath(), + ClientIP: ip.GetTrustedClientIP(c), + UserAgent: c.Request.UserAgent(), + RequestBody: bodyRedacted, + StatusCode: status, + LatencyMs: time.Since(start).Milliseconds(), + } + if entry.Path == "" { + entry.Path = c.Request.URL.Path + } + if entry.Action == "" { + entry.Action = deriveAuditAction(c.Request.Method, entry.Path) + } + if v, ok := c.Get(auditCtxKeyAction); ok { + if s, ok := v.(string); ok && s != "" { + entry.Action = s + } + } + if requestID, ok := c.Request.Context().Value(ctxkey.RequestID).(string); ok { + entry.RequestID = requestID + } + + // 操作者身份:优先取认证中间件写入的上下文,其次取 handler 覆写(登录等场景)。 + if subject, ok := GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { + uid := subject.UserID + entry.ActorUserID = &uid + } + if role, ok := GetUserRoleFromContext(c); ok { + entry.ActorRole = role + } + entry.ActorEmail = c.GetString(ContextKeyAuthEmail) + entry.AuthMethod = c.GetString("auth_method") + if entry.AuthMethod == "" && entry.ActorUserID != nil { + entry.AuthMethod = service.AuditAuthMethodJWT + } + if v, ok := c.Get(auditCtxKeyActorID); ok { + if id, ok := v.(int64); ok && id > 0 { + entry.ActorUserID = &id + } + } + if v, ok := c.Get(auditCtxKeyActorEmail); ok { + if s, ok := v.(string); ok && s != "" { + entry.ActorEmail = s + } + } + + // 请求头凭证掩码(仅保留首尾)。 + entry.CredentialMasked = MaskedRequestCredential(c) + + extra := map[string]any{} + if len(c.Params) > 0 { + params := make(map[string]string, len(c.Params)) + for _, p := range c.Params { + params[p.Key] = p.Value + } + extra["params"] = params + } + if q := service.RedactAuditQuery(c.Request.URL.RawQuery); q != "" { + extra["query"] = q + } + if len(extra) > 0 { + entry.Extra = extra + } + + auditService.Record(entry) + }) +} + +// MaskedRequestCredential 提取请求头中的凭证并做首尾掩码。 +func MaskedRequestCredential(c *gin.Context) string { + if apiKey := strings.TrimSpace(c.GetHeader("x-api-key")); apiKey != "" { + return "x-api-key " + service.MaskAuditCredential(apiKey) + } + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + if authHeader == "" { + return "" + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 { + return parts[0] + " " + service.MaskAuditCredential(strings.TrimSpace(parts[1])) + } + return service.MaskAuditCredential(authHeader) +} + +// deriveAuditAction 由 method + 路由模板自动推导动作名, +// 例:PUT /api/v1/admin/accounts/:id → admin.accounts.update +func deriveAuditAction(method, fullPath string) string { + path := strings.TrimPrefix(fullPath, "/api/v1/") + path = strings.Trim(path, "/") + segs := strings.Split(path, "/") + parts := make([]string, 0, len(segs)) + for _, seg := range segs { + if seg == "" || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") { + continue + } + parts = append(parts, strings.ReplaceAll(seg, "-", "_")) + } + verb := "" + switch method { + case "POST": + verb = "create" + case "PUT", "PATCH": + verb = "update" + case "DELETE": + verb = "delete" + case "GET": + verb = "read" + default: + verb = strings.ToLower(method) + } + if len(parts) == 0 { + return verb + } + return strings.Join(parts, ".") + "." + verb +} diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go new file mode 100644 index 0000000000..752bed9f8a --- /dev/null +++ b/backend/internal/server/middleware/audit_log_test.go @@ -0,0 +1,22 @@ +package middleware + +import "testing" + +func TestDeriveAuditAction(t *testing.T) { + cases := []struct { + method string + path string + want string + }{ + {"PUT", "/api/v1/admin/accounts/:id", "admin.accounts.update"}, + {"POST", "/api/v1/admin/accounts", "admin.accounts.create"}, + {"DELETE", "/api/v1/admin/backups/:id", "admin.backups.delete"}, + {"GET", "/api/v1/admin/users/:id/api-keys", "admin.users.api_keys.read"}, + {"POST", "/api/v1/admin/redeem-codes/batch", "admin.redeem_codes.batch.create"}, + } + for _, tc := range cases { + if got := deriveAuditAction(tc.method, tc.path); got != tc.want { + t.Fatalf("deriveAuditAction(%q, %q) = %q, want %q", tc.method, tc.path, got, tc.want) + } + } +} diff --git a/backend/internal/server/middleware/jwt_auth.go b/backend/internal/server/middleware/jwt_auth.go index 48cb9004bf..3ed0c36c9a 100644 --- a/backend/internal/server/middleware/jwt_auth.go +++ b/backend/internal/server/middleware/jwt_auth.go @@ -11,8 +11,13 @@ import ( ) // NewJWTAuthMiddleware 创建 JWT 认证中间件 -func NewJWTAuthMiddleware(authService *service.AuthService, userService *service.UserService) JWTAuthMiddleware { - return JWTAuthMiddleware(jwtAuth(authService, userService, userService)) +func NewJWTAuthMiddleware( + authService *service.AuthService, + userService *service.UserService, + settingService *service.SettingService, + auditService *service.AuditLogService, +) JWTAuthMiddleware { + return JWTAuthMiddleware(jwtAuth(authService, userService, userService, settingService, auditService)) } type jwtUserReader interface { @@ -24,7 +29,13 @@ type userActivityToucher interface { } // jwtAuth JWT认证中间件实现 -func jwtAuth(authService *service.AuthService, userService jwtUserReader, activityToucher userActivityToucher) gin.HandlerFunc { +func jwtAuth( + authService *service.AuthService, + userService jwtUserReader, + activityToucher userActivityToucher, + settingService *service.SettingService, + auditService *service.AuditLogService, +) gin.HandlerFunc { return func(c *gin.Context) { // 从Authorization header中提取token authHeader := c.GetHeader("Authorization") @@ -77,11 +88,18 @@ func jwtAuth(authService *service.AuthService, userService jwtUserReader, activi return } + // 会话绑定校验:IP/UA 任一变化即撤销会话(功能可在系统设置中关闭) + if !enforceSessionBinding(c, authService, settingService, auditService, claims) { + return + } + c.Set(string(ContextKeyUser), AuthSubject{ UserID: user.ID, Concurrency: user.Concurrency, }) c.Set(string(ContextKeyUserRole), user.Role) + c.Set(ContextKeyAuthEmail, user.Email) + c.Set(ContextKeySessionID, claims.SessionID) if activityToucher != nil { activityToucher.TouchLastActiveForUser(c.Request.Context(), user) } diff --git a/backend/internal/server/middleware/jwt_auth_test.go b/backend/internal/server/middleware/jwt_auth_test.go index e9922e54d3..ac2b867d1c 100644 --- a/backend/internal/server/middleware/jwt_auth_test.go +++ b/backend/internal/server/middleware/jwt_auth_test.go @@ -62,7 +62,7 @@ func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthSer userRepo := &stubJWTUserRepo{users: users} authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil) userSvc := service.NewUserService(userRepo, nil, nil, nil) - mw := NewJWTAuthMiddleware(authSvc, userSvc) + mw := NewJWTAuthMiddleware(authSvc, userSvc, nil, nil) r := gin.New() r.Use(gin.HandlerFunc(mw)) @@ -88,7 +88,7 @@ func TestJWTAuth_ValidToken(t *testing.T) { } router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user}) - token, err := authSvc.GenerateToken(user) + token, err := authSvc.GenerateToken(context.Background(), user) require.NoError(t, err) w := httptest.NewRecorder() @@ -115,7 +115,7 @@ func TestJWTAuth_ValidToken_LowercaseBearer(t *testing.T) { } router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user}) - token, err := authSvc.GenerateToken(user) + token, err := authSvc.GenerateToken(context.Background(), user) require.NoError(t, err) w := httptest.NewRecorder() @@ -148,12 +148,12 @@ func TestJWTAuth_ValidToken_TouchesLastActive(t *testing.T) { toucher := &recordingActivityToucher{} r := gin.New() - r.Use(jwtAuth(authSvc, userSvc, toucher)) + r.Use(jwtAuth(authSvc, userSvc, toucher, nil, nil)) r.GET("/protected", func(c *gin.Context) { c.Status(http.StatusOK) }) - token, err := authSvc.GenerateToken(user) + token, err := authSvc.GenerateToken(context.Background(), user) require.NoError(t, err) w := httptest.NewRecorder() @@ -244,7 +244,7 @@ func TestJWTAuth_UserNotFound(t *testing.T) { // 创建环境时不注入此用户,这样 GetByID 会失败 router, authSvc := newJWTTestEnv(map[int64]*service.User{}) - token, err := authSvc.GenerateToken(fakeUser) + token, err := authSvc.GenerateToken(context.Background(), fakeUser) require.NoError(t, err) w := httptest.NewRecorder() @@ -268,7 +268,7 @@ func TestJWTAuth_UserInactive(t *testing.T) { } router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user}) - token, err := authSvc.GenerateToken(user) + token, err := authSvc.GenerateToken(context.Background(), user) require.NoError(t, err) w := httptest.NewRecorder() @@ -300,7 +300,7 @@ func TestJWTAuth_TokenVersionMismatch(t *testing.T) { } router, authSvc := newJWTTestEnv(map[int64]*service.User{1: userInDB}) - token, err := authSvc.GenerateToken(userForToken) + token, err := authSvc.GenerateToken(context.Background(), userForToken) require.NoError(t, err) w := httptest.NewRecorder() diff --git a/backend/internal/server/middleware/session_binding.go b/backend/internal/server/middleware/session_binding.go new file mode 100644 index 0000000000..b31e2a39e7 --- /dev/null +++ b/backend/internal/server/middleware/session_binding.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// SessionBindingContext 全局中间件:将请求的可信客户端 IP 与 User-Agent 注入 +// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定。 +// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。 +func SessionBindingContext() gin.HandlerFunc { + return func(c *gin.Context) { + binding := &service.SessionBinding{ + IP: ip.GetTrustedClientIP(c), + UserAgent: c.Request.UserAgent(), + } + c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding)) + c.Next() + } +} + +// currentSessionBindingHash 计算当前请求的会话指纹哈希。 +func currentSessionBindingHash(c *gin.Context) string { + binding := &service.SessionBinding{ + IP: ip.GetTrustedClientIP(c), + UserAgent: c.Request.UserAgent(), + } + return binding.Hash() +} + +// enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。 +// 指纹不匹配时:撤销该会话家族的所有 refresh token、写入审计安全事件、返回 401。 +// 返回 false 表示请求已被中断。 +// +// 兼容性:claims.BindingHash 为空(功能上线前签发的旧 token)时放行, +// 该会话在下一次 refresh 轮转时会自动获得绑定。 +func enforceSessionBinding( + c *gin.Context, + authService *service.AuthService, + settingService *service.SettingService, + auditService *service.AuditLogService, + claims *service.JWTClaims, +) bool { + if settingService == nil || !settingService.IsSessionBindingEnabled(c.Request.Context()) { + return true + } + if claims == nil || claims.BindingHash == "" { + return true + } + current := currentSessionBindingHash(c) + if current == "" || current == claims.BindingHash { + return true + } + + if authService != nil { + _ = authService.RevokeSessionFamily(c.Request.Context(), claims.SessionID) + } + if auditService != nil { + uid := claims.UserID + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + auditService.Record(&service.AuditLog{ + ActorUserID: &uid, + ActorEmail: claims.Email, + ActorRole: claims.Role, + AuthMethod: service.AuditAuthMethodJWT, + Action: service.AuditActionSessionBindingMismatch, + Method: c.Request.Method, + Path: path, + ClientIP: ip.GetTrustedClientIP(c), + UserAgent: c.Request.UserAgent(), + StatusCode: 401, + }) + } + AbortWithError(c, 401, "SESSION_BINDING_MISMATCH", "Session network fingerprint changed, please login again") + return false +} diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go new file mode 100644 index 0000000000..46fd82e8be --- /dev/null +++ b/backend/internal/server/middleware/step_up.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "context" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// StepUpAuthMiddleware 敏感操作 step-up 2FA 门控中间件类型。 +type StepUpAuthMiddleware gin.HandlerFunc + +// stepUpGrantChecker 抽象 TOTP step-up 授权检查能力(由 TotpService 实现)。 +type stepUpGrantChecker interface { + HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) +} + +// stepUpUserReader 抽象用户读取能力(检查 TOTP 是否启用)。 +type stepUpUserReader interface { + GetByID(ctx context.Context, id int64) (*service.User, error) +} + +// StepUpSessionKey 计算 step-up 授权的会话键: +// 优先绑定当前会话(refresh token family),无会话 ID 的旧 token 退化为用户级键。 +func StepUpSessionKey(c *gin.Context, userID int64) string { + if sid := c.GetString(ContextKeySessionID); sid != "" { + return sid + } + return fmt.Sprintf("u%d", userID) +} + +// NewStepUpAuthMiddleware 创建敏感操作 step-up 2FA 门控中间件。 +// +// 通过条件(全部满足): +// 1. 必须是 JWT 认证的真人会话——admin API key(机器凭证)一律拒绝 +// 2. 当前用户已启用 TOTP(未启用则拒绝并提示先启用 2FA) +// 3. 当前会话在有效期内完成过 TOTP step-up 验证(POST /api/v1/user/totp/step-up) +// +// 失败响应使用可区分的错误码,前端据此弹出 TOTP 验证对话框后重试。 +func NewStepUpAuthMiddleware(totpService *service.TotpService, userService *service.UserService) StepUpAuthMiddleware { + return StepUpAuthMiddleware(stepUpAuth(totpService, userService)) +} + +func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", + "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + return + } + + subject, ok := GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") + return + } + + user, err := userReader.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") + return + } + if !user.TotpEnabled { + AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED", + "This operation requires two-factor authentication; please enable TOTP first") + return + } + + sessionKey := StepUpSessionKey(c, subject.UserID) + granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey) + if err != nil { + // 安全门控故障时选择 fail-closed。 + AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable") + return + } + if !granted { + AbortWithError(c, 403, "STEP_UP_REQUIRED", + "This operation requires recent two-factor verification") + return + } + + c.Next() + } +} diff --git a/backend/internal/server/middleware/wire.go b/backend/internal/server/middleware/wire.go index dc01b74366..f2582e6f4f 100644 --- a/backend/internal/server/middleware/wire.go +++ b/backend/internal/server/middleware/wire.go @@ -19,4 +19,6 @@ var ProviderSet = wire.NewSet( NewJWTAuthMiddleware, NewAdminAuthMiddleware, NewAPIKeyAuthMiddleware, + NewAuditLogMiddleware, + NewStepUpAuthMiddleware, ) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 5fc70149fe..0030ee5cbd 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -26,6 +26,8 @@ func SetupRouter( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.APIKeyAuthMiddleware, + auditLog middleware2.AuditLogMiddleware, + stepUpAuth middleware2.StepUpAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, opsService *service.OpsService, @@ -52,6 +54,8 @@ func SetupRouter( // 应用中间件 r.Use(middleware2.RequestLogger()) + // 将可信客户端 IP + UA 注入 request context,供 token 签发路径写入会话绑定 + r.Use(middleware2.SessionBindingContext()) r.Use(middleware2.Logger()) r.Use(middleware2.CORS(cfg.CORS)) r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string { @@ -82,7 +86,7 @@ func SetupRouter( } // 注册路由 - registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) + registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) return r } @@ -94,6 +98,8 @@ func registerRoutes( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.APIKeyAuthMiddleware, + auditLog middleware2.AuditLogMiddleware, + stepUpAuth middleware2.StepUpAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, opsService *service.OpsService, @@ -108,11 +114,11 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 - routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) - routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) - routes.RegisterAdminRoutes(v1, h, adminAuth, settingService) + routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService) + routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService) + routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) - routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) + routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService) handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index b6aebb7029..94867bf592 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -14,10 +14,14 @@ func RegisterAdminRoutes( v1 *gin.RouterGroup, h *handler.Handlers, adminAuth middleware.AdminAuthMiddleware, + auditLog middleware.AuditLogMiddleware, + stepUpAuth middleware.StepUpAuthMiddleware, settingService *service.SettingService, ) { admin := v1.Group("/admin") admin.Use(gin.HandlerFunc(adminAuth)) + // 审计中间件挂在认证之后:所有管理面变更类操作 + 敏感读取入审计日志 + admin.Use(gin.HandlerFunc(auditLog)) admin.Use(middleware.AdminComplianceGuard(settingService)) { // 部署与运营合规确认 @@ -33,7 +37,7 @@ func RegisterAdminRoutes( registerGroupRoutes(admin, h) // 账号管理 - registerAccountRoutes(admin, h) + registerAccountRoutes(admin, h, stepUpAuth) // 公告管理 registerAnnouncementRoutes(admin, h) @@ -51,7 +55,7 @@ func RegisterAdminRoutes( registerGrokOAuthRoutes(admin, h) // 代理管理 - registerProxyRoutes(admin, h) + registerProxyRoutes(admin, h, stepUpAuth) // 卡密管理 registerRedeemCodeRoutes(admin, h) @@ -63,10 +67,10 @@ func RegisterAdminRoutes( registerSettingsRoutes(admin, h) // 数据管理 - registerDataManagementRoutes(admin, h) + registerDataManagementRoutes(admin, h, stepUpAuth) // 数据库备份恢复 - registerBackupRoutes(admin, h) + registerBackupRoutes(admin, h, stepUpAuth) // 运维监控(Ops) registerOpsRoutes(admin, h) @@ -106,6 +110,19 @@ func RegisterAdminRoutes( // 邀请返利(专属用户管理) registerAffiliateRoutes(admin, h) + + // 操作审计日志 + registerAuditLogRoutes(admin, h, stepUpAuth) + } +} + +func registerAuditLogRoutes(admin *gin.RouterGroup, h *handler.Handlers, _ middleware.StepUpAuthMiddleware) { + auditLogs := admin.Group("/audit-logs") + { + auditLogs.GET("", h.Admin.AuditLog.List) + auditLogs.GET("/:id", h.Admin.AuditLog.Get) + // 清空需现场 TOTP 校验(在 handler 内强制),不复用 step-up sudo 窗口 + auditLogs.POST("/clear", h.Admin.AuditLog.Clear) } } @@ -291,7 +308,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { accounts := admin.Group("/accounts") { accounts.GET("", h.Admin.Account.List) @@ -330,7 +347,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.POST("/:id/models/sync-upstream", h.Admin.Account.SyncUpstreamModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) - accounts.GET("/data", h.Admin.Account.ExportData) + // 账号导出泄露上游凭证原文——要求 step-up 2FA + accounts.GET("/data", gin.HandlerFunc(stepUpAuth), h.Admin.Account.ExportData) accounts.POST("/data", h.Admin.Account.ImportData) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) @@ -414,12 +432,13 @@ func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { proxies := admin.Group("/proxies") { proxies.GET("", h.Admin.Proxy.List) proxies.GET("/all", h.Admin.Proxy.GetAll) - proxies.GET("/data", h.Admin.Proxy.ExportData) + // 代理导出泄露账号密码原文——要求 step-up 2FA + proxies.GET("/data", gin.HandlerFunc(stepUpAuth), h.Admin.Proxy.ExportData) proxies.POST("/data", h.Admin.Proxy.ImportData) proxies.GET("/:id", h.Admin.Proxy.GetByID) proxies.POST("", h.Admin.Proxy.Create) @@ -501,7 +520,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerDataManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerDataManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { dataManagement := admin.Group("/data-management") { dataManagement.GET("/agent/health", h.Admin.DataManagement.GetAgentHealth) @@ -514,22 +533,24 @@ func registerDataManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { dataManagement.POST("/sources/:source_type/profiles/:profile_id/activate", h.Admin.DataManagement.SetActiveSourceProfile) dataManagement.POST("/s3/test", h.Admin.DataManagement.TestS3) dataManagement.GET("/s3/profiles", h.Admin.DataManagement.ListS3Profiles) - dataManagement.POST("/s3/profiles", h.Admin.DataManagement.CreateS3Profile) - dataManagement.PUT("/s3/profiles/:profile_id", h.Admin.DataManagement.UpdateS3Profile) + // 修改 S3 目标可将数据备份外泄——要求 step-up 2FA + dataManagement.POST("/s3/profiles", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.CreateS3Profile) + dataManagement.PUT("/s3/profiles/:profile_id", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.UpdateS3Profile) dataManagement.DELETE("/s3/profiles/:profile_id", h.Admin.DataManagement.DeleteS3Profile) - dataManagement.POST("/s3/profiles/:profile_id/activate", h.Admin.DataManagement.SetActiveS3Profile) - dataManagement.POST("/backups", h.Admin.DataManagement.CreateBackupJob) + dataManagement.POST("/s3/profiles/:profile_id/activate", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.SetActiveS3Profile) + dataManagement.POST("/backups", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.CreateBackupJob) dataManagement.GET("/backups", h.Admin.DataManagement.ListBackupJobs) dataManagement.GET("/backups/:job_id", h.Admin.DataManagement.GetBackupJob) } } -func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { backup := admin.Group("/backups") { // S3 存储配置 backup.GET("/s3-config", h.Admin.Backup.GetS3Config) - backup.PUT("/s3-config", h.Admin.Backup.UpdateS3Config) + // 修改 S3 目标可将数据库备份外泄——要求 step-up 2FA + backup.PUT("/s3-config", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.UpdateS3Config) backup.POST("/s3-config/test", h.Admin.Backup.TestS3Connection) // 定时备份配置 @@ -537,11 +558,12 @@ func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { backup.PUT("/schedule", h.Admin.Backup.UpdateSchedule) // 备份操作 - backup.POST("", h.Admin.Backup.CreateBackup) + backup.POST("", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.CreateBackup) backup.GET("", h.Admin.Backup.ListBackups) backup.GET("/:id", h.Admin.Backup.GetBackup) backup.DELETE("/:id", h.Admin.Backup.DeleteBackup) - backup.GET("/:id/download-url", h.Admin.Backup.GetDownloadURL) + // 备份下载链接可直接取走整库数据——要求 step-up 2FA + backup.GET("/:id/download-url", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.GetDownloadURL) // 恢复操作 backup.POST("/:id/restore", h.Admin.Backup.RestoreBackup) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 2c44a2b3a3..a379edd233 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -17,6 +17,7 @@ func RegisterAuthRoutes( v1 *gin.RouterGroup, h *handler.Handlers, jwtAuth servermiddleware.JWTAuthMiddleware, + auditLog servermiddleware.AuditLogMiddleware, redisClient *redis.Client, settingService *service.SettingService, ) { @@ -26,6 +27,8 @@ func RegisterAuthRoutes( // 公开接口 auth := v1.Group("/auth") auth.Use(servermiddleware.BackendModeAuthGuard(settingService)) + // 认证事件(登录/注册/2FA/token 刷新失败)入审计 + auth.Use(gin.HandlerFunc(auditLog)) { // 注册/登录/2FA/验证码发送均属于高风险入口,增加服务端兜底限流(Redis 故障时 fail-close) auth.POST("/register", rateLimiter.LimitWithOptions("auth-register", 5, time.Minute, middleware.RateLimitOptions{ diff --git a/backend/internal/server/routes/auth_rate_limit_test.go b/backend/internal/server/routes/auth_rate_limit_test.go index 07a66efb4e..463a93ba57 100644 --- a/backend/internal/server/routes/auth_rate_limit_test.go +++ b/backend/internal/server/routes/auth_rate_limit_test.go @@ -28,6 +28,9 @@ func newAuthRoutesTestRouter(redisClient *redis.Client) *gin.Engine { servermiddleware.JWTAuthMiddleware(func(c *gin.Context) { c.Next() }), + servermiddleware.AuditLogMiddleware(func(c *gin.Context) { + c.Next() + }), redisClient, nil, ) diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 9434a0b76d..f4f9128707 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -18,6 +18,7 @@ func RegisterPaymentRoutes( adminPaymentHandler *admin.PaymentHandler, jwtAuth middleware.JWTAuthMiddleware, adminAuth middleware.AdminAuthMiddleware, + auditLog middleware.AuditLogMiddleware, settingService *service.SettingService, ) { // --- User-facing payment endpoints (authenticated) --- @@ -67,6 +68,7 @@ func RegisterPaymentRoutes( // --- Admin payment endpoints (admin auth) --- adminGroup := v1.Group("/admin/payment") adminGroup.Use(gin.HandlerFunc(adminAuth)) + adminGroup.Use(gin.HandlerFunc(auditLog)) adminGroup.Use(middleware.AdminComplianceGuard(settingService)) { // Dashboard diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 9f89687ad1..f01627b523 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -13,11 +13,14 @@ func RegisterUserRoutes( v1 *gin.RouterGroup, h *handler.Handlers, jwtAuth middleware.JWTAuthMiddleware, + auditLog middleware.AuditLogMiddleware, settingService *service.SettingService, ) { authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) + // 用户管理面变更类操作入审计(含 TOTP 启用/禁用、step-up 验证、密码修改等安全事件) + authenticated.Use(gin.HandlerFunc(auditLog)) { // 用户接口 user := authenticated.Group("/user") @@ -52,6 +55,8 @@ func RegisterUserRoutes( totp.POST("/setup", h.Totp.InitiateSetup) totp.POST("/enable", h.Totp.Enable) totp.POST("/disable", h.Totp.Disable) + // 敏感操作二次验证:授予当前会话一段时间的 step-up 权限 + totp.POST("/step-up", h.Totp.StepUp) } } diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go new file mode 100644 index 0000000000..08d2b6cb20 --- /dev/null +++ b/backend/internal/service/audit_log.go @@ -0,0 +1,214 @@ +package service + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/util/logredact" +) + +// ErrAuditLogNotFound 审计日志不存在。 +var ErrAuditLogNotFound = infraerrors.NotFound("AUDIT_LOG_NOT_FOUND", "audit log not found") + +// 审计日志相关常量。 +const ( + // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。 + AuditAuthMethodJWT = "jwt" + AuditAuthMethodAdminAPIKey = "admin_api_key" + + // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 + auditRequestBodyMaxBytes = 16 * 1024 + // auditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限,超出不解析仅记录占位符。 + auditRequestBodyCaptureLimit = 256 * 1024 +) + +// 内置审计动作名(认证/安全事件与特殊操作使用固定值,普通请求由路由自动推导)。 +const ( + AuditActionLogin = "auth.login" + AuditActionLogin2FA = "auth.login.2fa" + AuditActionRegister = "auth.register" + AuditActionTokenRefresh = "auth.token.refresh" + AuditActionSessionBindingMismatch = "auth.session_binding.mismatch" + AuditActionStepUpVerify = "auth.step_up.verify" + AuditActionAuditLogClear = "admin.audit_log.clear" +) + +// AuditLog 一条管理面操作审计记录。 +type AuditLog struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ActorEmail string `json:"actor_email"` + ActorRole string `json:"actor_role"` + AuthMethod string `json:"auth_method"` + CredentialMasked string `json:"credential_masked"` + Action string `json:"action"` + Method string `json:"method"` + Path string `json:"path"` + RequestID string `json:"request_id"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + RequestBody string `json:"request_body,omitempty"` + StatusCode int `json:"status_code"` + LatencyMs int64 `json:"latency_ms"` + Extra map[string]any `json:"extra,omitempty"` +} + +// AuditLogFilter 审计日志列表查询条件。 +type AuditLogFilter struct { + Page int + PageSize int + + StartTime *time.Time + EndTime *time.Time + ActorUserID *int64 + ActorEmail string + AuthMethod string + Action string + Method string + ClientIP string + // Success: nil 全部;true 仅 2xx/3xx;false 仅 >=400。 + Success *bool + // Query 对 path / action / actor_email 做模糊匹配。 + Query string +} + +// AuditLogList 分页结果。 +type AuditLogList struct { + Logs []*AuditLog + Total int + Page int + PageSize int +} + +// AuditLogRepository 审计日志持久化端口。 +// 注意:接口刻意不提供单条删除能力——审计日志只允许追加与全量清空。 +type AuditLogRepository interface { + BatchInsert(ctx context.Context, logs []*AuditLog) (int64, error) + // Insert 同步写入单条(用于清空留痕等必须落库的记录)。 + Insert(ctx context.Context, log *AuditLog) error + List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error) + GetByID(ctx context.Context, id int64) (*AuditLog, error) + Count(ctx context.Context) (int64, error) + // TruncateAll 全量清空(TRUNCATE),返回前需调用方自行 Count 记录行数。 + TruncateAll(ctx context.Context) error + // DeleteBefore 按保留期批量删除,返回本批删除行数(幂等,可多实例并发)。 + DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) +} + +// auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(小写)。 +var auditBodySensitiveExactKeys = map[string]struct{}{ + "code": {}, "codes": {}, "pin": {}, "cvv": {}, + "authorization": {}, "cookie": {}, "x-api-key": {}, + "key": {}, +} + +// auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(小写)。 +// 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。 +var auditBodySensitiveSubstrings = []string{ + "password", "passwd", "secret", "token", + "api_key", "apikey", "access_key", "private_key", + "otp", "totp", "credential_value", +} + +func isAuditSensitiveBodyKey(key string) bool { + k := strings.ToLower(strings.TrimSpace(key)) + if _, ok := auditBodySensitiveExactKeys[k]; ok { + return true + } + for _, sub := range auditBodySensitiveSubstrings { + if strings.Contains(k, sub) { + return true + } + } + return false +} + +const auditRedactedPlaceholder = "***" + +// RedactAuditBody 对请求体做审计入库前的脱敏: +// - JSON:递归擦除敏感键的值(保留结构,base_url 等非敏感字段可见以便追责) +// - 非 JSON:返回占位说明 +// - 超长:截断并附截断标记 +func RedactAuditBody(raw []byte, contentType string) string { + if len(raw) == 0 { + return "" + } + if len(raw) > auditRequestBodyCaptureLimit { + return "" + } + ct := strings.ToLower(contentType) + if !strings.Contains(ct, "json") || !json.Valid(raw) { + // 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。 + return "" + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "" + } + redacted := redactAuditValue(value, 0) + encoded, err := json.Marshal(redacted) + if err != nil { + return "" + } + out := string(encoded) + if len(out) > auditRequestBodyMaxBytes { + out = out[:auditRequestBodyMaxBytes] + "..." + } + return out +} + +const auditRedactMaxDepth = 24 + +func redactAuditValue(value any, depth int) any { + if depth > auditRedactMaxDepth { + return "" + } + switch v := value.(type) { + case map[string]any: + out := make(map[string]any, len(v)) + for k, item := range v { + if isAuditSensitiveBodyKey(k) { + out[k] = auditRedactedPlaceholder + continue + } + out[k] = redactAuditValue(item, depth+1) + } + return out + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = redactAuditValue(item, depth+1) + } + return out + default: + return value + } +} + +// MaskAuditCredential 对请求头中的凭证做首尾保留掩码: +// 保留前 6 位与后 4 位,中间以 **** 表示;过短的凭证整体掩码。 +func MaskAuditCredential(credential string) string { + credential = strings.TrimSpace(credential) + if credential == "" { + return "" + } + if len(credential) <= 14 { + return "****" + } + return credential[:6] + "****" + credential[len(credential)-4:] +} + +// RedactAuditQuery 对 URL query 做轻量脱敏后返回。 +func RedactAuditQuery(rawQuery string) string { + rawQuery = strings.TrimSpace(rawQuery) + if rawQuery == "" { + return "" + } + return logredact.RedactText(rawQuery, "api_key", "apikey", "token", "secret", "key") +} diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go new file mode 100644 index 0000000000..7ff895713d --- /dev/null +++ b/backend/internal/service/audit_log_service.go @@ -0,0 +1,237 @@ +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 + } + } +} diff --git a/backend/internal/service/audit_log_test.go b/backend/internal/service/audit_log_test.go new file mode 100644 index 0000000000..b4acc3c510 --- /dev/null +++ b/backend/internal/service/audit_log_test.go @@ -0,0 +1,127 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestMaskAuditCredential(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"short", "abc", "****"}, + {"boundary_14", "12345678901234", "****"}, + {"long", "sk-ant-api03-abcdefghijklmnop1234", "sk-ant****1234"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := MaskAuditCredential(tc.in) + if got != tc.want { + t.Fatalf("MaskAuditCredential(%q) = %q, want %q", tc.in, got, tc.want) + } + // 掩码结果绝不能包含原始凭证的中间部分。 + if len(tc.in) > 14 && strings.Contains(got, tc.in) { + t.Fatalf("masked value leaks full credential: %q", got) + } + }) + } +} + +func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) { + raw := []byte(`{ + "name": "acc1", + "base_url": "https://evil.example.com", + "credentials": {"api_key": "sk-secret-123", "base_url": "https://evil.example.com"}, + "new_password": "hunter2", + "totp_code": "123456", + "nested": [{"access_token": "tok_abc"}] + }`) + out := RedactAuditBody(raw, "application/json") + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + + // 敏感字段被擦除。 + for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "tok_abc"} { + if strings.Contains(out, secret) { + t.Fatalf("redacted body still contains secret %q: %s", secret, out) + } + } + // 非敏感字段(base_url、name)保留以便追责。 + if !strings.Contains(out, "evil.example.com") { + t.Fatalf("base_url should be preserved for accountability: %s", out) + } + if !strings.Contains(out, "acc1") { + t.Fatalf("name should be preserved: %s", out) + } +} + +func TestRedactAuditBody_NonJSONOmitted(t *testing.T) { + out := RedactAuditBody([]byte("username=admin&password=secret"), "application/x-www-form-urlencoded") + if strings.Contains(out, "secret") { + t.Fatalf("non-json body must not leak content: %s", out) + } + if !strings.Contains(out, "omitted") { + t.Fatalf("expected omission marker, got: %s", out) + } +} + +func TestRedactAuditBody_Empty(t *testing.T) { + if got := RedactAuditBody(nil, "application/json"); got != "" { + t.Fatalf("expected empty for nil body, got %q", got) + } +} + +func TestSessionBindingHash(t *testing.T) { + a := &SessionBinding{IP: "1.2.3.4", UserAgent: "Mozilla/5.0"} + b := &SessionBinding{IP: "1.2.3.4", UserAgent: "Mozilla/5.0"} + if a.Hash() != b.Hash() { + t.Fatalf("identical bindings must hash equal") + } + if a.Hash() == "" { + t.Fatalf("non-empty binding must produce non-empty hash") + } + + // IP 变化 → 哈希变化。 + c := &SessionBinding{IP: "5.6.7.8", UserAgent: "Mozilla/5.0"} + if a.Hash() == c.Hash() { + t.Fatalf("changing IP must change hash") + } + // UA 变化 → 哈希变化。 + d := &SessionBinding{IP: "1.2.3.4", UserAgent: "curl/8.0"} + if a.Hash() == d.Hash() { + t.Fatalf("changing UA must change hash") + } + + // 空指纹 → 空哈希(旧 token 兼容)。 + empty := &SessionBinding{} + if empty.Hash() != "" { + t.Fatalf("empty binding must hash to empty string") + } + var nilBinding *SessionBinding + if nilBinding.Hash() != "" { + t.Fatalf("nil binding must hash to empty string") + } +} + +func TestParseAuditLogRetentionDays(t *testing.T) { + cases := map[string]int{ + "": defaultAuditLogRetentionDays, + "abc": defaultAuditLogRetentionDays, + "90": 90, + "0": 0, + "-1": 0, + " 30 ": 30, + } + for in, want := range cases { + if got := parseAuditLogRetentionDays(in); got != want { + t.Fatalf("parseAuditLogRetentionDays(%q) = %d, want %d", in, got, want) + } + } +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index a4c67a24e3..6f97b1e85f 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -57,6 +57,10 @@ type JWTClaims struct { Email string `json:"email"` Role string `json:"role"` TokenVersion int64 `json:"token_version"` // Used to invalidate tokens on password change + // SessionID 会话 ID(与 refresh token family 对应),用于单会话撤销与 step-up 授权绑定。 + SessionID string `json:"sid,omitempty"` + // BindingHash 会话指纹哈希(IP+UA),会话绑定开启时校验;空值表示旧 token(平滑升级)。 + BindingHash string `json:"bnd,omitempty"` jwt.RegisteredClaims } @@ -265,7 +269,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } // 生成token - token, err := s.GenerateToken(user) + token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } @@ -460,7 +464,7 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string } // 生成JWT token - token, err := s.GenerateToken(user) + token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } @@ -561,7 +565,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err) } } - token, err := s.GenerateToken(user) + token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } @@ -1185,8 +1189,18 @@ func isReservedEmail(email string) bool { } // GenerateToken 生成JWT access token -// 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour -func (s *AuthService) GenerateToken(user *User) (string, error) { +// 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour。 +// 会话指纹(IP/UA)从 ctx 中提取(由 HTTP 入口中间件注入),缺失时生成不带绑定的 token。 +func (s *AuthService) GenerateToken(ctx context.Context, user *User) (string, error) { + sessionID, err := randomHexString(8) + if err != nil { + return "", fmt.Errorf("generate session id: %w", err) + } + return s.generateAccessToken(user, sessionID, sessionBindingHashFromContext(ctx)) +} + +// generateAccessToken 生成带会话 ID 与绑定指纹的 access token。 +func (s *AuthService) generateAccessToken(user *User, sessionID, bindingHash string) (string, error) { now := time.Now() var expiresAt time.Time if s.cfg.JWT.AccessTokenExpireMinutes > 0 { @@ -1201,6 +1215,8 @@ func (s *AuthService) GenerateToken(user *User) (string, error) { Email: user.Email, Role: user.Role, TokenVersion: resolvedTokenVersion(user), + SessionID: sessionID, + BindingHash: bindingHash, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(now), @@ -1270,8 +1286,16 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) ( return "", ErrTokenRevoked } + // 会话绑定检查:指纹变化的旧 token 不允许换发新 token。 + if s.settingService != nil && s.settingService.IsSessionBindingEnabled(ctx) && claims.BindingHash != "" { + if current := sessionBindingHashFromContext(ctx); current != "" && current != claims.BindingHash { + _ = s.RevokeSessionFamily(ctx, claims.SessionID) + return "", ErrSessionBindingMismatch + } + } + // 生成新token - return s.GenerateToken(user) + return s.GenerateToken(ctx, user) } // IsPasswordResetEnabled 检查是否启用密码重置功能 @@ -1449,8 +1473,18 @@ func (s *AuthService) GenerateTokenPair(ctx context.Context, user *User, familyI return nil, errors.New("refresh token cache not configured") } - // 生成Access Token - accessToken, err := s.GenerateToken(user) + // 提前确定家族ID:作为 access token 的会话ID(sid),保证同一会话的 + // access/refresh token 可以互相关联(单会话撤销、step-up 授权绑定)。 + if familyID == "" { + familyBytes := make([]byte, 16) + if _, err := rand.Read(familyBytes); err != nil { + return nil, fmt.Errorf("generate family id: %w", err) + } + familyID = hex.EncodeToString(familyBytes) + } + + // 生成Access Token(携带会话ID与绑定指纹) + accessToken, err := s.generateAccessToken(user, familyID, sessionBindingHashFromContext(ctx)) if err != nil { return nil, fmt.Errorf("generate access token: %w", err) } @@ -1496,6 +1530,7 @@ func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, fami UserID: user.ID, TokenVersion: resolvedTokenVersion(user), FamilyID: familyID, + BindingHash: sessionBindingHashFromContext(ctx), CreatedAt: now, ExpiresAt: now.Add(ttl), } @@ -1580,6 +1615,16 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) return nil, ErrTokenRevoked } + // 会话绑定检查:IP/UA 任一变化即撤销整个会话家族。 + // data.BindingHash 为空表示功能开启前签发的旧会话,放行并在轮转时补齐绑定。 + if s.settingService != nil && s.settingService.IsSessionBindingEnabled(ctx) && data.BindingHash != "" { + if current := sessionBindingHashFromContext(ctx); current != "" && current != data.BindingHash { + _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID) + logger.LegacyPrintf("service.auth", "[Auth] Session binding mismatch on refresh for user %d, family revoked", data.UserID) + return nil, ErrSessionBindingMismatch + } + } + // Token轮转:立即使旧Token失效 if err := s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to delete old refresh token: %v", err) @@ -1610,6 +1655,15 @@ func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken strin return s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash) } +// RevokeSessionFamily 撤销单个会话家族(该会话的所有 refresh token)。 +// 用于会话绑定失效等单会话级撤销场景,不影响用户的其他设备会话。 +func (s *AuthService) RevokeSessionFamily(ctx context.Context, familyID string) error { + if s.refreshTokenCache == nil || familyID == "" { + return nil + } + return s.refreshTokenCache.DeleteTokenFamily(ctx, familyID) +} + // RevokeAllUserSessions 撤销用户的所有会话(所有Refresh Token) // 用于密码更改或用户主动登出所有设备 func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID int64) error { diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 2ee9f21a56..af5bdff3e6 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -484,7 +484,7 @@ func TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError(t *testing.T) { Status: StatusActive, TokenVersion: 1, } - token, err := service.GenerateToken(user) + token, err := service.GenerateToken(context.Background(), user) require.NoError(t, err) // 验证有效 token @@ -495,7 +495,7 @@ func TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError(t *testing.T) { // 模拟过期 token(通过创建一个过期很久的 token) service.cfg.JWT.ExpireHour = -1 // 设置为负数使 token 立即过期 - expiredToken, err := service.GenerateToken(user) + expiredToken, err := service.GenerateToken(context.Background(), user) require.NoError(t, err) service.cfg.JWT.ExpireHour = 1 // 恢复 @@ -520,7 +520,7 @@ func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) { // 创建过期 token service.cfg.JWT.ExpireHour = -1 - expiredToken, err := service.GenerateToken(user) + expiredToken, err := service.GenerateToken(context.Background(), user) require.NoError(t, err) service.cfg.JWT.ExpireHour = 1 @@ -561,7 +561,7 @@ func TestAuthService_GenerateToken_UsesExpireHourWhenMinutesZero(t *testing.T) { TokenVersion: 1, } - token, err := service.GenerateToken(user) + token, err := service.GenerateToken(context.Background(), user) require.NoError(t, err) claims, err := service.ValidateToken(token) @@ -586,7 +586,7 @@ func TestAuthService_GenerateToken_UsesMinutesWhenConfigured(t *testing.T) { TokenVersion: 1, } - token, err := service.GenerateToken(user) + token, err := service.GenerateToken(context.Background(), user) require.NoError(t, err) claims, err := service.ValidateToken(token) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ee556ceb86..96a0a39c7c 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -169,6 +169,12 @@ const ( // TOTP 双因素认证设置 SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + // 会话安全设置 + SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认开启 + + // 操作审计日志设置 + SettingKeyAuditLogRetentionDays = "audit_log_retention_days" // 审计日志保留天数(<=0 永久保留),默认 180 + // LinuxDo Connect OAuth 登录设置 SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled" SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id" diff --git a/backend/internal/service/refresh_token_cache.go b/backend/internal/service/refresh_token_cache.go index 91b3924fc5..9d49495ac9 100644 --- a/backend/internal/service/refresh_token_cache.go +++ b/backend/internal/service/refresh_token_cache.go @@ -13,8 +13,9 @@ var ErrRefreshTokenNotFound = errors.New("refresh token not found") // RefreshTokenData 存储在Redis中的Refresh Token数据 type RefreshTokenData struct { UserID int64 `json:"user_id"` - TokenVersion int64 `json:"token_version"` // 用于检测密码更改后的Token失效 - FamilyID string `json:"family_id"` // Token家族ID,用于防重放攻击 + TokenVersion int64 `json:"token_version"` // 用于检测密码更改后的Token失效 + FamilyID string `json:"family_id"` // Token家族ID,用于防重放攻击 + BindingHash string `json:"binding_hash,omitempty"` // 会话指纹哈希(IP+UA),会话绑定开启时校验 CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } diff --git a/backend/internal/service/session_binding.go b/backend/internal/service/session_binding.go new file mode 100644 index 0000000000..d1f6637e02 --- /dev/null +++ b/backend/internal/service/session_binding.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// ErrSessionBindingMismatch 会话绑定的 IP/UA 发生变化,会话已失效。 +var ErrSessionBindingMismatch = infraerrors.Unauthorized("SESSION_BINDING_MISMATCH", "session network fingerprint changed, please login again") + +// SessionBinding 会话指纹:登录时的客户端 IP 与 User-Agent。 +// 会话绑定开启时,两者任一变化即导致会话失效(防止凭证被盗后异地重放)。 +type SessionBinding struct { + IP string + UserAgent string +} + +// Hash 计算绑定指纹哈希(IP 与 UA 合并,任一变化哈希即变化)。 +func (b *SessionBinding) Hash() string { + if b == nil { + return "" + } + ip := strings.TrimSpace(b.IP) + ua := strings.TrimSpace(b.UserAgent) + if ip == "" && ua == "" { + return "" + } + sum := sha256.Sum256([]byte(ip + "\n" + ua)) + return hex.EncodeToString(sum[:16]) +} + +type sessionBindingCtxKey struct{} + +// WithSessionBinding 将会话指纹注入 context(由 HTTP 入口中间件调用)。 +func WithSessionBinding(ctx context.Context, binding *SessionBinding) context.Context { + if binding == nil { + return ctx + } + return context.WithValue(ctx, sessionBindingCtxKey{}, binding) +} + +// SessionBindingFromContext 从 context 提取会话指纹;不存在时返回 nil。 +func SessionBindingFromContext(ctx context.Context) *SessionBinding { + if ctx == nil { + return nil + } + binding, _ := ctx.Value(sessionBindingCtxKey{}).(*SessionBinding) + return binding +} + +// sessionBindingHashFromContext 提取指纹哈希,缺失时返回空串。 +func sessionBindingHashFromContext(ctx context.Context) string { + return SessionBindingFromContext(ctx).Hash() +} diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 11c8af9dbc..8543ad6f01 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -179,6 +179,44 @@ func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { return s.cfg.Totp.EncryptionKeyConfigured } +// IsSessionBindingEnabled 检查会话 IP/UA 绑定是否启用(默认开启)。 +// 开启时会话与登录时的 IP/User-Agent 绑定,任一变化立即失效并撤销该会话。 +func (s *SettingService) IsSessionBindingEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeySessionBindingEnabled) + if err != nil { + return true // 默认开启 + } + return value != "false" +} + +// defaultAuditLogRetentionDays 审计日志默认保留天数。 +const defaultAuditLogRetentionDays = 180 + +// GetAuditLogRetentionDays 审计日志保留天数(<=0 表示永久保留,仅支持手动清空)。 +func (s *SettingService) GetAuditLogRetentionDays(ctx context.Context) int { + value, err := s.settingRepo.GetValue(ctx, SettingKeyAuditLogRetentionDays) + if err != nil { + return defaultAuditLogRetentionDays + } + return parseAuditLogRetentionDays(value) +} + +// parseAuditLogRetentionDays 解析保留天数配置,空/非法值回退默认值。 +func parseAuditLogRetentionDays(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return defaultAuditLogRetentionDays + } + n, err := strconv.Atoi(value) + if err != nil { + return defaultAuditLogRetentionDays + } + if n < 0 { + return 0 + } + return n +} + // GetSiteName 获取网站名称 func (s *SettingService) GetSiteName(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index cdbc0c9c68..a3aec1dd7f 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -260,6 +260,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] != "false", // 默认开启 + AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index f118ae7563..686f315a85 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -121,6 +121,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled) + updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays) settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) if settings.LoginAgreementUpdatedAt == "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 4f83eaba52..9cfae8a8c6 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -20,6 +20,8 @@ type SystemSettings struct { FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) + AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) LoginAgreementEnabled bool LoginAgreementMode string LoginAgreementUpdatedAt string diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go index 6a0989c3d0..0f4fa185fc 100644 --- a/backend/internal/service/totp_service.go +++ b/backend/internal/service/totp_service.go @@ -41,6 +41,10 @@ type TotpCache interface { IncrementVerifyAttempts(ctx context.Context, userID int64) (int, error) GetVerifyAttempts(ctx context.Context, userID int64) (int, error) ClearVerifyAttempts(ctx context.Context, userID int64) error + + // Step-up grant methods (敏感操作 sudo 窗口) + SetStepUpGrant(ctx context.Context, userID int64, sessionKey string, ttl time.Duration) error + HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) } // SecretEncryptor defines encryption operations for TOTP secrets @@ -401,6 +405,26 @@ func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string) return nil } +// StepUpGrantTTL 敏感操作 step-up 验证的有效窗口(sudo 模式)。 +const StepUpGrantTTL = 15 * time.Minute + +// VerifyStepUp 校验 TOTP 码并授予当前会话一段时间的 step-up 权限。 +// 返回授权有效期,供前端展示/设置提醒。 +func (s *TotpService) VerifyStepUp(ctx context.Context, userID int64, sessionKey, code string) (time.Duration, error) { + if err := s.VerifyCode(ctx, userID, code); err != nil { + return 0, err + } + if err := s.cache.SetStepUpGrant(ctx, userID, sessionKey, StepUpGrantTTL); err != nil { + return 0, fmt.Errorf("store step-up grant: %w", err) + } + return StepUpGrantTTL, nil +} + +// HasStepUpGrant 检查当前会话是否在 step-up 有效期内。 +func (s *TotpService) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) { + return s.cache.HasStepUpGrant(ctx, userID, sessionKey) +} + // CreateLoginSession creates a temporary login session for 2FA func (s *TotpService) CreateLoginSession(ctx context.Context, userID int64, email string) (string, error) { return s.createLoginSession(ctx, userID, email, nil) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index ba078308c1..efece756ef 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -433,6 +433,14 @@ func ProvideOpsSystemLogSink(opsRepo OpsRepository) *OpsSystemLogSink { return sink } +// ProvideAuditLogService 创建操作审计日志服务并启动异步写入与保留期清理协程。 +// 停止逻辑挂在 cmd/server 的 provideCleanup。 +func ProvideAuditLogService(repo AuditLogRepository, settingService *SettingService) *AuditLogService { + svc := NewAuditLogService(repo, settingService) + svc.Start() + return svc +} + func buildIdempotencyConfig(cfg *config.Config) IdempotencyConfig { idempotencyCfg := DefaultIdempotencyConfig() if cfg != nil { @@ -670,6 +678,7 @@ var ProviderSet = wire.NewSet( ProvideBackupService, ProvideOpsSystemLogSink, ProvideOpsService, + ProvideAuditLogService, ProvideOpsMetricsCollector, ProvideOpsAggregationService, ProvideOpsAlertEvaluatorService, diff --git a/backend/migrations/180_audit_logs.sql b/backend/migrations/180_audit_logs.sql new file mode 100644 index 0000000000..5c376c3760 --- /dev/null +++ b/backend/migrations/180_audit_logs.sql @@ -0,0 +1,35 @@ +-- 管理面操作审计日志(append-only) +-- 记录管理员/用户在管理面(非 AI 网关转发面)的操作: +-- 变更类请求 + 敏感读取(导出/备份下载等) + 认证事件(登录/2FA/会话绑定失效) +-- 设计约束: +-- 1. 不提供单条删除;仅支持带 2FA 验证的全量清空(TRUNCATE),清空后写入留痕记录 +-- 2. credential_masked 仅保存请求头凭证的首尾片段(中间以 * 遮蔽) +-- 3. request_body 为脱敏后的请求体(敏感键值已擦除、超长截断) +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + actor_user_id BIGINT, + actor_email VARCHAR(255) NOT NULL DEFAULT '', + actor_role VARCHAR(32) NOT NULL DEFAULT '', + auth_method VARCHAR(32) NOT NULL DEFAULT '', + credential_masked VARCHAR(160) NOT NULL DEFAULT '', + action VARCHAR(128) NOT NULL DEFAULT '', + method VARCHAR(16) NOT NULL DEFAULT '', + path VARCHAR(512) NOT NULL DEFAULT '', + request_id VARCHAR(64) NOT NULL DEFAULT '', + client_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(512) NOT NULL DEFAULT '', + request_body TEXT NOT NULL DEFAULT '', + status_code INT NOT NULL DEFAULT 0, + latency_ms BIGINT NOT NULL DEFAULT 0, + extra JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at_id + ON audit_logs (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created + ON audit_logs (actor_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action + ON audit_logs (action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_client_ip + ON audit_logs (client_ip); diff --git a/frontend/src/api/admin/audit.ts b/frontend/src/api/admin/audit.ts new file mode 100644 index 0000000000..e977a84390 --- /dev/null +++ b/frontend/src/api/admin/audit.ts @@ -0,0 +1,82 @@ +/** + * Admin operation audit log API. + * + * The audit log is admin-only (not exposed to end users). It records + * management-plane operations with masked header credentials and redacted + * request bodies. Entries cannot be deleted individually; the whole log can + * only be cleared with a fresh TOTP verification. + */ + +import { apiClient } from '../client' +import type { PaginatedResponse } from '@/types' + +export interface AuditLog { + id: number + created_at: string + actor_user_id?: number + actor_email: string + actor_role: string + auth_method: string + credential_masked: string + action: string + method: string + path: string + request_id: string + client_ip: string + user_agent: string + request_body?: string + status_code: number + latency_ms: number + extra?: Record +} + +export interface AuditLogQuery { + page?: number + page_size?: number + start_time?: string + end_time?: string + actor_user_id?: number + actor_email?: string + auth_method?: string + action?: string + method?: string + client_ip?: string + success?: string + q?: string +} + +export type AuditLogListResponse = PaginatedResponse + +/** + * List audit logs (paginated, filterable). + */ +export async function list(params: AuditLogQuery): Promise { + const { data } = await apiClient.get('/admin/audit-logs', { params }) + return data +} + +/** + * Get a single audit log entry (includes the redacted request body). + */ +export async function get(id: number): Promise { + const { data } = await apiClient.get(`/admin/audit-logs/${id}`) + return data +} + +/** + * Clear all audit logs. Requires a fresh TOTP code (verified server-side); + * unavailable when 2FA is not enabled for the operator. + * @param totpCode - current 6-digit TOTP code + */ +export async function clear(totpCode: string): Promise<{ deleted: number }> { + const { data } = await apiClient.post('/admin/audit-logs/clear', { totp_code: totpCode }) + return data +} + +export const auditAPI = { + list, + get, + clear +} + +export default auditAPI diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 1698a92c43..80a7073dec 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -33,6 +33,7 @@ import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' import adminComplianceAPI from './compliance' +import auditAPI from './audit' /** * Unified admin API object for convenient access @@ -67,7 +68,8 @@ export const adminAPI = { payment: adminPaymentAPI, affiliates: affiliatesAPI, riskControl: riskControlAPI, - compliance: adminComplianceAPI + compliance: adminComplianceAPI, + audit: auditAPI } export { @@ -100,12 +102,14 @@ export { adminPaymentAPI, affiliatesAPI, riskControlAPI, - adminComplianceAPI + adminComplianceAPI, + auditAPI } export default adminAPI // Re-export types used by components +export type { AuditLog, AuditLogQuery, AuditLogListResponse } from './audit' export type { BalanceHistoryItem } from './users' export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough' export type { BackupAgentHealth, DataManagementConfig } from './dataManagement' diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index faa6e03581..b68846be1f 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -366,6 +366,8 @@ export interface SystemSettings { invitation_code_enabled: boolean; totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 + session_binding_enabled: boolean; // 会话 IP/UA 绑定 + audit_log_retention_days: number; // 审计日志保留天数 login_agreement_enabled: boolean; login_agreement_mode: "modal" | "checkbox" | string; login_agreement_updated_at: string; @@ -669,6 +671,8 @@ export interface UpdateSettingsRequest { frontend_url?: string; invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 + session_binding_enabled?: boolean; // 会话 IP/UA 绑定 + audit_log_retention_days?: number; // 审计日志保留天数 login_agreement_enabled?: boolean; login_agreement_mode?: "modal" | "checkbox" | string; login_agreement_updated_at?: string; diff --git a/frontend/src/api/totp.ts b/frontend/src/api/totp.ts index cd658acbc3..5f21111a52 100644 --- a/frontend/src/api/totp.ts +++ b/frontend/src/api/totp.ts @@ -71,13 +71,32 @@ export async function disable(request: TotpDisableRequest): Promise<{ success: b return data } +/** + * Step-up verification response + */ +export interface TotpStepUpResponse { + verified: boolean + expires_in: number +} + +/** + * Verify a TOTP code to grant the current session a short-lived step-up + * (sudo) window for sensitive operations (account export, DB backup download...). + * @param code - 6-digit TOTP code + */ +export async function stepUp(code: string): Promise { + const { data } = await apiClient.post('/user/totp/step-up', { code }) + return data +} + export const totpAPI = { getStatus, getVerificationMethod, sendVerifyCode, initiateSetup, enable, - disable + disable, + stepUp } export default totpAPI diff --git a/frontend/src/components/auth/TotpStepUpDialog.vue b/frontend/src/components/auth/TotpStepUpDialog.vue new file mode 100644 index 0000000000..9bfdfb0212 --- /dev/null +++ b/frontend/src/components/auth/TotpStepUpDialog.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index ad78c2d298..c66a7bfbf6 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -800,7 +800,8 @@ const adminNavItems = computed((): NavItem[] => { { path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon }, ], }, - { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon } + { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, + { path: '/admin/audit-logs', label: t('nav.auditLogs'), icon: ShieldIcon, hideInSimpleMode: true } ] const visible = applyFeatureFlags(baseItems) diff --git a/frontend/src/composables/__tests__/useStepUp.spec.ts b/frontend/src/composables/__tests__/useStepUp.spec.ts new file mode 100644 index 0000000000..1afbdb0133 --- /dev/null +++ b/frontend/src/composables/__tests__/useStepUp.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { useStepUp, isStepUpRequired, isStepUpBlocked, stepUpBlockReason } from '../useStepUp' + +describe('useStepUp error classification', () => { + it('detects STEP_UP_REQUIRED from code field', () => { + expect(isStepUpRequired({ status: 403, code: 'STEP_UP_REQUIRED' })).toBe(true) + expect(isStepUpRequired({ status: 403, reason: 'STEP_UP_REQUIRED' })).toBe(true) + expect(isStepUpRequired({ status: 500, code: 'INTERNAL' })).toBe(false) + expect(isStepUpRequired(null)).toBe(false) + }) + + it('detects blocked (non-retryable) step-up errors', () => { + expect(isStepUpBlocked({ code: 'STEP_UP_TOTP_NOT_ENABLED' })).toBe(true) + expect(isStepUpBlocked({ reason: 'STEP_UP_ADMIN_API_KEY_FORBIDDEN' })).toBe(true) + expect(isStepUpBlocked({ code: 'STEP_UP_REQUIRED' })).toBe(false) + }) + + it('surfaces the block reason marker', () => { + expect(stepUpBlockReason({ reason: 'STEP_UP_ADMIN_API_KEY_FORBIDDEN' })).toBe('STEP_UP_ADMIN_API_KEY_FORBIDDEN') + expect(stepUpBlockReason({ code: 'OTHER' })).toBe('') + }) +}) + +describe('useStepUp.run', () => { + it('returns the action result directly on success', async () => { + const stepUp = useStepUp() + const result = await stepUp.run(async () => 42) + expect(result).toBe(42) + expect(stepUp.visible.value).toBe(false) + }) + + it('re-throws non-step-up errors without prompting', async () => { + const stepUp = useStepUp() + const err = { status: 500, code: 'INTERNAL' } + await expect(stepUp.run(async () => { throw err })).rejects.toBe(err) + expect(stepUp.visible.value).toBe(false) + }) + + it('re-throws blocked errors without prompting', async () => { + const stepUp = useStepUp() + const err = { status: 403, code: 'STEP_UP_TOTP_NOT_ENABLED' } + await expect(stepUp.run(async () => { throw err })).rejects.toBe(err) + expect(stepUp.visible.value).toBe(false) + }) + + it('prompts on STEP_UP_REQUIRED and retries after verification', async () => { + const stepUp = useStepUp() + let calls = 0 + const action = async () => { + calls++ + if (calls === 1) throw { status: 403, code: 'STEP_UP_REQUIRED' } + return 'ok' + } + const promise = stepUp.run(action) + // The dialog should now be open, awaiting verification (after the first rejection is handled). + await vi.waitFor(() => expect(stepUp.visible.value).toBe(true)) + stepUp.onVerified() + await expect(promise).resolves.toBe('ok') + expect(calls).toBe(2) + expect(stepUp.visible.value).toBe(false) + }) + + it('re-throws the original error if the user cancels the prompt', async () => { + const stepUp = useStepUp() + const err = { status: 403, code: 'STEP_UP_REQUIRED' } + const promise = stepUp.run(async () => { throw err }) + await vi.waitFor(() => expect(stepUp.visible.value).toBe(true)) + stepUp.onCancel() + await expect(promise).rejects.toBe(err) + }) +}) diff --git a/frontend/src/composables/useStepUp.ts b/frontend/src/composables/useStepUp.ts new file mode 100644 index 0000000000..ba6123cd18 --- /dev/null +++ b/frontend/src/composables/useStepUp.ts @@ -0,0 +1,109 @@ +/** + * Step-up (sudo) 2FA composable. + * + * Wraps a sensitive admin action so that when the backend responds with a + * STEP_UP_REQUIRED error, the caller can prompt for a TOTP code, obtain a + * short-lived grant, and transparently retry the original action. + * + * Usage in a view: + * const stepUp = useStepUp() + * async function exportData() { + * await stepUp.run(() => adminAPI.accounts.exportData(...)) + * } + * // template: + */ +import { ref } from 'vue' + +/** Error codes the backend uses to signal step-up state. */ +const STEP_UP_REQUIRED = 'STEP_UP_REQUIRED' +const STEP_UP_TOTP_NOT_ENABLED = 'STEP_UP_TOTP_NOT_ENABLED' +const STEP_UP_ADMIN_API_KEY_FORBIDDEN = 'STEP_UP_ADMIN_API_KEY_FORBIDDEN' + +interface ApiError { + status?: number + code?: string | number + reason?: string + message?: string +} + +/** Extract the semantic error marker from either envelope shape (code or reason). */ +function markerOf(err: unknown): string { + const e = (err ?? {}) as ApiError + const candidates = [e.code, e.reason].map((v) => (typeof v === 'string' ? v : '')) + return candidates.find((v) => v.startsWith('STEP_UP')) || '' +} + +export function isStepUpRequired(err: unknown): boolean { + return markerOf(err) === STEP_UP_REQUIRED +} + +export function isStepUpBlocked(err: unknown): boolean { + const m = markerOf(err) + return m === STEP_UP_TOTP_NOT_ENABLED || m === STEP_UP_ADMIN_API_KEY_FORBIDDEN +} + +export function stepUpBlockReason(err: unknown): string { + return markerOf(err) +} + +export type StepUpController = ReturnType + +export function useStepUp() { + const visible = ref(false) + const blockedReason = ref('') + let resolver: ((ok: boolean) => void) | null = null + + /** Open the TOTP dialog and resolve true once a grant is obtained. */ + function prompt(): Promise { + visible.value = true + return new Promise((resolve) => { + resolver = resolve + }) + } + + function onVerified() { + visible.value = false + resolver?.(true) + resolver = null + } + + function onCancel() { + visible.value = false + resolver?.(false) + resolver = null + } + + /** + * Run a sensitive action. On STEP_UP_REQUIRED, prompt for a TOTP code and + * retry once. STEP_UP_TOTP_NOT_ENABLED / admin-api-key errors are surfaced + * to the caller (they cannot be resolved by entering a code). + */ + async function run(action: () => Promise): Promise { + try { + return await action() + } catch (err) { + if (isStepUpBlocked(err)) { + blockedReason.value = markerOf(err) + throw err + } + if (!isStepUpRequired(err)) { + throw err + } + const ok = await prompt() + if (!ok) { + throw err + } + // Retry once now that the session holds a step-up grant. + return await action() + } + } + + return { + visible, + blockedReason, + prompt, + onVerified, + onCancel, + run + } +} diff --git a/frontend/src/i18n/locales/en/admin/audit.ts b/frontend/src/i18n/locales/en/admin/audit.ts new file mode 100644 index 0000000000..10c48ebb8f --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/audit.ts @@ -0,0 +1,52 @@ +export default { + audit: { + title: 'Audit Logs', + description: 'Records management-plane operations by admins and users. Header credentials keep only their first/last characters and request bodies are redacted. Entries cannot be deleted individually; clearing all requires two-factor verification.', + clearAll: 'Clear All', + empty: 'No audit logs yet', + loadFailed: 'Failed to load audit logs', + filters: { + all: 'All', + q: 'Keyword', + qPlaceholder: 'Path / action / actor email', + actorEmail: 'Actor Email', + action: 'Action', + clientIp: 'Client IP', + method: 'Method', + authMethod: 'Auth Method', + result: 'Result', + resultSuccess: 'Success', + resultFailure: 'Failure', + startTime: 'Start Time', + endTime: 'End Time' + }, + columns: { + time: 'Time', + actor: 'Actor', + action: 'Action', + method: 'Method', + result: 'Result', + clientIp: 'Client IP', + detail: 'Detail' + }, + detail: { + title: 'Audit Log Detail', + actorRole: 'Role', + methodPath: 'Method / Path', + latency: 'Latency', + requestId: 'Request ID', + credential: 'Credential (masked)', + userAgent: 'User-Agent', + requestBody: 'Request Body (redacted)', + extra: 'Extra' + }, + clearConfirm: { + title: 'Clear All Audit Logs', + message: 'This permanently deletes all audit logs and cannot be undone. The clear action itself is recorded. Continue?', + totpTitle: 'Enter Two-Factor Code', + totpHint: 'Clearing audit logs requires a fresh TOTP verification.', + success: 'Cleared {count} audit log(s)', + failed: 'Failed to clear audit logs' + } + } +} diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts index e4e9bba438..52e9231c75 100644 --- a/frontend/src/i18n/locales/en/admin/index.ts +++ b/frontend/src/i18n/locales/en/admin/index.ts @@ -4,6 +4,7 @@ import accounts from './accounts' import resources from './resources' import ops from './ops' import settings from './settings' +import audit from './audit' export default { ...overview, @@ -12,4 +13,5 @@ export default { ...resources, ...ops, ...settings, + ...audit, } diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 345c45201d..d6bfb88e07 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -126,6 +126,12 @@ export default { totpKeyNotConfigured: 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' }, + security: { + sessionBinding: 'Session IP/UA Binding', + sessionBindingHint: 'Bind login sessions to the client IP and User-Agent. Any change immediately invalidates the session and forces re-login, raising the bar for stolen-credential reuse.', + auditRetention: 'Audit Log Retention (days)', + auditRetentionHint: 'Audit logs older than this are cleaned up automatically. Set to 0 to keep them forever (manual clear only).' + }, turnstile: { title: 'Cloudflare Turnstile', description: 'Bot protection for login and registration', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index ac0b8dfcae..3f9ca1dde6 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -190,6 +190,7 @@ export default { channelMonitor: 'Channel Monitor', channelStatus: 'Channel Status', riskControl: 'Risk Control', + auditLogs: 'Audit Logs', }, // Auth @@ -413,5 +414,14 @@ export default { invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.' }, + // Step-up (sudo) 2FA prompt + stepUp: { + title: 'Two-Factor Verification Required', + hint: 'Enter the 6-digit code from your authenticator app to continue this sensitive operation.', + verifyFailed: 'Verification failed, please try again', + notEnabled: 'This operation requires two-factor authentication. Please enable TOTP in your profile first.', + adminApiKeyForbidden: 'Admin API keys cannot perform this operation. Use a two-factor verified admin session.' + }, + // Dashboard } diff --git a/frontend/src/i18n/locales/zh/admin/audit.ts b/frontend/src/i18n/locales/zh/admin/audit.ts new file mode 100644 index 0000000000..644e2e2592 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/audit.ts @@ -0,0 +1,52 @@ +export default { + audit: { + title: '操作日志', + description: '记录管理员与用户的管理面操作,请求头凭证仅保留首尾、请求体已脱敏。日志无法单条删除,全量清理需二次验证。', + clearAll: '全部清理', + empty: '暂无操作日志', + loadFailed: '加载操作日志失败', + filters: { + all: '全部', + q: '关键字', + qPlaceholder: '路径 / 动作 / 操作者邮箱', + actorEmail: '操作者邮箱', + action: '动作', + clientIp: '客户端 IP', + method: '请求方法', + authMethod: '认证方式', + result: '结果', + resultSuccess: '成功', + resultFailure: '失败', + startTime: '开始时间', + endTime: '结束时间' + }, + columns: { + time: '时间', + actor: '操作者', + action: '动作', + method: '方法', + result: '结果', + clientIp: '客户端 IP', + detail: '详情' + }, + detail: { + title: '操作日志详情', + actorRole: '角色', + methodPath: '方法 / 路径', + latency: '耗时', + requestId: '请求 ID', + credential: '凭证(掩码)', + userAgent: 'User-Agent', + requestBody: '请求体(已脱敏)', + extra: '附加信息' + }, + clearConfirm: { + title: '清理全部操作日志', + message: '此操作将永久删除所有操作日志,且不可恢复。清理动作本身会被留痕记录。确定继续吗?', + totpTitle: '输入二次验证码', + totpHint: '清理操作日志需要现场验证 TOTP 验证码。', + success: '已清理 {count} 条操作日志', + failed: '清理操作日志失败' + } + } +} diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts index e4e9bba438..52e9231c75 100644 --- a/frontend/src/i18n/locales/zh/admin/index.ts +++ b/frontend/src/i18n/locales/zh/admin/index.ts @@ -4,6 +4,7 @@ import accounts from './accounts' import resources from './resources' import ops from './ops' import settings from './settings' +import audit from './audit' export default { ...overview, @@ -12,4 +13,5 @@ export default { ...resources, ...ops, ...settings, + ...audit, } diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 351a079cdd..b62ffb5085 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -126,6 +126,12 @@ export default { totpKeyNotConfigured: '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' }, + security: { + sessionBinding: '会话 IP/UA 绑定', + sessionBindingHint: '将登录会话与客户端 IP 和 User-Agent 绑定,任一变化即强制该会话失效并需重新登录(提升被盗凭证的利用门槛)。', + auditRetention: '操作日志保留天数', + auditRetentionHint: '超过该天数的操作日志将被自动清理;填 0 表示永久保留(仅支持手动清空)。' + }, turnstile: { title: 'Cloudflare Turnstile', description: '登录和注册的机器人防护', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 44fafef7a4..62a354a670 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -190,6 +190,7 @@ export default { channelMonitor: '渠道监控', channelStatus: '渠道状态', riskControl: '风控中心', + auditLogs: '操作日志', }, // Auth @@ -412,5 +413,14 @@ export default { invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。' }, + // Step-up(敏感操作二次验证) + stepUp: { + title: '需要二次验证', + hint: '请输入身份验证器应用中的 6 位验证码以继续此敏感操作。', + verifyFailed: '验证失败,请重试', + notEnabled: '此操作需要开启二次验证,请先在个人资料中启用 TOTP。', + adminApiKeyForbidden: '管理 API Key 无法执行此操作,请使用已通过二次验证的管理员会话。' + }, + // Dashboard } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e108d9d7b9..657d25f8d7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -415,6 +415,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.ops.description' } }, + { + path: '/admin/audit-logs', + name: 'AdminAuditLogs', + component: () => import('@/views/admin/AuditLogView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Audit Logs', + titleKey: 'admin.audit.title', + descriptionKey: 'admin.audit.description' + } + }, { path: '/admin/users', name: 'AdminUsers', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 688ca50027..c784d82c17 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -475,6 +475,7 @@ + @@ -488,6 +489,8 @@ import { adminAPI } from '@/api/admin' import { useTableLoader } from '@/composables/useTableLoader' import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect' import { useTableSelection } from '@/composables/useTableSelection' +import { useStepUp, isStepUpBlocked, stepUpBlockReason } from '@/composables/useStepUp' +import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' import DataTable from '@/components/common/DataTable.vue' @@ -1797,14 +1800,14 @@ const handleExportData = async () => { if (exportingData.value) return exportingData.value = true try { - const dataPayload = await adminAPI.accounts.exportData( + const dataPayload = await accountExportStepUp.run(() => adminAPI.accounts.exportData( selIds.value.length > 0 ? { ids: selIds.value, includeProxies: includeProxyOnExport.value } : { includeProxies: includeProxyOnExport.value, filters: buildAccountQueryFilters() } - ) + )) const timestamp = formatExportTimestamp() const filename = `sub2api-account-${timestamp}.json` const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' }) @@ -1822,12 +1825,21 @@ const handleExportData = async () => { appStore.showSuccess(t('admin.accounts.dataExported')) } } catch (error: any) { - appStore.showError(error?.message || t('admin.accounts.dataExportFailed')) + if (isStepUpBlocked(error)) { + appStore.showError( + stepUpBlockReason(error) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN' + ? t('stepUp.adminApiKeyForbidden') + : t('stepUp.notEnabled') + ) + } else { + appStore.showError(error?.message || t('admin.accounts.dataExportFailed')) + } } finally { exportingData.value = false showExportDataDialog.value = false } } +const accountExportStepUp = useStepUp() const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null } diff --git a/frontend/src/views/admin/AuditLogView.vue b/frontend/src/views/admin/AuditLogView.vue new file mode 100644 index 0000000000..7419c170c4 --- /dev/null +++ b/frontend/src/views/admin/AuditLogView.vue @@ -0,0 +1,394 @@ + + + diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 903dfe321e..4a2a3cd83a 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -276,6 +276,7 @@ +