Merge remote-tracking branch 'origin/main' into agent/fix-4326-async-image-object-storage

# Conflicts:
#	backend/cmd/server/wire_gen.go
This commit is contained in:
shaw
2026-07-16 15:32:38 +08:00
71 changed files with 3312 additions and 78 deletions
+1 -1
View File
@@ -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)
}
+7
View File
@@ -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()
+17 -5
View File
@@ -250,8 +250,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
complianceHandler := admin.NewComplianceHandler(settingService)
auditLogRepository := repository.NewAuditLogRepository(db)
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
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)
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)
@@ -280,10 +283,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, asyncImageHandler, 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)
@@ -298,7 +303,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,
@@ -359,6 +364,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)
@@ -388,6 +394,12 @@ func provideCleanup(
}
return nil
}},
{"AuditLogService", func() error {
if auditLog != nil {
auditLog.Stop()
}
return nil
}},
{"OpsAlertEvaluatorService", func() error {
if opsAlertEvaluator != nil {
opsAlertEvaluator.Stop()
+1
View File
@@ -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,
+1 -1
View File
@@ -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) {
+2
View File
@@ -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"`
+1
View File
@@ -38,6 +38,7 @@ type AdminHandlers struct {
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Compliance *admin.ComplianceHandler
AuditLog *admin.AuditLogHandler
}
// Handlers contains all HTTP handlers
+39
View File
@@ -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()),
})
}
+3
View File
@@ -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,
}
}
@@ -198,6 +200,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/service/s3"
@@ -83,9 +84,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)
+22
View File
@@ -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
}
+1
View File
@@ -85,6 +85,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",
+3 -1
View File
@@ -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)
}
}
}
+21 -3
View File
@@ -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,
)
+11 -5
View File
@@ -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)
}
+39 -17
View File
@@ -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)
+3
View File
@@ -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
+5
View File
@@ -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)
}
}
+256
View File
@@ -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
}
}
}
+186
View File
@@ -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)
}
}
}
+62 -8
View File
@@ -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
+24
View File
@@ -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)
+9
View File
@@ -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 {
@@ -688,6 +696,7 @@ var ProviderSet = wire.NewSet(
ProvideBackupService,
ProvideOpsSystemLogSink,
ProvideOpsService,
ProvideAuditLogService,
ProvideOpsMetricsCollector,
ProvideOpsAggregationService,
ProvideOpsAlertEvaluatorService,
+35
View File
@@ -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);
+82
View File
@@ -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
+6 -2
View File
@@ -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'
+4
View File
@@ -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;
+20 -1
View File
@@ -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)
})
})
+127
View File
@@ -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',
+10
View File
@@ -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: '登录和注册的机器人防护',
+10
View File
@@ -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
}
+12
View File
@@ -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',
+17 -3
View File
@@ -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 }
+394
View File
@@ -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>
+33 -3
View File
@@ -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'))
}
}
+43
View File
@@ -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,