diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index af51c8ed..84a14ca2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -324,10 +324,10 @@ type TurnstileConfig struct { Required bool `mapstructure:"required"` } -// LinuxDoConnectConfig controls LinuxDo Connect OAuth login (end-user SSO). +// LinuxDoConnectConfig 用于 LinuxDo Connect OAuth 登录(终端用户 SSO)。 // -// Note: This is NOT the same as upstream account OAuth (e.g. OpenAI/Gemini). -// It is used for logging in to Sub2API itself. +// 注意:这与上游账号的 OAuth(例如 OpenAI/Gemini 账号接入)不是一回事。 +// 这里是用于登录 Sub2API 本身的用户体系。 type LinuxDoConnectConfig struct { Enabled bool `mapstructure:"enabled"` ClientID string `mapstructure:"client_id"` @@ -336,13 +336,13 @@ type LinuxDoConnectConfig struct { TokenURL string `mapstructure:"token_url"` UserInfoURL string `mapstructure:"userinfo_url"` Scopes string `mapstructure:"scopes"` - RedirectURL string `mapstructure:"redirect_url"` // backend callback URL registered at the provider - FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // frontend route to receive token (default: /auth/linuxdo/callback) + RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记) + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/linuxdo/callback) TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none UsePKCE bool `mapstructure:"use_pkce"` - // Optional: gjson paths to extract fields from userinfo JSON. - // When empty, the server tries a set of common keys. + // 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。 + // 为空时,服务端会尝试一组常见字段名。 UserInfoEmailPath string `mapstructure:"userinfo_email_path"` UserInfoIDPath string `mapstructure:"userinfo_id_path"` UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` @@ -464,7 +464,8 @@ func Load() (*Config, error) { return &cfg, nil } -func validateAbsoluteHTTPURL(raw string) error { +// ValidateAbsoluteHTTPURL 校验一个绝对 http(s) URL(禁止 fragment)。 +func ValidateAbsoluteHTTPURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { return fmt.Errorf("empty url") @@ -488,7 +489,10 @@ func validateAbsoluteHTTPURL(raw string) error { return nil } -func validateFrontendRedirectURL(raw string) error { +// ValidateFrontendRedirectURL 校验前端回调地址: +// - 允许同源相对路径(以 / 开头) +// - 或绝对 http(s) URL(禁止 fragment) +func ValidateFrontendRedirectURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { return fmt.Errorf("empty url") @@ -584,7 +588,7 @@ func setDefaults() { // Turnstile viper.SetDefault("turnstile.required", false) - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) viper.SetDefault("linuxdo_connect.enabled", false) viper.SetDefault("linuxdo_connect.client_id", "") viper.SetDefault("linuxdo_connect.client_secret", "") @@ -743,19 +747,19 @@ func (c *Config) Validate() error { return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true") } - if err := validateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil { + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil { return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err) } - if err := validateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil { + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil { return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err) } - if err := validateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil { + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil { return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err) } - if err := validateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil { + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil { return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err) } - if err := validateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil { + if err := ValidateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil { return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err) } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 70f1fa0e..d95a8980 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -2,10 +2,10 @@ package admin import ( "log" - "net/url" "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -94,7 +94,7 @@ type UpdateSettingsRequest struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKey string `json:"turnstile_secret_key"` - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` @@ -191,12 +191,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "LinuxDo Redirect URL is required when enabled") return } - if !isAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL) { + if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil { response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL") return } - // If client_secret not provided, keep existing value (if any). + // 如果未提供 client_secret,则保留现有值(如有)。 if req.LinuxDoConnectClientSecret == "" { if previousSettings.LinuxDoConnectClientSecret == "" { response.BadRequest(c, "LinuxDo Client Secret is required when enabled") @@ -407,33 +407,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, return changed } -func isAbsoluteHTTPURL(raw string) bool { - raw = strings.TrimSpace(raw) - if raw == "" { - return false - } - if strings.HasPrefix(raw, "//") { - return false - } - u, err := url.Parse(raw) - if err != nil { - return false - } - if !u.IsAbs() { - return false - } - if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { - return false - } - if strings.TrimSpace(u.Host) == "" { - return false - } - if u.Fragment != "" { - return false - } - return true -} - // TestSMTPRequest 测试SMTP连接请求 type TestSMTPRequest struct { SMTPHost string `json:"smtp_host" binding:"required"` diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index a98291fa..a16c4cc7 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -17,6 +17,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/imroc/req/v3" @@ -66,7 +67,7 @@ func (e *linuxDoTokenExchangeError) Error() string { return strings.Join(parts, " ") } -// LinuxDoOAuthStart starts the LinuxDo Connect OAuth login flow. +// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。 // GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) @@ -116,7 +117,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { c.Redirect(http.StatusFound, authURL) } -// LinuxDoOAuthCallback handles the OAuth callback, creates/logins the user, then redirects to frontend. +// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。 // GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=... func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context()) @@ -197,16 +198,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { return } - email, username, _, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp) + email, username, subject, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp) if err != nil { log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err) redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "") return } + // 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。 + // 统一使用基于 subject 的稳定合成邮箱来做账号绑定。 + if subject != "" { + email = linuxDoSyntheticEmail(subject) + } + jwtToken, _, err := h.authService.LoginOrRegisterOAuth(c.Request.Context(), email, username) if err != nil { - // Avoid leaking internal details to the client; keep structured reason for frontend. + // 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。 redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err)) return } @@ -352,9 +359,8 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s email = strings.TrimSpace(email) if email == "" { - // LinuxDo Connect userinfo does not necessarily provide email. To keep compatibility with the - // existing user schema (email is required/unique), use a stable synthetic email. - email = fmt.Sprintf("linuxdo-%s@linuxdo-connect.invalid", subject) + // LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。 + email = linuxDoSyntheticEmail(subject) } username = strings.TrimSpace(username) @@ -403,7 +409,7 @@ func redirectOAuthError(c *gin.Context, frontendCallback string, code string, me func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) { u, err := url.Parse(frontendCallback) if err != nil { - // Fallback: best-effort redirect. + // 兜底:尽力跳转到默认页面,避免卡死在回调页。 c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) return } @@ -545,7 +551,7 @@ func sanitizeFrontendRedirectPath(path string) string { if len(path) > linuxDoOAuthMaxRedirectLen { return "" } - // Only allow same-origin relative paths (avoid open redirect). + // 只允许同源相对路径(避免开放重定向)。 if !strings.HasPrefix(path, "/") { return "" } @@ -663,3 +669,11 @@ func isSafeLinuxDoSubject(subject string) bool { } return true } + +func linuxDoSyntheticEmail(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e3532b25..e232deb3 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -32,8 +32,6 @@ var ( ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ) -const linuxDoSyntheticEmailDomain = "@linuxdo-connect.invalid" - // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 const maxTokenLength = 8192 @@ -87,7 +85,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrRegDisabled } - // Prevent users from registering emails reserved for synthetic OAuth accounts. + // 防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。 if isReservedEmail(email) { return "", nil, ErrEmailReserved } @@ -339,11 +337,12 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string return token, user, nil } -// LoginOrRegisterOAuth logs a user in by email (trusted from an OAuth provider) or creates a new user. +// LoginOrRegisterOAuth 用于第三方 OAuth/SSO 登录: +// - 如果邮箱已存在:直接登录(不需要本地密码) +// - 如果邮箱不存在:创建新用户并登录 // -// This is used by end-user OAuth/SSO login flows (e.g. LinuxDo Connect), and intentionally does -// NOT require the local password. A random password hash is generated for new users to satisfy -// the existing database constraint. +// 注意:该函数用于“终端用户登录 Sub2API 本身”的场景(不同于上游账号的 OAuth,例如 OpenAI/Gemini)。 +// 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。 func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) { email = strings.TrimSpace(email) if email == "" || len(email) > 255 { @@ -361,7 +360,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { - // Treat OAuth-first login as registration. + // OAuth 首次登录视为注册。 if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled } @@ -376,7 +375,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username return "", nil, fmt.Errorf("hash password: %w", err) } - // Defaults for new users. + // 新用户默认值。 defaultBalance := s.cfg.Default.UserBalance defaultConcurrency := s.cfg.Default.UserConcurrency if s.settingService != nil { @@ -396,7 +395,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username if err := s.userRepo.Create(ctx, newUser); err != nil { if errors.Is(err, ErrEmailExists) { - // Race: user created between GetByEmail and Create. + // 并发场景:GetByEmail 与 Create 之间用户被创建。 user, err = s.userRepo.GetByEmail(ctx, email) if err != nil { log.Printf("[Auth] Database error getting user after conflict: %v", err) @@ -419,7 +418,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username return "", nil, ErrUserNotActive } - // Best-effort: fill username when empty. + // 尽力补全:当用户名为空时,使用第三方返回的用户名回填。 if user.Username == "" && username != "" { user.Username = username if err := s.userRepo.Update(ctx, user); err != nil { @@ -489,7 +488,7 @@ func randomHexString(byteLength int) (string, error) { func isReservedEmail(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) - return strings.HasSuffix(normalized, linuxDoSyntheticEmailDomain) + return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) } // GenerateToken 生成JWT token diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index f63918d3..df34e167 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -106,12 +106,16 @@ const ( SettingKeyEnableIdentityPatch = "enable_identity_patch" SettingKeyIdentityPatchPrompt = "identity_patch_prompt" - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled" SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id" SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" ) +// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。 +// 目的:避免第三方登录返回的用户标识与本地真实邮箱发生碰撞,进而造成账号被接管的风险。 +const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" + // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). const AdminAPIKeyPrefix = "admin-" diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 67b31f39..d25698de 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -121,7 +121,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL @@ -289,9 +289,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] - // LinuxDo Connect settings: - // - Backward compatible with config.yaml/env (so existing deployments don't get disabled by accident) - // - Can be overridden and persisted via admin "system settings" (stored in DB) + // LinuxDo Connect 设置: + // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) + // - 支持在后台“系统设置”中覆盖并持久化(存储于 DB) linuxDoBase := config.LinuxDoConnectConfig{} if s.cfg != nil { linuxDoBase = s.cfg.LinuxDo @@ -339,11 +339,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin return result } -// GetLinuxDoConnectOAuthConfig returns the effective LinuxDo Connect config for login. +// GetLinuxDoConnectOAuthConfig 返回用于登录的“最终生效” LinuxDo Connect 配置。 // -// Precedence: -// - If a corresponding system setting key exists, it overrides config.yaml/env values. -// - Otherwise, it falls back to config.yaml/env values. +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { if s == nil || s.cfg == nil { return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") @@ -379,7 +379,7 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } - // Best-effort sanity check (avoid redirecting users into a broken OAuth flow). + // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 if strings.TrimSpace(effective.ClientID) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } @@ -399,6 +399,22 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) switch method { case "", "client_secret_post", "client_secret_basic": diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 87a1a32a..26051418 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -18,7 +18,7 @@ type SystemSettings struct { TurnstileSecretKey string TurnstileSecretKeyConfigured bool - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) LinuxDoConnectEnabled bool LinuxDoConnectClientID string LinuxDoConnectClientSecret string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index e58b5af4..2f6991e7 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -34,7 +34,7 @@ export interface SystemSettings { turnstile_enabled: boolean turnstile_site_key: string turnstile_secret_key_configured: boolean - // LinuxDo Connect OAuth login (end-user SSO) + // LinuxDo Connect OAuth 登录(终端用户 SSO) linuxdo_connect_enabled: boolean linuxdo_connect_client_id: string linuxdo_connect_client_secret_configured: boolean diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue new file mode 100644 index 00000000..8012b101 --- /dev/null +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -0,0 +1,61 @@ + + + + diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index d91a9b7e..ce7081e1 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => { const contactInfo = ref('') const apiBaseUrl = ref('') const docUrl = ref('') + const cachedPublicSettings = ref(null) // Version cache state const versionLoaded = ref(false) @@ -282,24 +283,27 @@ export const useAppStore = defineStore('app', () => { * Fetch public settings (uses cache unless force=true) * @param force - Force refresh from API */ - async function fetchPublicSettings(force = false): Promise { - // Return cached data if available and not forcing refresh - if (publicSettingsLoaded.value && !force) { - return { - registration_enabled: false, - email_verify_enabled: false, - turnstile_enabled: false, - turnstile_site_key: '', - site_name: siteName.value, - site_logo: siteLogo.value, - site_subtitle: '', - api_base_url: apiBaseUrl.value, - contact_info: contactInfo.value, - doc_url: docUrl.value, - linuxdo_oauth_enabled: false, - version: siteVersion.value - } - } + async function fetchPublicSettings(force = false): Promise { + // Return cached data if available and not forcing refresh + if (publicSettingsLoaded.value && !force) { + if (cachedPublicSettings.value) { + return { ...cachedPublicSettings.value } + } + return { + registration_enabled: false, + email_verify_enabled: false, + turnstile_enabled: false, + turnstile_site_key: '', + site_name: siteName.value, + site_logo: siteLogo.value, + site_subtitle: '', + api_base_url: apiBaseUrl.value, + contact_info: contactInfo.value, + doc_url: docUrl.value, + linuxdo_oauth_enabled: false, + version: siteVersion.value + } + } // Prevent duplicate requests if (publicSettingsLoading.value) { @@ -309,6 +313,7 @@ export const useAppStore = defineStore('app', () => { publicSettingsLoading.value = true try { const data = await fetchPublicSettingsAPI() + cachedPublicSettings.value = data siteName.value = data.site_name || 'Sub2API' siteLogo.value = data.site_logo || '' siteVersion.value = data.version || '' @@ -330,6 +335,7 @@ export const useAppStore = defineStore('app', () => { */ function clearPublicSettingsCache(): void { publicSettingsLoaded.value = false + cachedPublicSettings.value = null } // ==================== Return Store API ==================== diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3f22a9d3..4076e154 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -160,8 +160,8 @@ export const useAuthStore = defineStore('auth', () => { } /** - * Set token directly (OAuth/SSO callback) and load current user profile. - * @param newToken - JWT access token issued by backend + * 直接设置 token(用于 OAuth/SSO 回调),并加载当前用户信息。 + * @param newToken - 后端签发的 JWT access token */ async function setToken(newToken: string): Promise { // Clear any previous state first (avoid mixing sessions) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7eb6babb..e7956a98 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -261,7 +261,7 @@ - +

@@ -850,7 +850,7 @@ const form = reactive({ turnstile_site_key: '', turnstile_secret_key: '', turnstile_secret_key_configured: false, - // LinuxDo Connect OAuth + // LinuxDo Connect OAuth(终端用户登录) linuxdo_connect_enabled: false, linuxdo_connect_client_id: '', linuxdo_connect_client_secret: '', diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index a6b5d2b2..6e6cee27 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -11,50 +11,8 @@

- -
- - -
-
- - {{ t('auth.linuxdo.orContinue') }} - -
-
-
+ +
@@ -202,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' @@ -367,14 +326,6 @@ async function handleLogin(): Promise { isLoading.value = false } } - -function handleLinuxDoLogin(): void { - const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard' - const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' - const normalized = apiBase.replace(/\/$/, '') - const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}` - window.location.href = startURL -}