From 6826149a8f0bcdee2ffeeb1288a584e60bb4ef18 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 12 Mar 2026 02:42:57 +0300 Subject: [PATCH 01/19] feat: add Backend Mode toggle to disable user self-service Add a system-wide "Backend Mode" that disables user self-registration and self-service while keeping admin panel and API gateway fully functional. When enabled, only admin can log in; all user-facing routes return 403. Backend: - New setting key `backend_mode_enabled` with atomic cached reads (60s TTL) - BackendModeUserGuard middleware blocks non-admin authenticated routes - BackendModeAuthGuard middleware blocks registration/password-reset auth routes - Login/Login2FA/RefreshToken handlers reject non-admin when enabled - TokenPairWithUser struct for role-aware token refresh - 20 unit tests (middleware + service layer) Frontend: - Router guards redirect unauthenticated users to /login - Admin toggle in Settings page - Login page hides register link and footer in backend mode - 9 unit tests for router guard logic - i18n support (en/zh) 27 files changed, 833 insertions(+), 17 deletions(-) Co-Authored-By: Claude Opus 4.6 --- .../internal/handler/admin/setting_handler.go | 9 + backend/internal/handler/auth_handler.go | 34 ++- backend/internal/handler/dto/settings.go | 4 + backend/internal/handler/setting_handler.go | 1 + backend/internal/server/api_contract_test.go | 1 + .../server/middleware/backend_mode_guard.go | 51 ++++ .../middleware/backend_mode_guard_test.go | 239 ++++++++++++++++++ backend/internal/server/router.go | 6 +- backend/internal/server/routes/auth.go | 4 + .../server/routes/auth_rate_limit_test.go | 1 + backend/internal/server/routes/sora_client.go | 3 + backend/internal/server/routes/user.go | 3 + backend/internal/service/auth_service.go | 17 +- backend/internal/service/domain_constants.go | 3 + backend/internal/service/setting_service.go | 72 ++++++ .../setting_service_backend_mode_test.go | 199 +++++++++++++++ backend/internal/service/settings_view.go | 4 + frontend/src/api/admin/settings.ts | 2 + frontend/src/components/layout/AppSidebar.vue | 2 +- frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/zh.ts | 3 + frontend/src/router/__tests__/guards.spec.ts | 131 ++++++++++ frontend/src/router/index.ts | 28 ++ frontend/src/stores/app.ts | 3 + frontend/src/types/index.ts | 1 + frontend/src/views/admin/SettingsView.vue | 18 ++ frontend/src/views/auth/LoginView.vue | 8 +- 27 files changed, 833 insertions(+), 17 deletions(-) create mode 100644 backend/internal/server/middleware/backend_mode_guard.go create mode 100644 backend/internal/server/middleware/backend_mode_guard_test.go create mode 100644 backend/internal/service/setting_service_backend_mode_test.go diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8330868d..ff76edda 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -125,6 +125,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: settings.MinClaudeCodeVersion, AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, + BackendModeEnabled: settings.BackendModeEnabled, }) } @@ -199,6 +200,9 @@ type UpdateSettingsRequest struct { // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` + + // Backend Mode + BackendModeEnabled bool `json:"backend_mode_enabled"` } // UpdateSettings 更新系统设置 @@ -473,6 +477,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { IdentityPatchPrompt: req.IdentityPatchPrompt, MinClaudeCodeVersion: req.MinClaudeCodeVersion, AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, + BackendModeEnabled: req.BackendModeEnabled, OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -571,6 +576,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, + BackendModeEnabled: updatedSettings.BackendModeEnabled, }) } @@ -725,6 +731,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { changed = append(changed, "allow_ungrouped_key_scheduling") } + if before.BackendModeEnabled != after.BackendModeEnabled { + changed = append(changed, "backend_mode_enabled") + } if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled { changed = append(changed, "purchase_subscription_enabled") } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 1ffa9d71..3b257189 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -194,6 +194,12 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // Backend mode: only admin can login + if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && !user.IsAdmin() { + response.Forbidden(c, "Backend mode is active. Only admin login is allowed.") + return + } + h.respondWithTokenPair(c, user) } @@ -250,16 +256,22 @@ func (h *AuthHandler) Login2FA(c *gin.Context) { return } - // Delete the login session - _ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken) - - // Get the user + // Get the user (before session deletion so we can check backend mode) user, err := h.userService.GetByID(c.Request.Context(), session.UserID) if err != nil { response.ErrorFrom(c, err) return } + // Backend mode: only admin can login (check BEFORE deleting session) + if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && !user.IsAdmin() { + response.Forbidden(c, "Backend mode is active. Only admin login is allowed.") + return + } + + // Delete the login session (only after all checks pass) + _ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken) + h.respondWithTokenPair(c, user) } @@ -522,16 +534,22 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) { return } - tokenPair, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken) + result, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken) if err != nil { response.ErrorFrom(c, err) return } + // Backend mode: block non-admin token refresh + if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && result.UserRole != "admin" { + response.Forbidden(c, "Backend mode is active. Only admin login is allowed.") + return + } + response.Success(c, RefreshTokenResponse{ - AccessToken: tokenPair.AccessToken, - RefreshToken: tokenPair.RefreshToken, - ExpiresIn: tokenPair.ExpiresIn, + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + ExpiresIn: result.ExpiresIn, TokenType: "Bearer", }) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 8a1bba5d..3df54fe9 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -81,6 +81,9 @@ type SystemSettings struct { // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` + + // Backend Mode + BackendModeEnabled bool `json:"backend_mode_enabled"` } type DefaultSubscriptionSetting struct { @@ -111,6 +114,7 @@ type PublicSettings struct { CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` Version string `json:"version"` } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 1188d55e..92061895 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -54,6 +54,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, SoraClientEnabled: settings.SoraClientEnabled, + BackendModeEnabled: settings.BackendModeEnabled, Version: h.version, }) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 0b36bf66..67617154 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -537,6 +537,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "min_claude_code_version": "", "allow_ungrouped_key_scheduling": false, + "backend_mode_enabled": false, "custom_menu_items": [] } }`, diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go new file mode 100644 index 00000000..46482af3 --- /dev/null +++ b/backend/internal/server/middleware/backend_mode_guard.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// BackendModeUserGuard blocks non-admin users from accessing user routes when backend mode is enabled. +// Must be placed AFTER JWT auth middleware so that the user role is available in context. +func BackendModeUserGuard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService == nil || !settingService.IsBackendModeEnabled(c.Request.Context()) { + c.Next() + return + } + role, _ := GetUserRoleFromContext(c) + if role == "admin" { + c.Next() + return + } + response.Forbidden(c, "Backend mode is active. User self-service is disabled.") + c.Abort() + } +} + +// BackendModeAuthGuard selectively blocks auth endpoints when backend mode is enabled. +// Allows: login, login/2fa, logout, refresh (admin needs these). +// Blocks: register, forgot-password, reset-password, OAuth, etc. +func BackendModeAuthGuard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService == nil || !settingService.IsBackendModeEnabled(c.Request.Context()) { + c.Next() + return + } + path := c.Request.URL.Path + // Allow login, 2FA, logout, refresh, public settings + allowedSuffixes := []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"} + for _, suffix := range allowedSuffixes { + if strings.HasSuffix(path, suffix) { + c.Next() + return + } + } + response.Forbidden(c, "Backend mode is active. Registration and self-service auth flows are disabled.") + c.Abort() + } +} diff --git a/backend/internal/server/middleware/backend_mode_guard_test.go b/backend/internal/server/middleware/backend_mode_guard_test.go new file mode 100644 index 00000000..8878ebc9 --- /dev/null +++ b/backend/internal/server/middleware/backend_mode_guard_test.go @@ -0,0 +1,239 @@ +//go:build unit + +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type bmSettingRepo struct { + values map[string]string +} + +func (r *bmSettingRepo) Get(_ context.Context, _ string) (*service.Setting, error) { + panic("unexpected Get call") +} + +func (r *bmSettingRepo) GetValue(_ context.Context, key string) (string, error) { + v, ok := r.values[key] + if !ok { + return "", service.ErrSettingNotFound + } + return v, nil +} + +func (r *bmSettingRepo) Set(_ context.Context, _, _ string) error { + panic("unexpected Set call") +} + +func (r *bmSettingRepo) GetMultiple(_ context.Context, _ []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (r *bmSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { + if r.values == nil { + r.values = make(map[string]string, len(settings)) + } + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *bmSettingRepo) GetAll(_ context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (r *bmSettingRepo) Delete(_ context.Context, _ string) error { + panic("unexpected Delete call") +} + +func newBackendModeSettingService(t *testing.T, enabled string) *service.SettingService { + t.Helper() + + repo := &bmSettingRepo{ + values: map[string]string{ + service.SettingKeyBackendModeEnabled: enabled, + }, + } + svc := service.NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.UpdateSettings(context.Background(), &service.SystemSettings{ + BackendModeEnabled: enabled == "true", + })) + + return svc +} + +func stringPtr(v string) *string { + return &v +} + +func TestBackendModeUserGuard(t *testing.T) { + tests := []struct { + name string + nilService bool + enabled string + role *string + wantStatus int + }{ + { + name: "disabled_allows_all", + enabled: "false", + role: stringPtr("user"), + wantStatus: http.StatusOK, + }, + { + name: "nil_service_allows_all", + nilService: true, + role: stringPtr("user"), + wantStatus: http.StatusOK, + }, + { + name: "enabled_admin_allowed", + enabled: "true", + role: stringPtr("admin"), + wantStatus: http.StatusOK, + }, + { + name: "enabled_user_blocked", + enabled: "true", + role: stringPtr("user"), + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_no_role_blocked", + enabled: "true", + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_empty_role_blocked", + enabled: "true", + role: stringPtr(""), + wantStatus: http.StatusForbidden, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + if tc.role != nil { + role := *tc.role + r.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUserRole), role) + c.Next() + }) + } + + var svc *service.SettingService + if !tc.nilService { + svc = newBackendModeSettingService(t, tc.enabled) + } + + r.Use(BackendModeUserGuard(svc)) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + require.Equal(t, tc.wantStatus, w.Code) + }) + } +} + +func TestBackendModeAuthGuard(t *testing.T) { + tests := []struct { + name string + nilService bool + enabled string + path string + wantStatus int + }{ + { + name: "disabled_allows_all", + enabled: "false", + path: "/api/v1/auth/register", + wantStatus: http.StatusOK, + }, + { + name: "nil_service_allows_all", + nilService: true, + path: "/api/v1/auth/register", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_login", + enabled: "true", + path: "/api/v1/auth/login", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_login_2fa", + enabled: "true", + path: "/api/v1/auth/login/2fa", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_logout", + enabled: "true", + path: "/api/v1/auth/logout", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_refresh", + enabled: "true", + path: "/api/v1/auth/refresh", + wantStatus: http.StatusOK, + }, + { + name: "enabled_blocks_register", + enabled: "true", + path: "/api/v1/auth/register", + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_blocks_forgot_password", + enabled: "true", + path: "/api/v1/auth/forgot-password", + wantStatus: http.StatusForbidden, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + + var svc *service.SettingService + if !tc.nilService { + svc = newBackendModeSettingService(t, tc.enabled) + } + + r.Use(BackendModeAuthGuard(svc)) + r.Any("/*path", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + r.ServeHTTP(w, req) + + require.Equal(t, tc.wantStatus, w.Code) + }) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 571986b4..99701531 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -107,9 +107,9 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 - routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient) - routes.RegisterUserRoutes(v1, h, jwtAuth) - routes.RegisterSoraClientRoutes(v1, h, jwtAuth) + routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) + routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) + routes.RegisterSoraClientRoutes(v1, h, jwtAuth, settingService) routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) } diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 0efc9560..a6c0ecf5 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -6,6 +6,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/middleware" servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -17,12 +18,14 @@ func RegisterAuthRoutes( h *handler.Handlers, jwtAuth servermiddleware.JWTAuthMiddleware, redisClient *redis.Client, + settingService *service.SettingService, ) { // 创建速率限制器 rateLimiter := middleware.NewRateLimiter(redisClient) // 公开接口 auth := v1.Group("/auth") + auth.Use(servermiddleware.BackendModeAuthGuard(settingService)) { // 注册/登录/2FA/验证码发送均属于高风险入口,增加服务端兜底限流(Redis 故障时 fail-close) auth.POST("/register", rateLimiter.LimitWithOptions("auth-register", 5, time.Minute, middleware.RateLimitOptions{ @@ -78,6 +81,7 @@ func RegisterAuthRoutes( // 需要认证的当前用户信息 authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) + authenticated.Use(servermiddleware.BackendModeUserGuard(settingService)) { authenticated.GET("/auth/me", h.Auth.GetCurrentUser) // 撤销所有会话(需要认证) diff --git a/backend/internal/server/routes/auth_rate_limit_test.go b/backend/internal/server/routes/auth_rate_limit_test.go index 5ce8497c..4f411cec 100644 --- a/backend/internal/server/routes/auth_rate_limit_test.go +++ b/backend/internal/server/routes/auth_rate_limit_test.go @@ -29,6 +29,7 @@ func newAuthRoutesTestRouter(redisClient *redis.Client) *gin.Engine { c.Next() }), redisClient, + nil, ) return router diff --git a/backend/internal/server/routes/sora_client.go b/backend/internal/server/routes/sora_client.go index 40ae0436..13fceb81 100644 --- a/backend/internal/server/routes/sora_client.go +++ b/backend/internal/server/routes/sora_client.go @@ -3,6 +3,7 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) @@ -12,6 +13,7 @@ func RegisterSoraClientRoutes( v1 *gin.RouterGroup, h *handler.Handlers, jwtAuth middleware.JWTAuthMiddleware, + settingService *service.SettingService, ) { if h.SoraClient == nil { return @@ -19,6 +21,7 @@ func RegisterSoraClientRoutes( authenticated := v1.Group("/sora") authenticated.Use(gin.HandlerFunc(jwtAuth)) + authenticated.Use(middleware.BackendModeUserGuard(settingService)) { authenticated.POST("/generate", h.SoraClient.Generate) authenticated.GET("/generations", h.SoraClient.ListGenerations) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index d0ed2489..c3b82742 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -3,6 +3,7 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) @@ -12,9 +13,11 @@ func RegisterUserRoutes( v1 *gin.RouterGroup, h *handler.Handlers, jwtAuth middleware.JWTAuthMiddleware, + settingService *service.SettingService, ) { authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) + authenticated.Use(middleware.BackendModeUserGuard(settingService)) { // 用户接口 user := authenticated.Group("/user") diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 28607e9f..6e524fb9 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -1087,6 +1087,12 @@ type TokenPair struct { ExpiresIn int `json:"expires_in"` // Access Token有效期(秒) } +// TokenPairWithUser extends TokenPair with user role for backend mode checks +type TokenPairWithUser struct { + TokenPair + UserRole string +} + // GenerateTokenPair 生成Access Token和Refresh Token对 // familyID: 可选的Token家族ID,用于Token轮转时保持家族关系 func (s *AuthService) GenerateTokenPair(ctx context.Context, user *User, familyID string) (*TokenPair, error) { @@ -1168,7 +1174,7 @@ func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, fami // RefreshTokenPair 使用Refresh Token刷新Token对 // 实现Token轮转:每次刷新都会生成新的Refresh Token,旧Token立即失效 -func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPair, error) { +func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPairWithUser, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, ErrRefreshTokenInvalid @@ -1233,7 +1239,14 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) } // 生成新的Token对,保持同一个家族ID - return s.GenerateTokenPair(ctx, user, data.FamilyID) + pair, err := s.GenerateTokenPair(ctx, user, data.FamilyID) + if err != nil { + return nil, err + } + return &TokenPairWithUser{ + TokenPair: *pair, + UserRole: user.Role, + }, nil } // RevokeRefreshToken 撤销单个Refresh Token diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 304c09f4..b5d3d2ce 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -219,6 +219,9 @@ const ( // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" + + // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录 + SettingKeyBackendModeEnabled = "backend_mode_enabled" ) // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index b77867de..5f2d47d7 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -65,6 +65,19 @@ const minVersionErrorTTL = 5 * time.Second // minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context const minVersionDBTimeout = 5 * time.Second +// cachedBackendMode Backend Mode cache (in-process, 60s TTL) +type cachedBackendMode struct { + value bool + expiresAt int64 // unix nano +} + +var backendModeCache atomic.Value // *cachedBackendMode +var backendModeSF singleflight.Group + +const backendModeCacheTTL = 60 * time.Second +const backendModeErrorTTL = 5 * time.Second +const backendModeDBTimeout = 5 * time.Second + // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) @@ -128,6 +141,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeySoraClientEnabled, SettingKeyCustomMenuItems, SettingKeyLinuxDoConnectEnabled, + SettingKeyBackendModeEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -172,6 +186,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", CustomMenuItems: settings[SettingKeyCustomMenuItems], LinuxDoOAuthEnabled: linuxDoEnabled, + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", }, nil } @@ -223,6 +238,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any SoraClientEnabled bool `json:"sora_client_enabled"` CustomMenuItems json.RawMessage `json:"custom_menu_items"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` Version string `json:"version,omitempty"` }{ RegistrationEnabled: settings.RegistrationEnabled, @@ -247,6 +263,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any SoraClientEnabled: settings.SoraClientEnabled, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, Version: s.version, }, nil } @@ -461,6 +478,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // 分组隔离 updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) + // Backend Mode + updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) + err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 @@ -469,6 +489,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet value: settings.MinClaudeCodeVersion, expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(), }) + backendModeSF.Forget("backend_mode") + backendModeCache.Store(&cachedBackendMode{ + value: settings.BackendModeEnabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } @@ -525,6 +550,52 @@ func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { return value == "true" } +// IsBackendModeEnabled checks if backend mode is enabled +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path +func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // Setting not yet created (fresh install) - default to disabled with full TTL + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return false, nil + } + slog.Warn("failed to get backend_mode_enabled setting", "error", err) + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(), + }) + return false, nil + } + enabled := value == "true" + backendModeCache.Store(&cachedBackendMode{ + value: enabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return enabled, nil + }) + if val, ok := result.(bool); ok { + return val + } + return false +} + // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) @@ -719,6 +790,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", CustomMenuItems: settings[SettingKeyCustomMenuItems], + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", } // 解析整数类型 diff --git a/backend/internal/service/setting_service_backend_mode_test.go b/backend/internal/service/setting_service_backend_mode_test.go new file mode 100644 index 00000000..39922ec8 --- /dev/null +++ b/backend/internal/service/setting_service_backend_mode_test.go @@ -0,0 +1,199 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type bmRepoStub struct { + getValueFn func(ctx context.Context, key string) (string, error) + calls int +} + +func (s *bmRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *bmRepoStub) GetValue(ctx context.Context, key string) (string, error) { + s.calls++ + if s.getValueFn == nil { + panic("unexpected GetValue call") + } + return s.getValueFn(ctx, key) +} + +func (s *bmRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *bmRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (s *bmRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *bmRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *bmRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + +type bmUpdateRepoStub struct { + updates map[string]string + getValueFn func(ctx context.Context, key string) (string, error) +} + +func (s *bmUpdateRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *bmUpdateRepoStub) GetValue(ctx context.Context, key string) (string, error) { + if s.getValueFn == nil { + panic("unexpected GetValue call") + } + return s.getValueFn(ctx, key) +} + +func (s *bmUpdateRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *bmUpdateRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (s *bmUpdateRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + s.updates = make(map[string]string, len(settings)) + for k, v := range settings { + s.updates[k] = v + } + return nil +} + +func (s *bmUpdateRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *bmUpdateRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + +func resetBackendModeTestCache(t *testing.T) { + t.Helper() + + backendModeCache.Store((*cachedBackendMode)(nil)) + t.Cleanup(func() { + backendModeCache.Store((*cachedBackendMode)(nil)) + }) +} + +func TestIsBackendModeEnabled_ReturnsTrue(t *testing.T) { + resetBackendModeTestCache(t) + + repo := &bmRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "true", nil + }, + } + svc := NewSettingService(repo, &config.Config{}) + + require.True(t, svc.IsBackendModeEnabled(context.Background())) + require.Equal(t, 1, repo.calls) +} + +func TestIsBackendModeEnabled_ReturnsFalse(t *testing.T) { + resetBackendModeTestCache(t) + + repo := &bmRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "false", nil + }, + } + svc := NewSettingService(repo, &config.Config{}) + + require.False(t, svc.IsBackendModeEnabled(context.Background())) + require.Equal(t, 1, repo.calls) +} + +func TestIsBackendModeEnabled_ReturnsFalseOnNotFound(t *testing.T) { + resetBackendModeTestCache(t) + + repo := &bmRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "", ErrSettingNotFound + }, + } + svc := NewSettingService(repo, &config.Config{}) + + require.False(t, svc.IsBackendModeEnabled(context.Background())) + require.Equal(t, 1, repo.calls) +} + +func TestIsBackendModeEnabled_ReturnsFalseOnDBError(t *testing.T) { + resetBackendModeTestCache(t) + + repo := &bmRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "", errors.New("db down") + }, + } + svc := NewSettingService(repo, &config.Config{}) + + require.False(t, svc.IsBackendModeEnabled(context.Background())) + require.Equal(t, 1, repo.calls) +} + +func TestIsBackendModeEnabled_CachesResult(t *testing.T) { + resetBackendModeTestCache(t) + + repo := &bmRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "true", nil + }, + } + svc := NewSettingService(repo, &config.Config{}) + + require.True(t, svc.IsBackendModeEnabled(context.Background())) + require.True(t, svc.IsBackendModeEnabled(context.Background())) + require.Equal(t, 1, repo.calls) +} + +func TestUpdateSettings_InvalidatesBackendModeCache(t *testing.T) { + resetBackendModeTestCache(t) + + backendModeCache.Store(&cachedBackendMode{ + value: true, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + + repo := &bmUpdateRepoStub{ + getValueFn: func(ctx context.Context, key string) (string, error) { + require.Equal(t, SettingKeyBackendModeEnabled, key) + return "true", nil + }, + } + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + BackendModeEnabled: false, + }) + require.NoError(t, err) + require.Equal(t, "false", repo.updates[SettingKeyBackendModeEnabled]) + require.False(t, svc.IsBackendModeEnabled(context.Background())) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8734e28a..376d5546 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -69,6 +69,9 @@ type SystemSettings struct { // 分组隔离:允许未分组 Key 调度(默认 false → 403) AllowUngroupedKeyScheduling bool + + // Backend 模式:禁用用户注册和自助服务,仅管理员可登录 + BackendModeEnabled bool } type DefaultSubscriptionSetting struct { @@ -101,6 +104,7 @@ type PublicSettings struct { CustomMenuItems string // JSON array of custom menu items LinuxDoOAuthEnabled bool + BackendModeEnabled bool Version string } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2b156ea1..2b22eeaf 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -40,6 +40,7 @@ export interface SystemSettings { purchase_subscription_enabled: boolean purchase_subscription_url: string sora_client_enabled: boolean + backend_mode_enabled: boolean custom_menu_items: CustomMenuItem[] // SMTP settings smtp_host: string @@ -106,6 +107,7 @@ export interface UpdateSettingsRequest { purchase_subscription_enabled?: boolean purchase_subscription_url?: string sora_client_enabled?: boolean + backend_mode_enabled?: boolean custom_menu_items?: CustomMenuItem[] smtp_host?: string smtp_port?: number diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3a23e6e0..95a13291 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -82,7 +82,7 @@ -