mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #4418 from Wei-Shaw/feat/audit-log-and-credential-hardening
feat(security): 操作审计日志 + 会话IP/UA绑定 + 敏感操作 step-up 2FA
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // channelMonitorRunner
|
||||
nil, // quotaFlusher
|
||||
nil, // upstreamBillingProbe
|
||||
nil, // auditLog
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -38,6 +38,7 @@ type AdminHandlers struct {
|
||||
Payment *admin.PaymentHandler
|
||||
Affiliate *admin.AffiliateHandler
|
||||
Compliance *admin.ComplianceHandler
|
||||
AuditLog *admin.AuditLogHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -103,9 +104,13 @@ func (s *S3BackupStore) Delete(ctx context.Context, key string) error {
|
||||
|
||||
func (s *S3BackupStore) PresignURL(ctx context.Context, key string, expiry time.Duration) (string, error) {
|
||||
presignClient := s3.NewPresignClient(s.client)
|
||||
// 强制 attachment disposition:浏览器同页导航该 URL 时直接触发下载而非渲染,
|
||||
// 前端无需依赖会被弹窗拦截的新标签页。
|
||||
disposition := fmt.Sprintf("attachment; filename=%q", path.Base(key))
|
||||
result, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
ResponseContentDisposition: &disposition,
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign url: %w", err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewDashboardAggregationRepository,
|
||||
NewSettingRepository,
|
||||
NewOpsRepository,
|
||||
NewAuditLogRepository,
|
||||
NewUserSubscriptionRepository,
|
||||
NewUserAttributeDefinitionRepository,
|
||||
NewUserAttributeValueRepository,
|
||||
|
||||
@@ -708,6 +708,8 @@ func TestAPIContracts(t *testing.T) {
|
||||
"frontend_url": "",
|
||||
"totp_enabled": false,
|
||||
"totp_encryption_key_configured": false,
|
||||
"session_binding_enabled": true,
|
||||
"audit_log_retention_days": 180,
|
||||
"login_agreement_enabled": false,
|
||||
"login_agreement_mode": "modal",
|
||||
"login_agreement_updated_at": "2026-03-31",
|
||||
@@ -1020,6 +1022,8 @@ func TestAPIContracts(t *testing.T) {
|
||||
"invitation_code_enabled": false,
|
||||
"totp_enabled": false,
|
||||
"totp_encryption_key_configured": false,
|
||||
"session_binding_enabled": true,
|
||||
"audit_log_retention_days": 180,
|
||||
"login_agreement_enabled": false,
|
||||
"login_agreement_mode": "modal",
|
||||
"login_agreement_updated_at": "2026-03-31",
|
||||
|
||||
@@ -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 服务器
|
||||
|
||||
@@ -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.<token>
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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",
|
||||
}
|
||||
|
||||
// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
|
||||
// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
|
||||
var auditBodyOmittedRoutes = map[string]struct{}{
|
||||
"POST /api/v1/admin/accounts/import/codex-session": {},
|
||||
}
|
||||
|
||||
// NewAuditLogMiddleware 创建审计中间件。
|
||||
// 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。
|
||||
// 挂载位置:admin / user / admin-payment 组挂在各自认证中间件之后(只审计已认证请求,
|
||||
// 未过认证的 401/403 不入库);auth 组(登录/注册/刷新)无前置认证,天然记录失败尝试。
|
||||
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 _, omit := auditBodyOmittedRoutes[routeKey]; omit {
|
||||
bodyRedacted = "<credential-bearing body omitted>"
|
||||
} else if c.Request.Body != nil && c.Request.Method != "GET" {
|
||||
orig := c.Request.Body
|
||||
raw, err := io.ReadAll(io.LimitReader(orig, service.AuditRequestBodyCaptureLimit+1))
|
||||
if err == nil {
|
||||
c.Request.Body = &restoredBody{
|
||||
Reader: io.MultiReader(bytes.NewReader(raw), orig),
|
||||
closer: orig,
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// restoredBody 把审计中间件按上限读出的前缀与未读完的原始 body 拼接回填,
|
||||
// 保证 handler 读到完整请求体;Close 委托给原始 body。
|
||||
type restoredBody struct {
|
||||
io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (b *restoredBody) Close() error { return b.closer.Close() }
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewJWTAuthMiddleware,
|
||||
NewAdminAuthMiddleware,
|
||||
NewAPIKeyAuthMiddleware,
|
||||
NewAuditLogMiddleware,
|
||||
NewStepUpAuthMiddleware,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// auditNormalizeBodyKey 归一化键名:小写并去除分隔符,
|
||||
// 使 private_key / privateKey / privatekey / api-v3-key 等写法共享同一判定,
|
||||
// 避免子串清单假设 snake_case 而漏掉支付渠道等无分隔符风格的密钥字段。
|
||||
func auditNormalizeBodyKey(key string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(key))
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(key)) {
|
||||
switch r {
|
||||
case '_', '-', '.', ' ':
|
||||
continue
|
||||
default:
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(归一化后)。
|
||||
// 除内置清单外,程序化并入两份权威敏感表以防清单漂移:
|
||||
// - SensitiveCredentialKeys:账号 credentials 的敏感子键(session_key / service_account_json 等)
|
||||
// - providerSensitiveConfigFields:支付渠道密钥字段(pkey / privatekey / apiv3key 等)
|
||||
var auditBodySensitiveExactKeys = func() map[string]struct{} {
|
||||
builtin := []string{
|
||||
"code", "codes", "pin", "cvv",
|
||||
"authorization", "cookie", "x-api-key",
|
||||
"key",
|
||||
// 字符串值内嵌完整凭证的字段:
|
||||
// proxy_key 为 protocol|host|port|username|password 拼接,
|
||||
// custom_key 为用户自设的平台 API Key 明文。
|
||||
"proxy_key", "custom_key",
|
||||
}
|
||||
set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16)
|
||||
for _, k := range builtin {
|
||||
set[auditNormalizeBodyKey(k)] = struct{}{}
|
||||
}
|
||||
for _, k := range SensitiveCredentialKeys {
|
||||
set[auditNormalizeBodyKey(k)] = struct{}{}
|
||||
}
|
||||
for _, fields := range providerSensitiveConfigFields {
|
||||
for k := range fields {
|
||||
set[auditNormalizeBodyKey(k)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}()
|
||||
|
||||
// auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(对归一化后的键名比对)。
|
||||
// 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。
|
||||
var auditBodySensitiveSubstrings = []string{
|
||||
"password", "passwd", "secret", "token",
|
||||
"apikey", "accesskey", "privatekey",
|
||||
"otp", "credentialvalue",
|
||||
"sessionkey", "serviceaccount",
|
||||
}
|
||||
|
||||
func isAuditSensitiveBodyKey(key string) bool {
|
||||
k := auditNormalizeBodyKey(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 {
|
||||
// raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。
|
||||
return "<body omitted: exceeds " + strconv.Itoa(AuditRequestBodyCaptureLimit) + " bytes>"
|
||||
}
|
||||
ct := strings.ToLower(contentType)
|
||||
if !strings.Contains(ct, "json") || !json.Valid(raw) {
|
||||
// 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。
|
||||
return "<non-json body omitted: " + strconv.Itoa(len(raw)) + " bytes, content-type=" + strings.TrimSpace(contentType) + ">"
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return "<unparsable body omitted>"
|
||||
}
|
||||
redacted := redactAuditValue(value, 0)
|
||||
encoded, err := json.Marshal(redacted)
|
||||
if err != nil {
|
||||
return "<redacted>"
|
||||
}
|
||||
out := string(encoded)
|
||||
if len(out) > auditRequestBodyMaxBytes {
|
||||
out = out[:auditRequestBodyMaxBytes] + "...<truncated>"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const auditRedactMaxDepth = 24
|
||||
|
||||
func redactAuditValue(value any, depth int) any {
|
||||
if depth > auditRedactMaxDepth {
|
||||
return "<depth limit exceeded>"
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactAuditBody_AuthoritativeTablesSynced 覆盖曾经漏网的凭证字段:
|
||||
// 账号 credentials 敏感子键、支付渠道无分隔符密钥、字符串值内嵌凭证的 proxy_key / custom_key,
|
||||
// 以及 camelCase 等命名变体(归一化比对)。
|
||||
func TestRedactAuditBody_AuthoritativeTablesSynced(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"credentials": {
|
||||
"session_key": "sk-session-aaa",
|
||||
"service_account_json": "{\"private_key\":\"pem-body-bbb\"}",
|
||||
"service_account": "sa-blob-ccc"
|
||||
},
|
||||
"proxy_key": "socks5|1.2.3.4|1080|proxyuser|proxypass-ddd",
|
||||
"custom_key": "sk-custom-eee",
|
||||
"config": {
|
||||
"pkey": "easypay-merchant-fff",
|
||||
"privateKey": "alipay-pem-ggg",
|
||||
"apiv3key": "wxpay-v3-hhh",
|
||||
"SecretKey": "stripe-sk-iii",
|
||||
"webhookSecret": "whsec-jjj"
|
||||
},
|
||||
"provider_key": "stripe",
|
||||
"name": "instance-1"
|
||||
}`)
|
||||
out := RedactAuditBody(raw, "application/json")
|
||||
|
||||
for _, secret := range []string{
|
||||
"sk-session-aaa", "pem-body-bbb", "sa-blob-ccc",
|
||||
"proxypass-ddd", "sk-custom-eee",
|
||||
"easypay-merchant-fff", "alipay-pem-ggg", "wxpay-v3-hhh",
|
||||
"stripe-sk-iii", "whsec-jjj",
|
||||
} {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("redacted body still contains secret %q: %s", secret, out)
|
||||
}
|
||||
}
|
||||
// provider_key 是渠道标识而非密钥,必须保留以便追责。
|
||||
if !strings.Contains(out, `"provider_key":"stripe"`) {
|
||||
t.Fatalf("provider_key should be preserved for accountability: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "instance-1") {
|
||||
t.Fatalf("name should be preserved: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// SensitiveCredentialKeys 中的每个键都必须被审计脱敏判定命中(防两表漂移的守卫)。
|
||||
func TestAuditSensitiveKeys_CoverCredentialTable(t *testing.T) {
|
||||
for _, k := range SensitiveCredentialKeys {
|
||||
if !isAuditSensitiveBodyKey(k) {
|
||||
t.Fatalf("credential key %q is not covered by audit redaction", k)
|
||||
}
|
||||
}
|
||||
for provider, fields := range providerSensitiveConfigFields {
|
||||
for k := range fields {
|
||||
if !isAuditSensitiveBodyKey(k) {
|
||||
t.Fatalf("payment provider %q sensitive field %q is not covered by audit redaction", provider, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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<string, any>
|
||||
}
|
||||
|
||||
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<AuditLog>
|
||||
|
||||
/**
|
||||
* List audit logs (paginated, filterable).
|
||||
*/
|
||||
export async function list(params: AuditLogQuery): Promise<AuditLogListResponse> {
|
||||
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<AuditLog> {
|
||||
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
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TotpStepUpResponse> {
|
||||
const { data } = await apiClient.post<TotpStepUpResponse>('/user/totp/step-up', { code })
|
||||
return data
|
||||
}
|
||||
|
||||
export const totpAPI = {
|
||||
getStatus,
|
||||
getVerificationMethod,
|
||||
sendVerifyCode,
|
||||
initiateSetup,
|
||||
enable,
|
||||
disable
|
||||
disable,
|
||||
stepUp
|
||||
}
|
||||
|
||||
export default totpAPI
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div v-if="controller.visible.value" class="fixed inset-0 z-[60] overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="handleCancel"></div>
|
||||
|
||||
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
|
||||
<div class="mb-6 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('stepUp.title') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('stepUp.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<input
|
||||
ref="hiddenOtpInputRef"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
class="pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@input="handleHiddenOtpInput"
|
||||
/>
|
||||
<div class="flex justify-center gap-2">
|
||||
<input
|
||||
v-for="(_, index) in 6"
|
||||
:key="index"
|
||||
:ref="(el) => setInputRef(el, index)"
|
||||
type="text"
|
||||
maxlength="1"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]"
|
||||
autocomplete="off"
|
||||
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
|
||||
:disabled="verifying"
|
||||
@input="handleCodeInput($event, index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
|
||||
{{ t('common.verifying') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="verifying"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { StepUpController } from '@/composables/useStepUp'
|
||||
|
||||
const props = defineProps<{
|
||||
controller: StepUpController
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const verifying = ref(false)
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const hiddenOtpInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Focus the first cell whenever the dialog opens.
|
||||
watch(
|
||||
() => props.controller.visible.value,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetInputs()
|
||||
nextTick(() => inputRefs.value[0]?.focus())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Auto-submit once 6 digits are entered.
|
||||
watch(
|
||||
() => code.value.join(''),
|
||||
(newCode) => {
|
||||
if (newCode.length === 6 && !verifying.value) {
|
||||
submit(newCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function submit(otp: string) {
|
||||
verifying.value = true
|
||||
try {
|
||||
await totpAPI.stepUp(otp)
|
||||
verifying.value = false
|
||||
resetInputs()
|
||||
props.controller.onVerified()
|
||||
} catch (err: any) {
|
||||
verifying.value = false
|
||||
appStore.showError(err?.message || t('stepUp.verifyFailed'))
|
||||
resetInputs()
|
||||
nextTick(() => inputRefs.value[0]?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
function resetInputs() {
|
||||
code.value = ['', '', '', '', '', '']
|
||||
inputRefs.value.forEach((input) => {
|
||||
if (input) input.value = ''
|
||||
})
|
||||
if (hiddenOtpInputRef.value) hiddenOtpInputRef.value.value = ''
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (verifying.value) return
|
||||
props.controller.onCancel()
|
||||
}
|
||||
|
||||
const setInputRef = (el: any, index: number) => {
|
||||
inputRefs.value[index] = el as HTMLInputElement | null
|
||||
}
|
||||
|
||||
const handleCodeInput = (event: Event, index: number) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value.replace(/[^0-9]/g, '')
|
||||
code.value[index] = value
|
||||
if (value && index < 5) {
|
||||
nextTick(() => inputRefs.value[index + 1]?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const handleHiddenOtpInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const digits = input.value.replace(/[^0-9]/g, '').slice(0, 6).split('')
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code.value[i] = digits[i] || ''
|
||||
if (inputRefs.value[i]) inputRefs.value[i]!.value = digits[i] || ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent, index: number) => {
|
||||
if (event.key === 'Backspace') {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.value && index > 0) {
|
||||
event.preventDefault()
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code.value[i] = digits[i] || ''
|
||||
if (inputRefs.value[i]) inputRefs.value[i]!.value = digits[i] || ''
|
||||
}
|
||||
const focusIndex = Math.min(digits.length, 5)
|
||||
nextTick(() => inputRefs.value[focusIndex]?.focus())
|
||||
}
|
||||
</script>
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
useStepUp,
|
||||
isStepUpRequired,
|
||||
isStepUpBlocked,
|
||||
isStepUpCancelled,
|
||||
stepUpBlockReason,
|
||||
StepUpCancelledError
|
||||
} 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('throws a cancellation sentinel (not 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.toBeInstanceOf(StepUpCancelledError)
|
||||
expect(stepUp.visible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies the cancellation sentinel distinctly from step-up errors', () => {
|
||||
const cancelled = new StepUpCancelledError()
|
||||
expect(isStepUpCancelled(cancelled)).toBe(true)
|
||||
expect(isStepUpRequired(cancelled)).toBe(false)
|
||||
expect(isStepUpBlocked(cancelled)).toBe(false)
|
||||
expect(isStepUpCancelled({ status: 403, code: 'STEP_UP_REQUIRED' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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: <TotpStepUpDialog :controller="stepUp" />
|
||||
*/
|
||||
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'
|
||||
|
||||
/**
|
||||
* Thrown by run() when the user dismisses the TOTP dialog.
|
||||
* Callers should treat it as a silent no-op, not an error to toast.
|
||||
*/
|
||||
export class StepUpCancelledError extends Error {
|
||||
readonly code = 'STEP_UP_CANCELLED'
|
||||
constructor() {
|
||||
super('step-up verification cancelled by user')
|
||||
this.name = 'StepUpCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isStepUpCancelled(err: unknown): boolean {
|
||||
return err instanceof StepUpCancelledError
|
||||
}
|
||||
|
||||
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<typeof useStepUp>
|
||||
|
||||
export function useStepUp() {
|
||||
const visible = ref(false)
|
||||
const blockedReason = ref<string>('')
|
||||
let resolver: ((ok: boolean) => void) | null = null
|
||||
|
||||
/** Open the TOTP dialog and resolve true once a grant is obtained. */
|
||||
function prompt(): Promise<boolean> {
|
||||
visible.value = true
|
||||
return new Promise<boolean>((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). If the user
|
||||
* cancels the prompt, a StepUpCancelledError is thrown so callers can
|
||||
* distinguish "user changed their mind" from real failures.
|
||||
*/
|
||||
async function run<T>(action: () => Promise<T>): Promise<T> {
|
||||
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 new StepUpCancelledError()
|
||||
}
|
||||
// Retry once now that the session holds a step-up grant.
|
||||
return await action()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
blockedReason,
|
||||
prompt,
|
||||
onVerified,
|
||||
onCancel,
|
||||
run
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '清理操作日志失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: '登录和注册的机器人防护',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -475,6 +475,7 @@
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
<TLSFingerprintProfilesModal :show="showTLSFingerprintProfiles" @close="showTLSFingerprintProfiles = false" />
|
||||
<TotpStepUpDialog :controller="accountExportStepUp" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -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, isStepUpCancelled, 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,23 @@ const handleExportData = async () => {
|
||||
appStore.showSuccess(t('admin.accounts.dataExported'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
|
||||
if (isStepUpCancelled(error)) {
|
||||
// 用户主动取消 step-up 验证,静默返回,不弹错误提示。
|
||||
} else 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 }
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('admin.audit.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.description') }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="openClearDialog">
|
||||
{{ t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.q') }}</span>
|
||||
<input v-model.trim="filters.q" class="input" :placeholder="t('admin.audit.filters.qPlaceholder')" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.actorEmail') }}</span>
|
||||
<input v-model.trim="filters.actor_email" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.action') }}</span>
|
||||
<input v-model.trim="filters.action" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.clientIp') }}</span>
|
||||
<input v-model.trim="filters.client_ip" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.method') }}</span>
|
||||
<Select v-model="filters.method" :options="methodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.authMethod') }}</span>
|
||||
<Select v-model="filters.auth_method" :options="authMethodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.result') }}</span>
|
||||
<Select v-model="filters.success" :options="resultOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.startTime') }}</span>
|
||||
<input v-model="filters.start_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.endTime') }}</span>
|
||||
<input v-model="filters.end_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="loading" @click="search">
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.time') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.actor') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.action') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.method') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.result') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.clientIp') }}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.detail') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="logs.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.audit.empty') }}</td>
|
||||
</tr>
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dark-800/60">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="font-medium">{{ log.actor_email || '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ log.actor_role }}<span v-if="log.auth_method"> · {{ log.auth_method }}</span></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">{{ log.action }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.method }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span :class="statusBadgeClass(log.status_code)">{{ log.status_code }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.client_ip || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button type="button" class="text-primary-600 hover:underline dark:text-primary-400" @click="openDetail(log.id)">
|
||||
{{ t('admin.audit.columns.detail') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="onPageChange"
|
||||
@update:pageSize="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<BaseDialog :show="detailVisible" :title="t('admin.audit.detail.title')" width="wide" @close="detailVisible = false">
|
||||
<div v-if="detailLoading" class="py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="detail" class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<DetailRow :label="t('admin.audit.columns.time')" :value="formatTime(detail.created_at)" />
|
||||
<DetailRow :label="t('admin.audit.columns.actor')" :value="detail.actor_email || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.actorRole')" :value="detail.actor_role" />
|
||||
<DetailRow :label="t('admin.audit.filters.authMethod')" :value="detail.auth_method" />
|
||||
<DetailRow :label="t('admin.audit.columns.action')" :value="detail.action" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.methodPath')" :value="`${detail.method} ${detail.path}`" mono />
|
||||
<DetailRow :label="t('admin.audit.columns.result')" :value="String(detail.status_code)" />
|
||||
<DetailRow :label="t('admin.audit.detail.latency')" :value="`${detail.latency_ms} ms`" />
|
||||
<DetailRow :label="t('admin.audit.columns.clientIp')" :value="detail.client_ip || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.requestId')" :value="detail.request_id || '—'" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.credential')" :value="detail.credential_masked || '—'" mono />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.userAgent') }}</div>
|
||||
<div class="break-all rounded bg-gray-50 p-2 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ detail.user_agent || '—' }}</div>
|
||||
</div>
|
||||
<div v-if="detail.request_body">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.requestBody') }}</div>
|
||||
<pre class="max-h-72 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
|
||||
</div>
|
||||
<div v-if="detail.extra && Object.keys(detail.extra).length">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.extra') }}</div>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Clear confirmation → step-up TOTP -->
|
||||
<ConfirmDialog
|
||||
:show="clearConfirmVisible"
|
||||
:title="t('admin.audit.clearConfirm.title')"
|
||||
:message="t('admin.audit.clearConfirm.message')"
|
||||
:confirm-text="t('admin.audit.clearAll')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="onClearConfirmed"
|
||||
@cancel="clearConfirmVisible = false"
|
||||
/>
|
||||
|
||||
<!-- Reused: TOTP prompt for the clear operation -->
|
||||
<div v-if="clearTotpVisible" class="fixed inset-0 z-[60] overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50" @click="cancelClearTotp"></div>
|
||||
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
|
||||
<h3 class="mb-2 text-center text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.audit.clearConfirm.totpTitle') }}</h3>
|
||||
<p class="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
|
||||
<input
|
||||
v-model.trim="clearTotpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
class="input mb-4 text-center text-lg tracking-[0.5em]"
|
||||
placeholder="••••••"
|
||||
@keyup.enter="submitClear"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-secondary flex-1" :disabled="clearing" @click="cancelClearTotp">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger flex-1" :disabled="clearing || clearTotpCode.length !== 6" @click="submitClear">
|
||||
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI, type AuditLog } from '@/api/admin'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Tiny inline label/value row for the detail dialog.
|
||||
const DetailRow = (props: { label: string; value: string; mono?: boolean }) =>
|
||||
h('div', { class: 'flex flex-col gap-0.5' }, [
|
||||
h('span', { class: 'text-xs text-gray-400' }, props.label),
|
||||
h('span', { class: ['text-gray-700 dark:text-gray-200 break-all', props.mono ? 'font-mono text-xs' : ''] }, props.value)
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const filters = reactive({
|
||||
q: '',
|
||||
actor_email: '',
|
||||
action: '',
|
||||
client_ip: '',
|
||||
method: '',
|
||||
auth_method: '',
|
||||
success: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
})
|
||||
|
||||
const methodOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
{ value: 'GET', label: 'GET' }
|
||||
])
|
||||
|
||||
const authMethodOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: 'jwt', label: 'JWT' },
|
||||
{ value: 'admin_api_key', label: 'Admin API Key' }
|
||||
])
|
||||
|
||||
const resultOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: 'true', label: t('admin.audit.filters.resultSuccess') },
|
||||
{ value: 'false', label: t('admin.audit.filters.resultFailure') }
|
||||
])
|
||||
|
||||
function toRFC3339(local: string): string | undefined {
|
||||
if (!local) return undefined
|
||||
const d = new Date(local)
|
||||
if (Number.isNaN(d.getTime())) return undefined
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
return {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
q: filters.q || undefined,
|
||||
actor_email: filters.actor_email || undefined,
|
||||
action: filters.action || undefined,
|
||||
client_ip: filters.client_ip || undefined,
|
||||
method: filters.method || undefined,
|
||||
auth_method: filters.auth_method || undefined,
|
||||
success: filters.success || undefined,
|
||||
start_time: toRFC3339(filters.start_time),
|
||||
end_time: toRFC3339(filters.end_time)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminAPI.audit.list(buildQuery())
|
||||
logs.value = res.items
|
||||
total.value = res.total
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.audit.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.q = ''
|
||||
filters.actor_email = ''
|
||||
filters.action = ''
|
||||
filters.client_ip = ''
|
||||
filters.method = ''
|
||||
filters.auth_method = ''
|
||||
filters.success = ''
|
||||
filters.start_time = ''
|
||||
filters.end_time = ''
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function onPageSizeChange(ps: number) {
|
||||
pageSize.value = ps
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// Detail dialog
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<AuditLog | null>(null)
|
||||
|
||||
async function openDetail(id: number) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await adminAPI.audit.get(id)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.audit.loadFailed'))
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prettyBody(body: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// Clear-all flow: confirm → TOTP → clear
|
||||
const clearConfirmVisible = ref(false)
|
||||
const clearTotpVisible = ref(false)
|
||||
const clearTotpCode = ref('')
|
||||
const clearing = ref(false)
|
||||
|
||||
function openClearDialog() {
|
||||
clearConfirmVisible.value = true
|
||||
}
|
||||
|
||||
function onClearConfirmed() {
|
||||
clearConfirmVisible.value = false
|
||||
clearTotpCode.value = ''
|
||||
clearTotpVisible.value = true
|
||||
}
|
||||
|
||||
function cancelClearTotp() {
|
||||
if (clearing.value) return
|
||||
clearTotpVisible.value = false
|
||||
}
|
||||
|
||||
async function submitClear() {
|
||||
if (clearTotpCode.value.length !== 6) return
|
||||
clearing.value = true
|
||||
try {
|
||||
const res = await adminAPI.audit.clear(clearTotpCode.value)
|
||||
clearTotpVisible.value = false
|
||||
appStore.showSuccess(t('admin.audit.clearConfirm.success', { count: res.deleted }))
|
||||
search()
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.audit.clearConfirm.failed'))
|
||||
clearTotpCode.value = ''
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: number): string {
|
||||
const base = 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium '
|
||||
if (status >= 500) return base + 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (status >= 400) return base + 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
return base + 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
@@ -276,6 +276,7 @@
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
<TotpStepUpDialog :controller="backupStepUp" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -284,9 +285,23 @@ import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores'
|
||||
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const backupStepUp = useStepUp()
|
||||
|
||||
// 敏感操作被 2FA 门控拦截时的统一提示。
|
||||
function reportStepUpBlocked(error: unknown): boolean {
|
||||
if (!isStepUpBlocked(error)) return false
|
||||
appStore.showError(
|
||||
stepUpBlockReason(error) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
|
||||
? t('stepUp.adminApiKeyForbidden')
|
||||
: t('stepUp.notEnabled')
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// S3 config
|
||||
const s3Form = ref<BackupS3Config>({
|
||||
@@ -523,11 +538,19 @@ async function loadBackups() {
|
||||
async function createBackup() {
|
||||
creatingBackup.value = true
|
||||
try {
|
||||
const record = await adminAPI.backup.createBackup({ expire_days: manualExpireDays.value })
|
||||
const record = await backupStepUp.run(() => adminAPI.backup.createBackup({ expire_days: manualExpireDays.value }))
|
||||
// 插入到列表顶部
|
||||
backups.value.unshift(record)
|
||||
startPolling(record.id)
|
||||
} catch (error: any) {
|
||||
if (isStepUpCancelled(error)) {
|
||||
creatingBackup.value = false
|
||||
return
|
||||
}
|
||||
if (reportStepUpBlocked(error)) {
|
||||
creatingBackup.value = false
|
||||
return
|
||||
}
|
||||
if (error?.response?.status === 409) {
|
||||
appStore.showWarning(t('admin.backup.operations.alreadyInProgress'))
|
||||
} else {
|
||||
@@ -539,9 +562,16 @@ async function createBackup() {
|
||||
|
||||
async function downloadBackup(id: string) {
|
||||
try {
|
||||
const result = await adminAPI.backup.getDownloadURL(id)
|
||||
window.open(result.url, '_blank')
|
||||
const result = await backupStepUp.run(() => adminAPI.backup.getDownloadURL(id))
|
||||
// 预签名 URL 带 attachment disposition,同页 anchor 导航直接触发下载;
|
||||
// 不用 window.open:step-up 弹窗 await 会耗尽瞬态用户激活,新标签页会被浏览器拦截。
|
||||
const link = document.createElement('a')
|
||||
link.href = result.url
|
||||
link.rel = 'noopener'
|
||||
link.click()
|
||||
} catch (error) {
|
||||
if (isStepUpCancelled(error)) return
|
||||
if (reportStepUpBlocked(error)) return
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1577,6 +1577,41 @@
|
||||
:disabled="!form.totp_encryption_key_configured"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 会话 IP/UA 绑定 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.security.sessionBinding")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.security.sessionBindingHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.session_binding_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- 审计日志保留天数 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.security.auditRetention")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.security.auditRetentionHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.audit_log_retention_days"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input w-28 text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8157,6 +8192,8 @@ const form = reactive<SettingsForm>({
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
session_binding_enabled: true,
|
||||
audit_log_retention_days: 180,
|
||||
login_agreement_enabled: false,
|
||||
login_agreement_mode: "modal",
|
||||
login_agreement_updated_at: "2026-03-31",
|
||||
@@ -9521,6 +9558,12 @@ async function saveSettings() {
|
||||
invitation_code_enabled: form.invitation_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
totp_enabled: form.totp_enabled,
|
||||
session_binding_enabled: form.session_binding_enabled,
|
||||
// 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存;
|
||||
// 空/非法值回退默认 180(与后端 parseAuditLogRetentionDays("") 语义一致,0 仍表示永久保留)。
|
||||
audit_log_retention_days: Number.isFinite(form.audit_log_retention_days)
|
||||
? form.audit_log_retention_days
|
||||
: 180,
|
||||
login_agreement_enabled: form.login_agreement_enabled,
|
||||
login_agreement_mode: form.login_agreement_mode,
|
||||
login_agreement_updated_at: form.login_agreement_updated_at,
|
||||
|
||||
Reference in New Issue
Block a user