From 02a66a01c3da45d29c37080abb4b87cfbd40bd25 Mon Sep 17 00:00:00 2001 From: ruiqurm Date: Fri, 13 Mar 2026 23:38:58 +0800 Subject: [PATCH] feat: support OIDC login. --- backend/internal/config/config.go | 160 ++++ backend/internal/config/config_test.go | 54 ++ .../internal/handler/admin/setting_handler.go | 281 ++++++ backend/internal/handler/auth_oidc_oauth.go | 865 ++++++++++++++++++ .../internal/handler/auth_oidc_oauth_test.go | 106 +++ backend/internal/handler/dto/settings.go | 26 + backend/internal/handler/setting_handler.go | 3 + backend/internal/server/api_contract_test.go | 46 +- backend/internal/server/routes/auth.go | 8 + backend/internal/service/auth_service.go | 3 +- backend/internal/service/domain_constants.go | 27 + backend/internal/service/setting_service.go | 458 ++++++++++ .../setting_service_oidc_config_test.go | 103 +++ backend/internal/service/settings_view.go | 33 +- deploy/config.example.yaml | 40 + frontend/src/api/admin/settings.ts | 46 + frontend/src/api/auth.ts | 25 +- .../components/auth/LinuxDoOAuthSection.vue | 12 +- .../src/components/auth/OidcOAuthSection.vue | 53 ++ frontend/src/i18n/locales/en.ts | 66 ++ frontend/src/i18n/locales/zh.ts | 65 ++ frontend/src/router/index.ts | 9 + frontend/src/stores/app.ts | 3 + frontend/src/types/index.ts | 3 + frontend/src/views/admin/SettingsView.vue | 386 +++++++- frontend/src/views/auth/LoginView.vue | 28 +- frontend/src/views/auth/OidcCallbackView.vue | 234 +++++ frontend/src/views/auth/RegisterView.vue | 27 +- 28 files changed, 3154 insertions(+), 16 deletions(-) create mode 100644 backend/internal/handler/auth_oidc_oauth.go create mode 100644 backend/internal/handler/auth_oidc_oauth_test.go create mode 100644 backend/internal/service/setting_service_oidc_config_test.go create mode 100644 frontend/src/components/auth/OidcOAuthSection.vue create mode 100644 frontend/src/views/auth/OidcCallbackView.vue diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9b430377..117d4293 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -65,6 +65,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Totp TotpConfig `mapstructure:"totp"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` + OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` Default DefaultConfig `mapstructure:"default"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Pricing PricingConfig `mapstructure:"pricing"` @@ -184,6 +185,34 @@ type LinuxDoConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +type OIDCConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + ProviderName string `mapstructure:"provider_name"` // 显示名: "Keycloak" 等 + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + IssuerURL string `mapstructure:"issuer_url"` + DiscoveryURL string `mapstructure:"discovery_url"` + AuthorizeURL string `mapstructure:"authorize_url"` + TokenURL string `mapstructure:"token_url"` + UserInfoURL string `mapstructure:"userinfo_url"` + JWKSURL string `mapstructure:"jwks_url"` + Scopes string `mapstructure:"scopes"` // 默认 "openid email profile" + RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记) + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/oidc/callback) + TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none + UsePKCE bool `mapstructure:"use_pkce"` + ValidateIDToken bool `mapstructure:"validate_id_token"` + AllowedSigningAlgs string `mapstructure:"allowed_signing_algs"` // 默认 "RS256,ES256,PS256" + ClockSkewSeconds int `mapstructure:"clock_skew_seconds"` // 默认 120 + RequireEmailVerified bool `mapstructure:"require_email_verified"` // 默认 false + + // 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。 + // 为空时,服务端会尝试一组常见字段名。 + UserInfoEmailPath string `mapstructure:"userinfo_email_path"` + UserInfoIDPath string `mapstructure:"userinfo_id_path"` + UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` +} + // TokenRefreshConfig OAuth token自动刷新配置 type TokenRefreshConfig struct { // 是否启用自动刷新 @@ -968,6 +997,23 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath) cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath) cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath) + cfg.OIDC.ProviderName = strings.TrimSpace(cfg.OIDC.ProviderName) + cfg.OIDC.ClientID = strings.TrimSpace(cfg.OIDC.ClientID) + cfg.OIDC.ClientSecret = strings.TrimSpace(cfg.OIDC.ClientSecret) + cfg.OIDC.IssuerURL = strings.TrimSpace(cfg.OIDC.IssuerURL) + cfg.OIDC.DiscoveryURL = strings.TrimSpace(cfg.OIDC.DiscoveryURL) + cfg.OIDC.AuthorizeURL = strings.TrimSpace(cfg.OIDC.AuthorizeURL) + cfg.OIDC.TokenURL = strings.TrimSpace(cfg.OIDC.TokenURL) + cfg.OIDC.UserInfoURL = strings.TrimSpace(cfg.OIDC.UserInfoURL) + cfg.OIDC.JWKSURL = strings.TrimSpace(cfg.OIDC.JWKSURL) + cfg.OIDC.Scopes = strings.TrimSpace(cfg.OIDC.Scopes) + cfg.OIDC.RedirectURL = strings.TrimSpace(cfg.OIDC.RedirectURL) + cfg.OIDC.FrontendRedirectURL = strings.TrimSpace(cfg.OIDC.FrontendRedirectURL) + cfg.OIDC.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.OIDC.TokenAuthMethod)) + cfg.OIDC.AllowedSigningAlgs = strings.TrimSpace(cfg.OIDC.AllowedSigningAlgs) + cfg.OIDC.UserInfoEmailPath = strings.TrimSpace(cfg.OIDC.UserInfoEmailPath) + cfg.OIDC.UserInfoIDPath = strings.TrimSpace(cfg.OIDC.UserInfoIDPath) + cfg.OIDC.UserInfoUsernamePath = strings.TrimSpace(cfg.OIDC.UserInfoUsernamePath) cfg.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix) cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins) cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed) @@ -1138,6 +1184,30 @@ func setDefaults() { viper.SetDefault("linuxdo_connect.userinfo_id_path", "") viper.SetDefault("linuxdo_connect.userinfo_username_path", "") + // Generic OIDC OAuth 登录 + viper.SetDefault("oidc_connect.enabled", false) + viper.SetDefault("oidc_connect.provider_name", "OIDC") + viper.SetDefault("oidc_connect.client_id", "") + viper.SetDefault("oidc_connect.client_secret", "") + viper.SetDefault("oidc_connect.issuer_url", "") + viper.SetDefault("oidc_connect.discovery_url", "") + viper.SetDefault("oidc_connect.authorize_url", "") + viper.SetDefault("oidc_connect.token_url", "") + viper.SetDefault("oidc_connect.userinfo_url", "") + viper.SetDefault("oidc_connect.jwks_url", "") + viper.SetDefault("oidc_connect.scopes", "openid email profile") + viper.SetDefault("oidc_connect.redirect_url", "") + viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback") + viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post") + viper.SetDefault("oidc_connect.use_pkce", false) + viper.SetDefault("oidc_connect.validate_id_token", true) + viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256") + viper.SetDefault("oidc_connect.clock_skew_seconds", 120) + viper.SetDefault("oidc_connect.require_email_verified", false) + viper.SetDefault("oidc_connect.userinfo_email_path", "") + viper.SetDefault("oidc_connect.userinfo_id_path", "") + viper.SetDefault("oidc_connect.userinfo_username_path", "") + // Database viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) @@ -1572,6 +1642,87 @@ func (c *Config) Validate() error { warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL) warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL) } + if c.OIDC.Enabled { + if strings.TrimSpace(c.OIDC.ClientID) == "" { + return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.IssuerURL) == "" { + return fmt.Errorf("oidc_connect.issuer_url is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.RedirectURL) == "" { + return fmt.Errorf("oidc_connect.redirect_url is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.FrontendRedirectURL) == "" { + return fmt.Errorf("oidc_connect.frontend_redirect_url is required when oidc_connect.enabled=true") + } + if !scopeContainsOpenID(c.OIDC.Scopes) { + return fmt.Errorf("oidc_connect.scopes must contain openid") + } + + method := strings.ToLower(strings.TrimSpace(c.OIDC.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic", "none": + default: + return fmt.Errorf("oidc_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none") + } + if method == "none" && !c.OIDC.UsePKCE { + return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.token_auth_method=none") + } + if (method == "" || method == "client_secret_post" || method == "client_secret_basic") && + strings.TrimSpace(c.OIDC.ClientSecret) == "" { + return fmt.Errorf("oidc_connect.client_secret is required when oidc_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic") + } + if c.OIDC.ClockSkewSeconds < 0 || c.OIDC.ClockSkewSeconds > 600 { + return fmt.Errorf("oidc_connect.clock_skew_seconds must be between 0 and 600") + } + if c.OIDC.ValidateIDToken && strings.TrimSpace(c.OIDC.AllowedSigningAlgs) == "" { + return fmt.Errorf("oidc_connect.allowed_signing_algs is required when oidc_connect.validate_id_token=true") + } + + if err := ValidateAbsoluteHTTPURL(c.OIDC.IssuerURL); err != nil { + return fmt.Errorf("oidc_connect.issuer_url invalid: %w", err) + } + if v := strings.TrimSpace(c.OIDC.DiscoveryURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.discovery_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.AuthorizeURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.authorize_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.TokenURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.token_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.UserInfoURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.userinfo_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.JWKSURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.jwks_url invalid: %w", err) + } + } + if err := ValidateAbsoluteHTTPURL(c.OIDC.RedirectURL); err != nil { + return fmt.Errorf("oidc_connect.redirect_url invalid: %w", err) + } + if err := ValidateFrontendRedirectURL(c.OIDC.FrontendRedirectURL); err != nil { + return fmt.Errorf("oidc_connect.frontend_redirect_url invalid: %w", err) + } + + warnIfInsecureURL("oidc_connect.issuer_url", c.OIDC.IssuerURL) + warnIfInsecureURL("oidc_connect.discovery_url", c.OIDC.DiscoveryURL) + warnIfInsecureURL("oidc_connect.authorize_url", c.OIDC.AuthorizeURL) + warnIfInsecureURL("oidc_connect.token_url", c.OIDC.TokenURL) + warnIfInsecureURL("oidc_connect.userinfo_url", c.OIDC.UserInfoURL) + warnIfInsecureURL("oidc_connect.jwks_url", c.OIDC.JWKSURL) + warnIfInsecureURL("oidc_connect.redirect_url", c.OIDC.RedirectURL) + warnIfInsecureURL("oidc_connect.frontend_redirect_url", c.OIDC.FrontendRedirectURL) + } if c.Billing.CircuitBreaker.Enabled { if c.Billing.CircuitBreaker.FailureThreshold <= 0 { return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive") @@ -2184,6 +2335,15 @@ func ValidateFrontendRedirectURL(raw string) error { return nil } +func scopeContainsOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + // isHTTPScheme 检查是否为 HTTP 或 HTTPS 协议 func isHTTPScheme(scheme string) bool { return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2de5451e..b9660b78 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -351,6 +351,60 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) { } } +func TestValidateOIDCScopesMustContainOpenID(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.OIDC.Enabled = true + cfg.OIDC.ClientID = "oidc-client" + cfg.OIDC.ClientSecret = "oidc-secret" + cfg.OIDC.IssuerURL = "https://issuer.example.com" + cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth" + cfg.OIDC.TokenURL = "https://issuer.example.com/token" + cfg.OIDC.JWKSURL = "https://issuer.example.com/jwks" + cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" + cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" + cfg.OIDC.Scopes = "profile email" + + err = cfg.Validate() + if err == nil { + t.Fatalf("Validate() expected error when scopes do not include openid, got nil") + } + if !strings.Contains(err.Error(), "oidc_connect.scopes") { + t.Fatalf("Validate() expected oidc_connect.scopes error, got: %v", err) + } +} + +func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.OIDC.Enabled = true + cfg.OIDC.ClientID = "oidc-client" + cfg.OIDC.ClientSecret = "oidc-secret" + cfg.OIDC.IssuerURL = "https://issuer.example.com" + cfg.OIDC.AuthorizeURL = "" + cfg.OIDC.TokenURL = "" + cfg.OIDC.JWKSURL = "" + cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" + cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" + cfg.OIDC.Scopes = "openid email profile" + cfg.OIDC.ValidateIDToken = true + + err = cfg.Validate() + if err != nil { + t.Fatalf("Validate() expected issuer-only OIDC config to pass with discovery fallback, got: %v", err) + } +} + func TestLoadDefaultDashboardCacheConfig(t *testing.T) { resetViperWithJWTSecret(t) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4cbe5188..abae75d9 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -35,6 +35,15 @@ func generateMenuItemID() (string, error) { return hex.EncodeToString(b), nil } +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + // SettingHandler 系统设置处理器 type SettingHandler struct { settingService *service.SettingService @@ -96,6 +105,28 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: settings.OIDCConnectEnabled, + OIDCConnectProviderName: settings.OIDCConnectProviderName, + OIDCConnectClientID: settings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: settings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, + OIDCConnectScopes: settings.OIDCConnectScopes, + OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, @@ -164,6 +195,30 @@ type UpdateSettingsRequest struct { LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + // Generic OIDC OAuth 登录 + OIDCConnectEnabled bool `json:"oidc_connect_enabled"` + OIDCConnectProviderName string `json:"oidc_connect_provider_name"` + OIDCConnectClientID string `json:"oidc_connect_client_id"` + OIDCConnectClientSecret string `json:"oidc_connect_client_secret"` + OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` + OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` + OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` + OIDCConnectTokenURL string `json:"oidc_connect_token_url"` + OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` + OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` + OIDCConnectScopes string `json:"oidc_connect_scopes"` + OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` + OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` + OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` + OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"` + OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` + OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` + OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` + OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` + OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` + OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + // OEM设置 SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` @@ -324,6 +379,122 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // Generic OIDC 参数验证 + if req.OIDCConnectEnabled { + req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) + req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID) + req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret) + req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL) + req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL) + req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL) + req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL) + req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL) + req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL) + req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes) + req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL) + req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL) + req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod)) + req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs) + req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath) + req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath) + req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath) + + if req.OIDCConnectProviderName == "" { + req.OIDCConnectProviderName = "OIDC" + } + if req.OIDCConnectClientID == "" { + response.BadRequest(c, "OIDC Client ID is required when enabled") + return + } + if req.OIDCConnectIssuerURL == "" { + response.BadRequest(c, "OIDC Issuer URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil { + response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectDiscoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil { + response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectAuthorizeURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil { + response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil { + response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectUserInfoURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil { + response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectRedirectURL == "" { + response.BadRequest(c, "OIDC Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil { + response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectFrontendRedirectURL == "" { + response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled") + return + } + if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil { + response.BadRequest(c, "OIDC Frontend Redirect URL is invalid") + return + } + if !scopesContainOpenID(req.OIDCConnectScopes) { + response.BadRequest(c, "OIDC scopes must contain openid") + return + } + switch req.OIDCConnectTokenAuthMethod { + case "", "client_secret_post", "client_secret_basic", "none": + default: + response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none") + return + } + if req.OIDCConnectTokenAuthMethod == "none" && !req.OIDCConnectUsePKCE { + response.BadRequest(c, "OIDC PKCE must be enabled when token_auth_method=none") + return + } + if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 { + response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600") + return + } + if req.OIDCConnectValidateIDToken { + if req.OIDCConnectAllowedSigningAlgs == "" { + response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true") + return + } + } + if req.OIDCConnectJWKSURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil { + response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" { + if req.OIDCConnectClientSecret == "" { + if previousSettings.OIDCConnectClientSecret == "" { + response.BadRequest(c, "OIDC Client Secret is required when enabled") + return + } + req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret + } + } + } + // “购买订阅”页面配置验证 purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled if req.PurchaseSubscriptionEnabled != nil { @@ -554,6 +725,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: req.OIDCConnectEnabled, + OIDCConnectProviderName: req.OIDCConnectProviderName, + OIDCConnectClientID: req.OIDCConnectClientID, + OIDCConnectClientSecret: req.OIDCConnectClientSecret, + OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: req.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, + OIDCConnectScopes: req.OIDCConnectScopes, + OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: req.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, SiteName: req.SiteName, SiteLogo: req.SiteLogo, SiteSubtitle: req.SiteSubtitle, @@ -669,6 +862,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, + OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, + OIDCConnectClientID: updatedSettings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, + OIDCConnectScopes: updatedSettings.OIDCConnectScopes, + OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, SiteName: updatedSettings.SiteName, SiteLogo: updatedSettings.SiteLogo, SiteSubtitle: updatedSettings.SiteSubtitle, @@ -787,6 +1002,72 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { changed = append(changed, "linuxdo_connect_redirect_url") } + if before.OIDCConnectEnabled != after.OIDCConnectEnabled { + changed = append(changed, "oidc_connect_enabled") + } + if before.OIDCConnectProviderName != after.OIDCConnectProviderName { + changed = append(changed, "oidc_connect_provider_name") + } + if before.OIDCConnectClientID != after.OIDCConnectClientID { + changed = append(changed, "oidc_connect_client_id") + } + if req.OIDCConnectClientSecret != "" { + changed = append(changed, "oidc_connect_client_secret") + } + if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL { + changed = append(changed, "oidc_connect_issuer_url") + } + if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL { + changed = append(changed, "oidc_connect_discovery_url") + } + if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL { + changed = append(changed, "oidc_connect_authorize_url") + } + if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL { + changed = append(changed, "oidc_connect_token_url") + } + if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL { + changed = append(changed, "oidc_connect_userinfo_url") + } + if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL { + changed = append(changed, "oidc_connect_jwks_url") + } + if before.OIDCConnectScopes != after.OIDCConnectScopes { + changed = append(changed, "oidc_connect_scopes") + } + if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL { + changed = append(changed, "oidc_connect_redirect_url") + } + if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL { + changed = append(changed, "oidc_connect_frontend_redirect_url") + } + if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod { + changed = append(changed, "oidc_connect_token_auth_method") + } + if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE { + changed = append(changed, "oidc_connect_use_pkce") + } + if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken { + changed = append(changed, "oidc_connect_validate_id_token") + } + if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs { + changed = append(changed, "oidc_connect_allowed_signing_algs") + } + if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds { + changed = append(changed, "oidc_connect_clock_skew_seconds") + } + if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified { + changed = append(changed, "oidc_connect_require_email_verified") + } + if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath { + changed = append(changed, "oidc_connect_userinfo_email_path") + } + if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath { + changed = append(changed, "oidc_connect_userinfo_id_path") + } + if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath { + changed = append(changed, "oidc_connect_userinfo_username_path") + } if before.SiteName != after.SiteName { changed = append(changed, "site_name") } diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go new file mode 100644 index 00000000..f46fb850 --- /dev/null +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -0,0 +1,865 @@ +package handler + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + 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/golang-jwt/jwt/v5" + "github.com/imroc/req/v3" + "github.com/tidwall/gjson" +) + +const ( + oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc" + oidcOAuthStateCookieName = "oidc_oauth_state" + oidcOAuthVerifierCookie = "oidc_oauth_verifier" + oidcOAuthRedirectCookie = "oidc_oauth_redirect" + oidcOAuthNonceCookie = "oidc_oauth_nonce" + oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes + oidcOAuthDefaultRedirectTo = "/dashboard" + oidcOAuthDefaultFrontendCB = "/auth/oidc/callback" +) + +type oidcTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + IDToken string `json:"id_token,omitempty"` +} + +type oidcTokenExchangeError struct { + StatusCode int + ProviderError string + ProviderDescription string + Body string +} + +func (e *oidcTokenExchangeError) Error() string { + if e == nil { + return "" + } + parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)} + if strings.TrimSpace(e.ProviderError) != "" { + parts = append(parts, "error="+strings.TrimSpace(e.ProviderError)) + } + if strings.TrimSpace(e.ProviderDescription) != "" { + parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription)) + } + return strings.Join(parts, " ") +} + +type oidcIDTokenClaims struct { + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Name string `json:"name,omitempty"` + Nonce string `json:"nonce,omitempty"` + Azp string `json:"azp,omitempty"` + jwt.RegisteredClaims +} + +type oidcUserInfoClaims struct { + Email string + Username string + Subject string + EmailVerified *bool +} + +type oidcJWKSet struct { + Keys []oidcJWK `json:"keys"` +} + +type oidcJWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + + N string `json:"n"` + E string `json:"e"` + + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。 +// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard +func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { + cfg, err := h.getOIDCOAuthConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = oidcOAuthDefaultRedirectTo + } + + secureCookie := isRequestHTTPS(c) + oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie) + oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie) + + codeChallenge := "" + if cfg.UsePKCE { + verifier, genErr := oauth.GenerateCodeVerifier() + if genErr != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr)) + return + } + codeChallenge = oauth.GenerateCodeChallenge(verifier) + oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie) + } + + nonce := "" + if cfg.ValidateIDToken { + nonce, err = oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err)) + return + } + oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie) + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")) + return + } + + authURL, err := buildOIDCAuthorizeURL(cfg, state, nonce, codeChallenge, redirectURI) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) + return + } + + c.Redirect(http.StatusFound, authURL) +} + +// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。 +// GET /api/v1/auth/oauth/oidc/callback?code=...&state=... +func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { + cfg, cfgErr := h.getOIDCOAuthConfig(c.Request.Context()) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = oidcOAuthDefaultFrontendCB + } + + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + oidcClearCookie(c, oidcOAuthStateCookieName, secureCookie) + oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie) + oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie) + oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie) + }() + + expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName) + if err != nil || expectedState == "" || state != expectedState { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") + return + } + + redirectTo, _ := readCookieDecoded(c, oidcOAuthRedirectCookie) + redirectTo = sanitizeFrontendRedirectPath(redirectTo) + if redirectTo == "" { + redirectTo = oidcOAuthDefaultRedirectTo + } + + codeVerifier := "" + if cfg.UsePKCE { + codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie) + if codeVerifier == "" { + redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "") + return + } + } + + expectedNonce := "" + if cfg.ValidateIDToken { + expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie) + if expectedNonce == "" { + redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "") + return + } + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "") + return + } + + tokenResp, err := oidcExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier) + if err != nil { + description := "" + var exchangeErr *oidcTokenExchangeError + if errors.As(err, &exchangeErr) && exchangeErr != nil { + log.Printf( + "[OIDC OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s", + exchangeErr.StatusCode, + exchangeErr.ProviderError, + exchangeErr.ProviderDescription, + truncateLogValue(exchangeErr.Body, 2048), + ) + description = exchangeErr.Error() + } else { + log.Printf("[OIDC OAuth] token exchange failed: %v", err) + description = err.Error() + } + redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description)) + return + } + + if cfg.ValidateIDToken && strings.TrimSpace(tokenResp.IDToken) == "" { + redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "") + return + } + + idClaims, err := oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce) + if err != nil { + log.Printf("[OIDC OAuth] id_token validation failed: %v", err) + redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "") + return + } + + userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp) + if err != nil { + log.Printf("[OIDC OAuth] userinfo fetch failed: %v", err) + redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "") + return + } + + subject := strings.TrimSpace(idClaims.Subject) + if subject == "" { + subject = strings.TrimSpace(userInfoClaims.Subject) + } + if subject == "" { + redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "") + return + } + issuer := strings.TrimSpace(idClaims.Issuer) + if issuer == "" { + issuer = strings.TrimSpace(cfg.IssuerURL) + } + if issuer == "" { + redirectOAuthError(c, frontendCallback, "missing_issuer", "missing issuer claim", "") + return + } + + emailVerified := userInfoClaims.EmailVerified + if emailVerified == nil { + emailVerified = idClaims.EmailVerified + } + if cfg.RequireEmailVerified { + if emailVerified == nil || !*emailVerified { + redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "") + return + } + } + + identityKey := oidcIdentityKey(issuer, subject) + email := oidcSyntheticEmailFromIdentityKey(identityKey) + username := firstNonEmpty( + userInfoClaims.Username, + idClaims.PreferredUsername, + idClaims.Name, + oidcFallbackUsername(subject), + ) + + // 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired + tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "") + if err != nil { + if errors.Is(err, service.ErrOAuthInvitationRequired) { + pendingToken, tokenErr := h.authService.CreatePendingOAuthToken(email, username) + if tokenErr != nil { + redirectOAuthError(c, frontendCallback, "login_failed", "service_error", "") + return + } + fragment := url.Values{} + fragment.Set("error", "invitation_required") + fragment.Set("pending_oauth_token", pendingToken) + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) + return + } + redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + + fragment := url.Values{} + fragment.Set("access_token", tokenPair.AccessToken) + fragment.Set("refresh_token", tokenPair.RefreshToken) + fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn)) + fragment.Set("token_type", "Bearer") + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) +} + +type completeOIDCOAuthRequest struct { + PendingOAuthToken string `json:"pending_oauth_token" binding:"required"` + InvitationCode string `json:"invitation_code" binding:"required"` +} + +// CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating +// the invitation code and creating the user account. +// POST /api/v1/auth/oauth/oidc/complete-registration +func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { + var req completeOIDCOAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()}) + return + } + + email, username, err := h.authService.VerifyPendingOAuthToken(req.PendingOAuthToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "INVALID_TOKEN", "message": "invalid or expired registration token"}) + return + } + + tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + if err != nil { + response.ErrorFrom(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": tokenPair.AccessToken, + "refresh_token": tokenPair.RefreshToken, + "expires_in": tokenPair.ExpiresIn, + "token_type": "Bearer", + }) +} + +func (h *AuthHandler) getOIDCOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetOIDCConnectOAuthConfig(ctx) + } + if h == nil || h.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + if !h.cfg.OIDC.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + return h.cfg.OIDC, nil +} + +func oidcExchangeCode( + ctx context.Context, + cfg config.OIDCConnectConfig, + code string, + redirectURI string, + codeVerifier string, +) (*oidcTokenResponse, error) { + client := req.C().SetTimeout(30 * time.Second) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + if cfg.UsePKCE { + form.Set("code_verifier", codeVerifier) + } + + r := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json") + + switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) { + case "", "client_secret_post": + form.Set("client_secret", cfg.ClientSecret) + case "client_secret_basic": + r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret) + case "none": + default: + return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod) + } + + resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL) + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + body := strings.TrimSpace(resp.String()) + if !resp.IsSuccessState() { + providerErr, providerDesc := parseOAuthProviderError(body) + return nil, &oidcTokenExchangeError{ + StatusCode: resp.StatusCode, + ProviderError: providerErr, + ProviderDescription: providerDesc, + Body: body, + } + } + + tokenResp, ok := oidcParseTokenResponse(body) + if !ok { + return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body} + } + if strings.TrimSpace(tokenResp.TokenType) == "" { + tokenResp.TokenType = "Bearer" + } + if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" { + return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body} + } + return tokenResp, nil +} + +func oidcParseTokenResponse(body string) (*oidcTokenResponse, bool) { + body = strings.TrimSpace(body) + if body == "" { + return nil, false + } + + accessToken := strings.TrimSpace(getGJSON(body, "access_token")) + idToken := strings.TrimSpace(getGJSON(body, "id_token")) + if accessToken != "" || idToken != "" { + tokenType := strings.TrimSpace(getGJSON(body, "token_type")) + refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token")) + scope := strings.TrimSpace(getGJSON(body, "scope")) + expiresIn := gjson.Get(body, "expires_in").Int() + return &oidcTokenResponse{ + AccessToken: accessToken, + TokenType: tokenType, + ExpiresIn: expiresIn, + RefreshToken: refreshToken, + Scope: scope, + IDToken: idToken, + }, true + } + + values, err := url.ParseQuery(body) + if err != nil { + return nil, false + } + accessToken = strings.TrimSpace(values.Get("access_token")) + idToken = strings.TrimSpace(values.Get("id_token")) + if accessToken == "" && idToken == "" { + return nil, false + } + expiresIn := int64(0) + if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" { + if v, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil { + expiresIn = v + } + } + return &oidcTokenResponse{ + AccessToken: accessToken, + TokenType: strings.TrimSpace(values.Get("token_type")), + ExpiresIn: expiresIn, + RefreshToken: strings.TrimSpace(values.Get("refresh_token")), + Scope: strings.TrimSpace(values.Get("scope")), + IDToken: idToken, + }, true +} + +func oidcFetchUserInfo( + ctx context.Context, + cfg config.OIDCConnectConfig, + token *oidcTokenResponse, +) (*oidcUserInfoClaims, error) { + if strings.TrimSpace(cfg.UserInfoURL) == "" { + return &oidcUserInfoClaims{}, nil + } + if token == nil || strings.TrimSpace(token.AccessToken) == "" { + return nil, errors.New("missing access_token for userinfo request") + } + + client := req.C().SetTimeout(30 * time.Second) + authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken) + if err != nil { + return nil, fmt.Errorf("invalid token for userinfo request: %w", err) + } + + resp, err := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetHeader("Authorization", authorization). + Get(cfg.UserInfoURL) + if err != nil { + return nil, fmt.Errorf("request userinfo: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("userinfo status=%d", resp.StatusCode) + } + + return oidcParseUserInfo(resp.String(), cfg), nil +} + +func oidcParseUserInfo(body string, cfg config.OIDCConnectConfig) *oidcUserInfoClaims { + claims := &oidcUserInfoClaims{} + claims.Email = firstNonEmpty( + getGJSON(body, cfg.UserInfoEmailPath), + getGJSON(body, "email"), + getGJSON(body, "user.email"), + getGJSON(body, "data.email"), + getGJSON(body, "attributes.email"), + ) + claims.Username = firstNonEmpty( + getGJSON(body, cfg.UserInfoUsernamePath), + getGJSON(body, "preferred_username"), + getGJSON(body, "username"), + getGJSON(body, "name"), + getGJSON(body, "user.username"), + getGJSON(body, "user.name"), + ) + claims.Subject = firstNonEmpty( + getGJSON(body, cfg.UserInfoIDPath), + getGJSON(body, "sub"), + getGJSON(body, "id"), + getGJSON(body, "user_id"), + getGJSON(body, "uid"), + getGJSON(body, "user.id"), + ) + if verified, ok := getGJSONBool(body, "email_verified"); ok { + claims.EmailVerified = &verified + } + claims.Email = strings.TrimSpace(claims.Email) + claims.Username = strings.TrimSpace(claims.Username) + claims.Subject = strings.TrimSpace(claims.Subject) + return claims +} + +func getGJSONBool(body string, path string) (bool, bool) { + path = strings.TrimSpace(path) + if path == "" { + return false, false + } + res := gjson.Get(body, path) + if !res.Exists() { + return false, false + } + return res.Bool(), true +} + +func buildOIDCAuthorizeURL(cfg config.OIDCConnectConfig, state, nonce, codeChallenge, redirectURI string) (string, error) { + u, err := url.Parse(cfg.AuthorizeURL) + if err != nil { + return "", fmt.Errorf("parse authorize_url: %w", err) + } + + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", redirectURI) + if strings.TrimSpace(cfg.Scopes) != "" { + q.Set("scope", cfg.Scopes) + } + q.Set("state", state) + if strings.TrimSpace(nonce) != "" { + q.Set("nonce", nonce) + } + if cfg.UsePKCE { + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String(), nil +} + +func oidcParseAndValidateIDToken(ctx context.Context, cfg config.OIDCConnectConfig, idToken string, expectedNonce string) (*oidcIDTokenClaims, error) { + idToken = strings.TrimSpace(idToken) + if idToken == "" { + return nil, errors.New("missing id_token") + } + allowed := oidcAllowedSigningAlgs(cfg.AllowedSigningAlgs) + if len(allowed) == 0 { + return nil, errors.New("empty allowed signing algorithms") + } + + jwks, err := oidcFetchJWKSet(ctx, cfg.JWKSURL) + if err != nil { + return nil, err + } + leeway := time.Duration(cfg.ClockSkewSeconds) * time.Second + claims := &oidcIDTokenClaims{} + + parsed, err := jwt.ParseWithClaims( + idToken, + claims, + func(token *jwt.Token) (any, error) { + alg := strings.TrimSpace(token.Method.Alg()) + if !containsString(allowed, alg) { + return nil, fmt.Errorf("unexpected signing algorithm: %s", alg) + } + kid, _ := token.Header["kid"].(string) + return oidcFindPublicKey(jwks, strings.TrimSpace(kid), alg) + }, + jwt.WithValidMethods(allowed), + jwt.WithAudience(cfg.ClientID), + jwt.WithIssuer(cfg.IssuerURL), + jwt.WithLeeway(leeway), + ) + if err != nil { + return nil, err + } + if !parsed.Valid { + return nil, errors.New("id_token invalid") + } + if strings.TrimSpace(claims.Subject) == "" { + return nil, errors.New("id_token missing sub") + } + if expectedNonce != "" && strings.TrimSpace(claims.Nonce) != strings.TrimSpace(expectedNonce) { + return nil, errors.New("id_token nonce mismatch") + } + if len(claims.Audience) > 1 { + if strings.TrimSpace(claims.Azp) == "" || strings.TrimSpace(claims.Azp) != strings.TrimSpace(cfg.ClientID) { + return nil, errors.New("id_token azp mismatch") + } + } + return claims, nil +} + +func oidcAllowedSigningAlgs(raw string) []string { + if strings.TrimSpace(raw) == "" { + return []string{"RS256", "ES256", "PS256"} + } + seen := make(map[string]struct{}) + out := make([]string, 0, 4) + for _, part := range strings.Split(raw, ",") { + alg := strings.ToUpper(strings.TrimSpace(part)) + if alg == "" { + continue + } + if _, ok := seen[alg]; ok { + continue + } + seen[alg] = struct{}{} + out = append(out, alg) + } + return out +} + +func oidcFetchJWKSet(ctx context.Context, jwksURL string) (*oidcJWKSet, error) { + jwksURL = strings.TrimSpace(jwksURL) + if jwksURL == "" { + return nil, errors.New("missing jwks_url") + } + resp, err := req.C(). + SetTimeout(30*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(jwksURL) + if err != nil { + return nil, fmt.Errorf("request jwks: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("jwks status=%d", resp.StatusCode) + } + set := &oidcJWKSet{} + if err := json.Unmarshal(resp.Bytes(), set); err != nil { + return nil, fmt.Errorf("parse jwks: %w", err) + } + if len(set.Keys) == 0 { + return nil, errors.New("jwks empty keys") + } + return set, nil +} + +func oidcFindPublicKey(set *oidcJWKSet, kid, alg string) (any, error) { + if set == nil { + return nil, errors.New("jwks not loaded") + } + alg = strings.ToUpper(strings.TrimSpace(alg)) + kid = strings.TrimSpace(kid) + + var lastErr error + for i := range set.Keys { + k := set.Keys[i] + if strings.TrimSpace(k.Use) != "" && !strings.EqualFold(strings.TrimSpace(k.Use), "sig") { + continue + } + if kid != "" && strings.TrimSpace(k.Kid) != kid { + continue + } + if strings.TrimSpace(k.Alg) != "" && !strings.EqualFold(strings.TrimSpace(k.Alg), alg) { + continue + } + pk, err := k.publicKey() + if err != nil { + lastErr = err + continue + } + if pk != nil { + return pk, nil + } + } + if lastErr != nil { + return nil, lastErr + } + if kid != "" { + return nil, fmt.Errorf("jwk not found for kid=%s", kid) + } + return nil, errors.New("jwk not found") +} + +func (k oidcJWK) publicKey() (any, error) { + switch strings.ToUpper(strings.TrimSpace(k.Kty)) { + case "RSA": + n, err := decodeBase64URLBigInt(k.N) + if err != nil { + return nil, fmt.Errorf("decode rsa n: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(k.E)) + if err != nil { + return nil, fmt.Errorf("decode rsa e: %w", err) + } + if len(eBytes) == 0 { + return nil, errors.New("empty rsa e") + } + e := 0 + for _, b := range eBytes { + e = (e << 8) | int(b) + } + if e <= 0 { + return nil, errors.New("invalid rsa exponent") + } + if n.Sign() <= 0 { + return nil, errors.New("invalid rsa modulus") + } + return &rsa.PublicKey{N: n, E: e}, nil + case "EC": + var curve elliptic.Curve + switch strings.TrimSpace(k.Crv) { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv) + } + x, err := decodeBase64URLBigInt(k.X) + if err != nil { + return nil, fmt.Errorf("decode ec x: %w", err) + } + y, err := decodeBase64URLBigInt(k.Y) + if err != nil { + return nil, fmt.Errorf("decode ec y: %w", err) + } + if !curve.IsOnCurve(x, y) { + return nil, errors.New("ec point is not on curve") + } + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + default: + return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty) + } +} + +func decodeBase64URLBigInt(raw string) (*big.Int, error) { + buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil { + return nil, err + } + if len(buf) == 0 { + return nil, errors.New("empty value") + } + return new(big.Int).SetBytes(buf), nil +} + +func containsString(values []string, target string) bool { + target = strings.TrimSpace(target) + for _, v := range values { + if strings.EqualFold(strings.TrimSpace(v), target) { + return true + } + } + return false +} + +func oidcIdentityKey(issuer, subject string) string { + issuer = strings.TrimSpace(strings.ToLower(issuer)) + subject = strings.TrimSpace(subject) + return issuer + "\x1f" + subject +} + +func oidcSyntheticEmailFromIdentityKey(identityKey string) string { + identityKey = strings.TrimSpace(identityKey) + if identityKey == "" { + return "" + } + sum := sha256.Sum256([]byte(identityKey)) + return "oidc-" + hex.EncodeToString(sum[:16]) + service.OIDCConnectSyntheticEmailDomain +} + +func oidcFallbackUsername(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "oidc_user" + } + sum := sha256.Sum256([]byte(subject)) + return "oidc_" + hex.EncodeToString(sum[:])[:12] +} + +func oidcSetCookie(c *gin.Context, name, value string, maxAgeSec int, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: oidcOAuthCookiePath, + MaxAge: maxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func oidcClearCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: oidcOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/backend/internal/handler/auth_oidc_oauth_test.go b/backend/internal/handler/auth_oidc_oauth_test.go new file mode 100644 index 00000000..1f50dd49 --- /dev/null +++ b/backend/internal/handler/auth_oidc_oauth_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) { + k1 := oidcIdentityKey("https://issuer.example.com", "subject-a") + k2 := oidcIdentityKey("https://issuer.example.com", "subject-b") + + e1 := oidcSyntheticEmailFromIdentityKey(k1) + e1Again := oidcSyntheticEmailFromIdentityKey(k1) + e2 := oidcSyntheticEmailFromIdentityKey(k2) + + require.Equal(t, e1, e1Again) + require.NotEqual(t, e1, e2) + require.Contains(t, e1, "@oidc-connect.invalid") +} + +func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) { + cfg := config.OIDCConnectConfig{ + AuthorizeURL: "https://issuer.example.com/auth", + ClientID: "cid", + Scopes: "openid email profile", + UsePKCE: true, + } + + u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback") + require.NoError(t, err) + require.Contains(t, u, "nonce=nonce123") + require.Contains(t, u, "code_challenge=challenge123") + require.Contains(t, u, "code_challenge_method=S256") + require.Contains(t, u, "scope=openid+email+profile") +} + +func TestOIDCParseAndValidateIDToken(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + kid := "kid-1" + jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(jwks)) + })) + defer srv.Close() + + now := time.Now() + claims := oidcIDTokenClaims{ + Nonce: "nonce-ok", + Azp: "client-1", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://issuer.example.com", + Subject: "subject-1", + Audience: jwt.ClaimStrings{"client-1", "another-aud"}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)), + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = kid + signed, err := tok.SignedString(priv) + require.NoError(t, err) + + cfg := config.OIDCConnectConfig{ + ClientID: "client-1", + IssuerURL: "https://issuer.example.com", + JWKSURL: srv.URL, + AllowedSigningAlgs: "RS256", + ClockSkewSeconds: 120, + } + + parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok") + require.NoError(t, err) + require.Equal(t, "subject-1", parsed.Subject) + require.Equal(t, "https://issuer.example.com", parsed.Issuer) + + _, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce") + require.Error(t, err) +} + +func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK { + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()) + return oidcJWK{ + Kty: "RSA", + Kid: kid, + Use: "sig", + Alg: "RS256", + N: n, + E: e, + } +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 73707f79..c8fc3b5d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -51,6 +51,29 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + OIDCConnectEnabled bool `json:"oidc_connect_enabled"` + OIDCConnectProviderName string `json:"oidc_connect_provider_name"` + OIDCConnectClientID string `json:"oidc_connect_client_id"` + OIDCConnectClientSecretConfigured bool `json:"oidc_connect_client_secret_configured"` + OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` + OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` + OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` + OIDCConnectTokenURL string `json:"oidc_connect_token_url"` + OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` + OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` + OIDCConnectScopes string `json:"oidc_connect_scopes"` + OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` + OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` + OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` + OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"` + OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` + OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` + OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` + OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` + OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` + OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` @@ -128,6 +151,9 @@ type PublicSettings struct { CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + 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 977c2301..8b536877 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -54,6 +54,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + 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 24f60f27..fee879a4 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyTurnstileSiteKey: "site-key", service.SettingKeyTurnstileSecretKey: "secret-key", + service.SettingKeyOIDCConnectEnabled: "false", + service.SettingKeyOIDCConnectProviderName: "OIDC", + service.SettingKeyOIDCConnectClientID: "", + service.SettingKeyOIDCConnectIssuerURL: "", + service.SettingKeyOIDCConnectDiscoveryURL: "", + service.SettingKeyOIDCConnectAuthorizeURL: "", + service.SettingKeyOIDCConnectTokenURL: "", + service.SettingKeyOIDCConnectUserInfoURL: "", + service.SettingKeyOIDCConnectJWKSURL: "", + service.SettingKeyOIDCConnectScopes: "openid email profile", + service.SettingKeyOIDCConnectRedirectURL: "", + service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + service.SettingKeyOIDCConnectUsePKCE: "false", + service.SettingKeyOIDCConnectValidateIDToken: "true", + service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", + service.SettingKeyOIDCConnectClockSkewSeconds: "120", + service.SettingKeyOIDCConnectRequireEmailVerified: "false", + service.SettingKeyOIDCConnectUserInfoEmailPath: "", + service.SettingKeyOIDCConnectUserInfoIDPath: "", + service.SettingKeyOIDCConnectUserInfoUsernamePath: "", + service.SettingKeySiteName: "Sub2API", service.SettingKeySiteLogo: "", service.SettingKeySiteSubtitle: "Subtitle", @@ -503,10 +525,32 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, - "linuxdo_connect_enabled": false, + "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, "linuxdo_connect_redirect_url": "", + "oidc_connect_enabled": false, + "oidc_connect_provider_name": "OIDC", + "oidc_connect_client_id": "", + "oidc_connect_client_secret_configured": false, + "oidc_connect_issuer_url": "", + "oidc_connect_discovery_url": "", + "oidc_connect_authorize_url": "", + "oidc_connect_token_url": "", + "oidc_connect_userinfo_url": "", + "oidc_connect_jwks_url": "", + "oidc_connect_scopes": "openid email profile", + "oidc_connect_redirect_url": "", + "oidc_connect_frontend_redirect_url": "/auth/oidc/callback", + "oidc_connect_token_auth_method": "client_secret_post", + "oidc_connect_use_pkce": false, + "oidc_connect_validate_id_token": true, + "oidc_connect_allowed_signing_algs": "RS256,ES256,PS256", + "oidc_connect_clock_skew_seconds": 120, + "oidc_connect_require_email_verified": false, + "oidc_connect_userinfo_email_path": "", + "oidc_connect_userinfo_id_path": "", + "oidc_connect_userinfo_username_path": "", "ops_monitoring_enabled": false, "ops_realtime_monitoring_enabled": true, "ops_query_mode_default": "auto", diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index a6c0ecf5..c143b030 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -70,6 +70,14 @@ func RegisterAuthRoutes( }), h.Auth.CompleteLinuxDoOAuthRegistration, ) + auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart) + auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback) + auth.POST("/oauth/oidc/complete-registration", + rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CompleteOIDCOAuthRegistration, + ) } // 公开设置(无需认证) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 6e524fb9..fd28cd42 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) { func isReservedEmail(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) - return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) + return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) } // GenerateToken 生成JWT access token diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 92be3e06..e194f921 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -71,6 +71,9 @@ const ( // LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。 const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" +// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。 +const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid" + // Setting keys const ( // 注册设置 @@ -105,6 +108,30 @@ const ( SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" + // Generic OIDC OAuth 登录设置 + SettingKeyOIDCConnectEnabled = "oidc_connect_enabled" + SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name" + SettingKeyOIDCConnectClientID = "oidc_connect_client_id" + SettingKeyOIDCConnectClientSecret = "oidc_connect_client_secret" + SettingKeyOIDCConnectIssuerURL = "oidc_connect_issuer_url" + SettingKeyOIDCConnectDiscoveryURL = "oidc_connect_discovery_url" + SettingKeyOIDCConnectAuthorizeURL = "oidc_connect_authorize_url" + SettingKeyOIDCConnectTokenURL = "oidc_connect_token_url" + SettingKeyOIDCConnectUserInfoURL = "oidc_connect_userinfo_url" + SettingKeyOIDCConnectJWKSURL = "oidc_connect_jwks_url" + SettingKeyOIDCConnectScopes = "oidc_connect_scopes" + SettingKeyOIDCConnectRedirectURL = "oidc_connect_redirect_url" + SettingKeyOIDCConnectFrontendRedirectURL = "oidc_connect_frontend_redirect_url" + SettingKeyOIDCConnectTokenAuthMethod = "oidc_connect_token_auth_method" + SettingKeyOIDCConnectUsePKCE = "oidc_connect_use_pkce" + SettingKeyOIDCConnectValidateIDToken = "oidc_connect_validate_id_token" + SettingKeyOIDCConnectAllowedSigningAlgs = "oidc_connect_allowed_signing_algs" + SettingKeyOIDCConnectClockSkewSeconds = "oidc_connect_clock_skew_seconds" + SettingKeyOIDCConnectRequireEmailVerified = "oidc_connect_require_email_verified" + SettingKeyOIDCConnectUserInfoEmailPath = "oidc_connect_userinfo_email_path" + SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path" + SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path" + // OEM设置 SettingKeySiteName = "site_name" // 网站名称 SettingKeySiteLogo = "site_logo" // 网站Logo (base64) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7d0ef5bd..37677fa5 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) @@ -164,6 +165,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyBackendModeEnabled, + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -177,6 +180,19 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } else { linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } + oidcEnabled := false + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + oidcEnabled = raw == "true" + } else { + oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled + } + oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) + if oidcProviderName == "" && s.cfg != nil { + oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) + } + if oidcProviderName == "" { + oidcProviderName = "OIDC" + } // Password reset requires email verification to be enabled emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" @@ -209,6 +225,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, }, nil } @@ -256,6 +274,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` Version string `json:"version,omitempty"` }{ RegistrationEnabled: settings.RegistrationEnabled, @@ -281,6 +301,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, Version: s.version, }, nil } @@ -460,6 +482,32 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret } + // Generic OIDC OAuth 登录 + updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) + updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName + updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID + updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL + updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL + updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL + updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL + updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL + updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL + updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes + updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL + updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL + updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod + updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) + updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) + updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs + updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) + updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) + updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath + updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath + updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath + if settings.OIDCConnectClientSecret != "" { + updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret + } + // OEM设置 updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo @@ -826,6 +874,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyPurchaseSubscriptionURL: "", SettingKeyCustomMenuItems: "[]", SettingKeyCustomEndpoints: "[]", + SettingKeyOIDCConnectEnabled: "false", + SettingKeyOIDCConnectProviderName: "OIDC", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultSubscriptions: "[]", @@ -951,6 +1001,138 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" + // Generic OIDC 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + oidcBase := config.OIDCConnectConfig{} + if s.cfg != nil { + oidcBase = s.cfg.OIDC + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + result.OIDCConnectEnabled = raw == "true" + } else { + result.OIDCConnectEnabled = oidcBase.Enabled + } + + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectProviderName = strings.TrimSpace(v) + } else { + result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) + } + if result.OIDCConnectProviderName == "" { + result.OIDCConnectProviderName = "OIDC" + } + + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectClientID = strings.TrimSpace(v) + } else { + result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectIssuerURL = strings.TrimSpace(v) + } else { + result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) + } else { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) + } else { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenURL = strings.TrimSpace(v) + } else { + result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectUserInfoURL = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectJWKSURL = strings.TrimSpace(v) + } else { + result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectScopes = strings.TrimSpace(v) + } else { + result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } else { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + result.OIDCConnectUsePKCE = raw == "true" + } else { + result.OIDCConnectUsePKCE = oidcBase.UsePKCE + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + result.OIDCConnectValidateIDToken = raw == "true" + } else { + result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) + } else { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) + } + clockSkewSet := false + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + result.OIDCConnectClockSkewSeconds = parsed + clockSkewSet = true + } + } + if !clockSkewSet { + result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds + } + if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { + result.OIDCConnectClockSkewSeconds = 120 + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + result.OIDCConnectRequireEmailVerified = raw == "true" + } else { + result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) + } + result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) + if result.OIDCConnectClientSecret == "" { + result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) + } + result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" + // Model fallback settings result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") @@ -1323,6 +1505,282 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) } +// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.OIDC + + keys := []string{ + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyOIDCConnectClientID, + SettingKeyOIDCConnectClientSecret, + SettingKeyOIDCConnectIssuerURL, + SettingKeyOIDCConnectDiscoveryURL, + SettingKeyOIDCConnectAuthorizeURL, + SettingKeyOIDCConnectTokenURL, + SettingKeyOIDCConnectUserInfoURL, + SettingKeyOIDCConnectJWKSURL, + SettingKeyOIDCConnectScopes, + SettingKeyOIDCConnectRedirectURL, + SettingKeyOIDCConnectFrontendRedirectURL, + SettingKeyOIDCConnectTokenAuthMethod, + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + SettingKeyOIDCConnectAllowedSigningAlgs, + SettingKeyOIDCConnectClockSkewSeconds, + SettingKeyOIDCConnectRequireEmailVerified, + SettingKeyOIDCConnectUserInfoEmailPath, + SettingKeyOIDCConnectUserInfoIDPath, + SettingKeyOIDCConnectUserInfoUsernamePath, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + effective.ProviderName = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + effective.IssuerURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + effective.DiscoveryURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + effective.AuthorizeURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + effective.TokenURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + effective.UserInfoURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + effective.JWKSURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + effective.Scopes = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.FrontendRedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + effective.UsePKCE = raw == "true" + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + effective.ValidateIDToken = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + effective.AllowedSigningAlgs = strings.TrimSpace(v) + } + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { + effective.ClockSkewSeconds = parsed + } + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + effective.RequireEmailVerified = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + effective.UserInfoEmailPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + effective.UserInfoIDPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + effective.UserInfoUsernamePath = strings.TrimSpace(v) + } + + if !effective.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(effective.ProviderName) == "" { + effective.ProviderName = "OIDC" + } + if strings.TrimSpace(effective.ClientID) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.IssuerURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + if !scopesContainOpenID(effective.Scopes) { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") + } + if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") + } + + discoveryURL := strings.TrimSpace(effective.DiscoveryURL) + if discoveryURL == "" { + discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) + effective.DiscoveryURL = discoveryURL + } + if discoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") + } + } + + needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || + strings.TrimSpace(effective.TokenURL) == "" || + (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") + if needsDiscovery && discoveryURL != "" { + metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) + if resolveErr != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) + } + if strings.TrimSpace(effective.TokenURL) == "" { + effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) + } + if strings.TrimSpace(effective.JWKSURL) == "" { + effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) + } + } + + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if v := strings.TrimSpace(effective.UserInfoURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + } + if effective.ValidateIDToken { + if strings.TrimSpace(effective.JWKSURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") + } + if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") + } + } + if v := strings.TrimSpace(effective.JWKSURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") + } + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.OIDCConnectConfig{}, 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": + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + if !effective.UsePKCE { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none") + } + default: + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + +type oidcProviderMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func oidcDefaultDiscoveryURL(issuerURL string) string { + issuerURL = strings.TrimSpace(issuerURL) + if issuerURL == "" { + return "" + } + return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" +} + +func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { + discoveryURL = strings.TrimSpace(discoveryURL) + if discoveryURL == "" { + return nil, fmt.Errorf("discovery url is empty") + } + + resp, err := req.C(). + SetTimeout(15*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(discoveryURL) + if err != nil { + return nil, fmt.Errorf("request discovery document: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) + } + + metadata := &oidcProviderMetadata{} + if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { + return nil, fmt.Errorf("parse discovery document: %w", err) + } + return metadata, nil +} + // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) diff --git a/backend/internal/service/setting_service_oidc_config_test.go b/backend/internal/service/setting_service_oidc_config_test.go new file mode 100644 index 00000000..3809b332 --- /dev/null +++ b/backend/internal/service/setting_service_oidc_config_test.go @@ -0,0 +1,103 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type settingOIDCRepoStub struct { + values map[string]string +} + +func (s *settingOIDCRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *settingOIDCRepoStub) GetValue(ctx context.Context, key string) (string, error) { + panic("unexpected GetValue call") +} + +func (s *settingOIDCRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *settingOIDCRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (s *settingOIDCRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *settingOIDCRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *settingOIDCRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + +func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testing.T) { + var discoveryHits int + var baseURL string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issuer/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + discoveryHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "authorization_endpoint":"%s/issuer/protocol/openid-connect/auth", + "token_endpoint":"%s/issuer/protocol/openid-connect/token", + "userinfo_endpoint":"%s/issuer/protocol/openid-connect/userinfo", + "jwks_uri":"%s/issuer/protocol/openid-connect/certs" + }`, baseURL, baseURL, baseURL, baseURL))) + })) + defer srv.Close() + baseURL = srv.URL + + cfg := &config.Config{ + OIDC: config.OIDCConnectConfig{ + Enabled: true, + ProviderName: "OIDC", + ClientID: "oidc-client", + ClientSecret: "oidc-secret", + IssuerURL: srv.URL + "/issuer", + RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback", + FrontendRedirectURL: "/auth/oidc/callback", + Scopes: "openid email profile", + TokenAuthMethod: "client_secret_post", + ValidateIDToken: true, + AllowedSigningAlgs: "RS256", + ClockSkewSeconds: 120, + }, + } + + repo := &settingOIDCRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, cfg) + + got, err := svc.GetOIDCConnectOAuthConfig(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, discoveryHits) + require.Equal(t, srv.URL+"/issuer/.well-known/openid-configuration", got.DiscoveryURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/auth", got.AuthorizeURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/token", got.TokenURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/userinfo", got.UserInfoURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fedb3f2f..80932e9d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -31,6 +31,31 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool LinuxDoConnectRedirectURL string + // Generic OIDC OAuth 登录 + OIDCConnectEnabled bool + OIDCConnectProviderName string + OIDCConnectClientID string + OIDCConnectClientSecret string + OIDCConnectClientSecretConfigured bool + OIDCConnectIssuerURL string + OIDCConnectDiscoveryURL string + OIDCConnectAuthorizeURL string + OIDCConnectTokenURL string + OIDCConnectUserInfoURL string + OIDCConnectJWKSURL string + OIDCConnectScopes string + OIDCConnectRedirectURL string + OIDCConnectFrontendRedirectURL string + OIDCConnectTokenAuthMethod string + OIDCConnectUsePKCE bool + OIDCConnectValidateIDToken bool + OIDCConnectAllowedSigningAlgs string + OIDCConnectClockSkewSeconds int + OIDCConnectRequireEmailVerified bool + OIDCConnectUserInfoEmailPath string + OIDCConnectUserInfoIDPath string + OIDCConnectUserInfoUsernamePath string + SiteName string SiteLogo string SiteSubtitle string @@ -110,9 +135,11 @@ type PublicSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - LinuxDoOAuthEnabled bool - BackendModeEnabled bool - Version string + LinuxDoOAuthEnabled bool + BackendModeEnabled bool + OIDCOAuthEnabled bool + OIDCOAuthProviderName string + Version string } // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 8f60acd5..cd6e7e3f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -820,6 +820,46 @@ linuxdo_connect: userinfo_id_path: "" userinfo_username_path: "" +# ============================================================================= +# Generic OIDC OAuth Login (SSO) +# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录) +# ============================================================================= +oidc_connect: + enabled: false + provider_name: "OIDC" + client_id: "" + client_secret: "" + # 例如: "https://keycloak.example.com/realms/myrealm" + issuer_url: "" + # 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks + discovery_url: "" + authorize_url: "" + token_url: "" + # 可选(仅补充 email/username,不用于 sub 可信绑定) + userinfo_url: "" + # validate_id_token=true 时必填 + jwks_url: "" + scopes: "openid email profile" + # 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback" + redirect_url: "" + # 安全提示: + # - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名 + # - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token) + frontend_redirect_url: "/auth/oidc/callback" + token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none + # 注意:当 token_auth_method=none(public client)时,必须启用 PKCE + use_pkce: false + # 开启后强制校验 id_token 的签名和 claims(推荐) + validate_id_token: true + allowed_signing_algs: "RS256,ES256,PS256" + # 允许的时钟偏移(秒) + clock_skew_seconds: 120 + # 若 Provider 返回 email_verified=false,是否拒绝登录 + require_email_verified: false + userinfo_email_path: "" + userinfo_id_path: "" + userinfo_username_path: "" + # ============================================================================= # Default Settings # 默认设置 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b7ee6be5..9916f1ab 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -62,6 +62,30 @@ export interface SystemSettings { linuxdo_connect_client_secret_configured: boolean linuxdo_connect_redirect_url: string + // Generic OIDC OAuth settings + oidc_connect_enabled: boolean + oidc_connect_provider_name: string + oidc_connect_client_id: string + oidc_connect_client_secret_configured: boolean + oidc_connect_issuer_url: string + oidc_connect_discovery_url: string + oidc_connect_authorize_url: string + oidc_connect_token_url: string + oidc_connect_userinfo_url: string + oidc_connect_jwks_url: string + oidc_connect_scopes: string + oidc_connect_redirect_url: string + oidc_connect_frontend_redirect_url: string + oidc_connect_token_auth_method: string + oidc_connect_use_pkce: boolean + oidc_connect_validate_id_token: boolean + oidc_connect_allowed_signing_algs: string + oidc_connect_clock_skew_seconds: number + oidc_connect_require_email_verified: boolean + oidc_connect_userinfo_email_path: string + oidc_connect_userinfo_id_path: string + oidc_connect_userinfo_username_path: string + // Model fallback configuration enable_model_fallback: boolean fallback_model_anthropic: string @@ -131,6 +155,28 @@ export interface UpdateSettingsRequest { linuxdo_connect_client_id?: string linuxdo_connect_client_secret?: string linuxdo_connect_redirect_url?: string + oidc_connect_enabled?: boolean + oidc_connect_provider_name?: string + oidc_connect_client_id?: string + oidc_connect_client_secret?: string + oidc_connect_issuer_url?: string + oidc_connect_discovery_url?: string + oidc_connect_authorize_url?: string + oidc_connect_token_url?: string + oidc_connect_userinfo_url?: string + oidc_connect_jwks_url?: string + oidc_connect_scopes?: string + oidc_connect_redirect_url?: string + oidc_connect_frontend_redirect_url?: string + oidc_connect_token_auth_method?: string + oidc_connect_use_pkce?: boolean + oidc_connect_validate_id_token?: boolean + oidc_connect_allowed_signing_algs?: string + oidc_connect_clock_skew_seconds?: number + oidc_connect_require_email_verified?: boolean + oidc_connect_userinfo_email_path?: string + oidc_connect_userinfo_id_path?: string + oidc_connect_userinfo_username_path?: string enable_model_fallback?: boolean fallback_model_anthropic?: string fallback_model_openai?: string diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c5e1f35d..837c4f4c 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration( return data } +/** + * Complete OIDC OAuth registration by supplying an invitation code + * @param pendingOAuthToken - Short-lived JWT from the OAuth callback + * @param invitationCode - Invitation code entered by the user + * @returns Token pair on success + */ +export async function completeOIDCOAuthRegistration( + pendingOAuthToken: string, + invitationCode: string +): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> { + const { data } = await apiClient.post<{ + access_token: string + refresh_token: string + expires_in: number + token_type: string + }>('/auth/oauth/oidc/complete-registration', { + pending_oauth_token: pendingOAuthToken, + invitation_code: invitationCode + }) + return data +} + export const authAPI = { login, login2FA, @@ -380,7 +402,8 @@ export const authAPI = { resetPassword, refreshToken, revokeAllSessions, - completeLinuxDoOAuthRegistration + completeLinuxDoOAuthRegistration, + completeOIDCOAuthRegistration } export default authAPI diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index 8012b101..c740d06f 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -29,10 +29,10 @@ {{ t('auth.linuxdo.signIn') }} -
+
- {{ t('auth.linuxdo.orContinue') }} + {{ t('auth.oauthOrContinue') }}
@@ -43,9 +43,12 @@ import { useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' -defineProps<{ +withDefaults(defineProps<{ disabled?: boolean -}>() + showDivider?: boolean +}>(), { + showDivider: true +}) const route = useRoute() const { t } = useI18n() @@ -58,4 +61,3 @@ function startLogin(): void { window.location.href = startURL } - diff --git a/frontend/src/components/auth/OidcOAuthSection.vue b/frontend/src/components/auth/OidcOAuthSection.vue new file mode 100644 index 00000000..f7cc7fa3 --- /dev/null +++ b/frontend/src/components/auth/OidcOAuthSection.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d3b16d4a..475d8f33 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -428,6 +428,7 @@ export default { invitationCodeInvalid: 'Invalid or used invitation code', invitationCodeValidating: 'Validating invitation code...', invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', + oauthOrContinue: 'or continue with email', linuxdo: { signIn: 'Continue with Linux.do', orContinue: 'or continue with email', @@ -442,6 +443,20 @@ export default { completing: 'Completing registration…', completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' }, + oidc: { + signIn: 'Continue with {providerName}', + callbackTitle: 'Signing you in with {providerName}', + callbackProcessing: 'Completing login with {providerName}, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: + 'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' + }, oauth: { code: 'Code', state: 'State', @@ -4227,6 +4242,57 @@ export default { quickSetCopy: 'Generate & Copy (current site)', redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' }, + oidc: { + title: 'OIDC Login', + description: 'Configure a standard OIDC provider (for example Keycloak)', + enable: 'Enable OIDC Login', + enableHint: 'Show OIDC login on the login/register pages', + providerName: 'Provider Name', + providerNamePlaceholder: 'for example Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: 'Optional, can be discovered automatically', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: 'Optional, can be discovered automatically', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: 'Optional, can be discovered automatically', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: 'Must include openid', + redirectUrl: 'Backend Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: 'Must match the callback URL configured in the OIDC provider', + quickSetCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', + frontendRedirectUrl: 'Frontend Callback Path', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: 'Frontend route used after backend callback', + tokenAuthMethod: 'Token Auth Method', + clockSkewSeconds: 'Clock Skew (seconds)', + allowedSigningAlgs: 'Allowed Signing Algs', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: 'Use PKCE', + validateIdToken: 'Validate ID Token', + requireEmailVerified: 'Require Email Verified', + userinfoEmailPath: 'UserInfo Email Path', + userinfoEmailPathPlaceholder: 'for example data.email', + userinfoIdPath: 'UserInfo ID Path', + userinfoIdPathPlaceholder: 'for example data.id', + userinfoUsernamePath: 'UserInfo Username Path', + userinfoUsernamePathPlaceholder: 'for example data.username' + }, defaults: { title: 'Default User Settings', description: 'Default values for new users', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fcaaf5ab..eee2725c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -427,6 +427,7 @@ export default { invitationCodeInvalid: '邀请码无效或已被使用', invitationCodeValidating: '正在验证邀请码...', invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', + oauthOrContinue: '或使用邮箱密码继续', linuxdo: { signIn: '使用 Linux.do 登录', orContinue: '或使用邮箱密码继续', @@ -441,6 +442,19 @@ export default { completing: '正在完成注册...', completeRegistrationFailed: '注册失败,请检查邀请码后重试。' }, + oidc: { + signIn: '使用 {providerName} 登录', + callbackTitle: '正在完成 {providerName} 登录', + callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。' + }, oauth: { code: '授权码', state: '状态', @@ -4393,6 +4407,57 @@ export default { quickSetCopy: '使用当前站点生成并复制', redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' }, + oidc: { + title: 'OIDC 登录', + description: '配置标准 OIDC Provider(例如 Keycloak)', + enable: '启用 OIDC 登录', + enableHint: '在登录/注册页面显示 OIDC 登录入口', + providerName: 'Provider 名称', + providerNamePlaceholder: '例如 Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: '可选,可通过 discovery 自动获取', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: '必须包含 openid', + redirectUrl: '后端回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致', + quickSetCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', + frontendRedirectUrl: '前端回调路径', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径', + tokenAuthMethod: 'Token 鉴权方式', + clockSkewSeconds: '时钟偏移(秒)', + allowedSigningAlgs: '允许的签名算法', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: '启用 PKCE', + validateIdToken: '校验 ID Token', + requireEmailVerified: '要求邮箱已验证', + userinfoEmailPath: 'UserInfo 邮箱字段路径', + userinfoEmailPathPlaceholder: '例如 data.email', + userinfoIdPath: 'UserInfo ID 字段路径', + userinfoIdPathPlaceholder: '例如 data.id', + userinfoUsernamePath: 'UserInfo 用户名字段路径', + userinfoUsernamePathPlaceholder: '例如 data.username' + }, defaults: { title: '用户默认设置', description: '新用户的默认值', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6faf6f59..9bc6115f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [ title: 'LinuxDo OAuth Callback' } }, + { + path: '/auth/oidc/callback', + name: 'OIDCOAuthCallback', + component: () => import('@/views/auth/OidcCallbackView.vue'), + meta: { + requiresAuth: false, + title: 'OIDC OAuth Callback' + } + }, { path: '/forgot-password', name: 'ForgotPassword', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 24057136..edeb3bb1 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -332,6 +332,9 @@ export const useAppStore = defineStore('app', () => { custom_menu_items: [], custom_endpoints: [], linuxdo_oauth_enabled: false, + oidc_oauth_enabled: false, + oidc_oauth_provider_name: 'OIDC', + sora_client_enabled: false, backend_mode_enabled: false, version: siteVersion.value } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 580126c8..b4f8f79a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -109,6 +109,9 @@ export interface PublicSettings { custom_menu_items: CustomMenuItem[] custom_endpoints: CustomEndpoint[] linuxdo_oauth_enabled: boolean + oidc_oauth_enabled: boolean + oidc_oauth_provider_name: string + sora_client_enabled: boolean backend_mode_enabled: boolean version: string } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index f43140ab..dd934235 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1124,7 +1124,327 @@
- + + +
+
+

+ {{ t('admin.settings.oidc.title') }} +

+

+ {{ t('admin.settings.oidc.description') }} +

+
+
+
+
+ +

+ {{ t('admin.settings.oidc.enableHint') }} +

+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +

+ {{ + form.oidc_connect_client_secret_configured + ? t('admin.settings.oidc.clientSecretConfiguredHint') + : t('admin.settings.oidc.clientSecretHint') + }} +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +

+ {{ t('admin.settings.oidc.scopesHint') }} +

+
+ +
+ + +
+ + + {{ oidcRedirectUrlSuggestion }} + +
+

+ {{ t('admin.settings.oidc.redirectUrlHint') }} +

+
+ +
+ + +

+ {{ t('admin.settings.oidc.frontendRedirectUrlHint') }} +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
@@ -2193,6 +2513,7 @@ type SettingsForm = SystemSettings & { smtp_password: string turnstile_secret_key: string linuxdo_connect_client_secret: string + oidc_connect_client_secret: string } const form = reactive({ @@ -2240,6 +2561,30 @@ const form = reactive({ linuxdo_connect_client_secret: '', linuxdo_connect_client_secret_configured: false, linuxdo_connect_redirect_url: '', + // Generic OIDC OAuth 登录 + oidc_connect_enabled: false, + oidc_connect_provider_name: 'OIDC', + oidc_connect_client_id: '', + oidc_connect_client_secret: '', + oidc_connect_client_secret_configured: false, + oidc_connect_issuer_url: '', + oidc_connect_discovery_url: '', + oidc_connect_authorize_url: '', + oidc_connect_token_url: '', + oidc_connect_userinfo_url: '', + oidc_connect_jwks_url: '', + oidc_connect_scopes: 'openid email profile', + oidc_connect_redirect_url: '', + oidc_connect_frontend_redirect_url: '/auth/oidc/callback', + oidc_connect_token_auth_method: 'client_secret_post', + oidc_connect_use_pkce: false, + oidc_connect_validate_id_token: true, + oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256', + oidc_connect_clock_skew_seconds: 120, + oidc_connect_require_email_verified: false, + oidc_connect_userinfo_email_path: '', + oidc_connect_userinfo_id_path: '', + oidc_connect_userinfo_username_path: '', // Model fallback enable_model_fallback: false, fallback_model_anthropic: 'claude-3-5-sonnet-20241022', @@ -2360,6 +2705,21 @@ async function setAndCopyLinuxdoRedirectUrl() { await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) } +const oidcRedirectUrlSuggestion = computed(() => { + if (typeof window === 'undefined') return '' + const origin = + window.location.origin || `${window.location.protocol}//${window.location.host}` + return `${origin}/api/v1/auth/oauth/oidc/callback` +}) + +async function setAndCopyOIDCRedirectUrl() { + const url = oidcRedirectUrlSuggestion.value + if (!url) return + + form.oidc_connect_redirect_url = url + await copyToClipboard(url, t('admin.settings.oidc.redirectUrlSetAndCopied')) +} + // Custom menu item management function addMenuItem() { form.custom_menu_items.push({ @@ -2425,6 +2785,7 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' + form.oidc_connect_client_secret = '' } catch (error: any) { loadFailed.value = true appStore.showError( @@ -2559,6 +2920,28 @@ async function saveSettings() { linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined, linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url, + oidc_connect_enabled: form.oidc_connect_enabled, + oidc_connect_provider_name: form.oidc_connect_provider_name, + oidc_connect_client_id: form.oidc_connect_client_id, + oidc_connect_client_secret: form.oidc_connect_client_secret || undefined, + oidc_connect_issuer_url: form.oidc_connect_issuer_url, + oidc_connect_discovery_url: form.oidc_connect_discovery_url, + oidc_connect_authorize_url: form.oidc_connect_authorize_url, + oidc_connect_token_url: form.oidc_connect_token_url, + oidc_connect_userinfo_url: form.oidc_connect_userinfo_url, + oidc_connect_jwks_url: form.oidc_connect_jwks_url, + oidc_connect_scopes: form.oidc_connect_scopes, + oidc_connect_redirect_url: form.oidc_connect_redirect_url, + oidc_connect_frontend_redirect_url: form.oidc_connect_frontend_redirect_url, + oidc_connect_token_auth_method: form.oidc_connect_token_auth_method, + oidc_connect_use_pkce: form.oidc_connect_use_pkce, + oidc_connect_validate_id_token: form.oidc_connect_validate_id_token, + oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs, + oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds, + oidc_connect_require_email_verified: form.oidc_connect_require_email_verified, + oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path, + oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path, + oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path, enable_model_fallback: form.enable_model_fallback, fallback_model_anthropic: form.fallback_model_anthropic, fallback_model_openai: form.fallback_model_openai, @@ -2583,6 +2966,7 @@ async function saveSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' + form.oidc_connect_client_secret = '' // Refresh cached settings so sidebar/header update immediately await appStore.fetchPublicSettings(true) await adminSettingsStore.fetch(true) diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 73d2474c..70b64e3f 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -11,8 +11,26 @@

- - +
+ + +
+
+ + {{ t('auth.oauthOrContinue') }} + +
+
+
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' +import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' import TotpLoginModal from '@/components/auth/TotpLoginModal.vue' import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' @@ -207,6 +226,8 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const linuxdoOAuthEnabled = ref(false) const backendModeEnabled = ref(false) +const oidcOAuthEnabled = ref(false) +const oidcOAuthProviderName = ref('OIDC') const passwordResetEnabled = ref(false) // Turnstile @@ -247,6 +268,9 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled backendModeEnabled.value = settings.backend_mode_enabled + oidcOAuthEnabled.value = settings.oidc_oauth_enabled + oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' + backendModeEnabled.value = settings.backend_mode_enabled passwordResetEnabled.value = settings.password_reset_enabled } catch (error) { console.error('Failed to load public settings:', error) diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue new file mode 100644 index 00000000..a6cb6c12 --- /dev/null +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index d1b576d4..bc8b8dce 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -11,8 +11,26 @@

- - +
+ + +
+
+ + {{ t('auth.oauthOrContinue') }} + +
+
+
(false) const turnstileSiteKey = ref('') const siteName = ref('Sub2API') const linuxdoOAuthEnabled = ref(false) +const oidcOAuthEnabled = ref(false) +const oidcOAuthProviderName = ref('OIDC') const registrationEmailSuffixWhitelist = ref([]) // Turnstile @@ -376,6 +397,8 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' siteName.value = settings.site_name || 'Sub2API' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled + oidcOAuthEnabled.value = settings.oidc_oauth_enabled + oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( settings.registration_email_suffix_whitelist || [] )