feat: support OIDC login.
This commit is contained in:
@@ -65,6 +65,7 @@ type Config struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Totp TotpConfig `mapstructure:"totp"`
|
Totp TotpConfig `mapstructure:"totp"`
|
||||||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||||
|
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||||||
Default DefaultConfig `mapstructure:"default"`
|
Default DefaultConfig `mapstructure:"default"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Pricing PricingConfig `mapstructure:"pricing"`
|
Pricing PricingConfig `mapstructure:"pricing"`
|
||||||
@@ -184,6 +185,34 @@ type LinuxDoConnectConfig struct {
|
|||||||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
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自动刷新配置
|
// TokenRefreshConfig OAuth token自动刷新配置
|
||||||
type TokenRefreshConfig struct {
|
type TokenRefreshConfig struct {
|
||||||
// 是否启用自动刷新
|
// 是否启用自动刷新
|
||||||
@@ -968,6 +997,23 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
|||||||
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
||||||
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
||||||
cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
|
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.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix)
|
||||||
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
||||||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
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_id_path", "")
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_username_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
|
// Database
|
||||||
viper.SetDefault("database.host", "localhost")
|
viper.SetDefault("database.host", "localhost")
|
||||||
viper.SetDefault("database.port", 5432)
|
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.redirect_url", c.LinuxDo.RedirectURL)
|
||||||
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
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.Enabled {
|
||||||
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
||||||
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
||||||
@@ -2184,6 +2335,15 @@ func ValidateFrontendRedirectURL(raw string) error {
|
|||||||
return nil
|
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 协议
|
// isHTTPScheme 检查是否为 HTTP 或 HTTPS 协议
|
||||||
func isHTTPScheme(scheme string) bool {
|
func isHTTPScheme(scheme string) bool {
|
||||||
return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https")
|
return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https")
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadDefaultDashboardCacheConfig(t *testing.T) {
|
||||||
resetViperWithJWTSecret(t)
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ func generateMenuItemID() (string, error) {
|
|||||||
return hex.EncodeToString(b), nil
|
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 系统设置处理器
|
// SettingHandler 系统设置处理器
|
||||||
type SettingHandler struct {
|
type SettingHandler struct {
|
||||||
settingService *service.SettingService
|
settingService *service.SettingService
|
||||||
@@ -96,6 +105,28 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
||||||
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
||||||
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
|
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,
|
SiteName: settings.SiteName,
|
||||||
SiteLogo: settings.SiteLogo,
|
SiteLogo: settings.SiteLogo,
|
||||||
SiteSubtitle: settings.SiteSubtitle,
|
SiteSubtitle: settings.SiteSubtitle,
|
||||||
@@ -164,6 +195,30 @@ type UpdateSettingsRequest struct {
|
|||||||
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
||||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
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设置
|
// OEM设置
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
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
|
purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled
|
||||||
if req.PurchaseSubscriptionEnabled != nil {
|
if req.PurchaseSubscriptionEnabled != nil {
|
||||||
@@ -554,6 +725,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
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,
|
SiteName: req.SiteName,
|
||||||
SiteLogo: req.SiteLogo,
|
SiteLogo: req.SiteLogo,
|
||||||
SiteSubtitle: req.SiteSubtitle,
|
SiteSubtitle: req.SiteSubtitle,
|
||||||
@@ -669,6 +862,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||||||
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||||||
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
|
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,
|
SiteName: updatedSettings.SiteName,
|
||||||
SiteLogo: updatedSettings.SiteLogo,
|
SiteLogo: updatedSettings.SiteLogo,
|
||||||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||||||
@@ -787,6 +1002,72 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
||||||
changed = append(changed, "linuxdo_connect_redirect_url")
|
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 {
|
if before.SiteName != after.SiteName {
|
||||||
changed = append(changed, "site_name")
|
changed = append(changed, "site_name")
|
||||||
}
|
}
|
||||||
|
|||||||
865
backend/internal/handler/auth_oidc_oauth.go
Normal file
865
backend/internal/handler/auth_oidc_oauth.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
106
backend/internal/handler/auth_oidc_oauth_test.go
Normal file
106
backend/internal/handler/auth_oidc_oauth_test.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,29 @@ type SystemSettings struct {
|
|||||||
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
||||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
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"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
@@ -128,6 +151,9 @@ type PublicSettings struct {
|
|||||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
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"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
||||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||||
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||||
|
SoraClientEnabled: settings.SoraClientEnabled,
|
||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
service.SettingKeyTurnstileSiteKey: "site-key",
|
service.SettingKeyTurnstileSiteKey: "site-key",
|
||||||
service.SettingKeyTurnstileSecretKey: "secret-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.SettingKeySiteName: "Sub2API",
|
||||||
service.SettingKeySiteLogo: "",
|
service.SettingKeySiteLogo: "",
|
||||||
service.SettingKeySiteSubtitle: "Subtitle",
|
service.SettingKeySiteSubtitle: "Subtitle",
|
||||||
@@ -503,10 +525,32 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"turnstile_enabled": true,
|
"turnstile_enabled": true,
|
||||||
"turnstile_site_key": "site-key",
|
"turnstile_site_key": "site-key",
|
||||||
"turnstile_secret_key_configured": true,
|
"turnstile_secret_key_configured": true,
|
||||||
"linuxdo_connect_enabled": false,
|
"linuxdo_connect_enabled": false,
|
||||||
"linuxdo_connect_client_id": "",
|
"linuxdo_connect_client_id": "",
|
||||||
"linuxdo_connect_client_secret_configured": false,
|
"linuxdo_connect_client_secret_configured": false,
|
||||||
"linuxdo_connect_redirect_url": "",
|
"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_monitoring_enabled": false,
|
||||||
"ops_realtime_monitoring_enabled": true,
|
"ops_realtime_monitoring_enabled": true,
|
||||||
"ops_query_mode_default": "auto",
|
"ops_query_mode_default": "auto",
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ func RegisterAuthRoutes(
|
|||||||
}),
|
}),
|
||||||
h.Auth.CompleteLinuxDoOAuthRegistration,
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开设置(无需认证)
|
// 公开设置(无需认证)
|
||||||
|
|||||||
@@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) {
|
|||||||
|
|
||||||
func isReservedEmail(email string) bool {
|
func isReservedEmail(email string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain)
|
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) ||
|
||||||
|
strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken 生成JWT access token
|
// GenerateToken 生成JWT access token
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ const (
|
|||||||
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
|
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
|
||||||
const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
||||||
|
|
||||||
|
// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。
|
||||||
|
const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid"
|
||||||
|
|
||||||
// Setting keys
|
// Setting keys
|
||||||
const (
|
const (
|
||||||
// 注册设置
|
// 注册设置
|
||||||
@@ -105,6 +108,30 @@ const (
|
|||||||
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
||||||
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
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设置
|
// OEM设置
|
||||||
SettingKeySiteName = "site_name" // 网站名称
|
SettingKeySiteName = "site_name" // 网站名称
|
||||||
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/imroc/req/v3"
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +165,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyCustomEndpoints,
|
SettingKeyCustomEndpoints,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
SettingKeyBackendModeEnabled,
|
SettingKeyBackendModeEnabled,
|
||||||
|
SettingKeyOIDCConnectEnabled,
|
||||||
|
SettingKeyOIDCConnectProviderName,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
@@ -177,6 +180,19 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
} else {
|
} else {
|
||||||
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
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
|
// Password reset requires email verification to be enabled
|
||||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||||
@@ -209,6 +225,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||||
|
OIDCOAuthEnabled: oidcEnabled,
|
||||||
|
OIDCOAuthProviderName: oidcProviderName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +274,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
BackendModeEnabled bool `json:"backend_mode_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"`
|
Version string `json:"version,omitempty"`
|
||||||
}{
|
}{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
@@ -281,6 +301,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||||
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||||
Version: s.version,
|
Version: s.version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -460,6 +482,32 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
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设置
|
// OEM设置
|
||||||
updates[SettingKeySiteName] = settings.SiteName
|
updates[SettingKeySiteName] = settings.SiteName
|
||||||
updates[SettingKeySiteLogo] = settings.SiteLogo
|
updates[SettingKeySiteLogo] = settings.SiteLogo
|
||||||
@@ -826,6 +874,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyPurchaseSubscriptionURL: "",
|
SettingKeyPurchaseSubscriptionURL: "",
|
||||||
SettingKeyCustomMenuItems: "[]",
|
SettingKeyCustomMenuItems: "[]",
|
||||||
SettingKeyCustomEndpoints: "[]",
|
SettingKeyCustomEndpoints: "[]",
|
||||||
|
SettingKeyOIDCConnectEnabled: "false",
|
||||||
|
SettingKeyOIDCConnectProviderName: "OIDC",
|
||||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||||
SettingKeyDefaultSubscriptions: "[]",
|
SettingKeyDefaultSubscriptions: "[]",
|
||||||
@@ -951,6 +1001,138 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
}
|
}
|
||||||
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
|
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
|
// Model fallback settings
|
||||||
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
||||||
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
|
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))
|
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 获取流超时处理配置
|
// GetStreamTimeoutSettings 获取流超时处理配置
|
||||||
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
|
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
|
||||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
|
||||||
|
|||||||
103
backend/internal/service/setting_service_oidc_config_test.go
Normal file
103
backend/internal/service/setting_service_oidc_config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -31,6 +31,31 @@ type SystemSettings struct {
|
|||||||
LinuxDoConnectClientSecretConfigured bool
|
LinuxDoConnectClientSecretConfigured bool
|
||||||
LinuxDoConnectRedirectURL string
|
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
|
SiteName string
|
||||||
SiteLogo string
|
SiteLogo string
|
||||||
SiteSubtitle string
|
SiteSubtitle string
|
||||||
@@ -110,9 +135,11 @@ type PublicSettings struct {
|
|||||||
CustomMenuItems string // JSON array of custom menu items
|
CustomMenuItems string // JSON array of custom menu items
|
||||||
CustomEndpoints string // JSON array of custom endpoints
|
CustomEndpoints string // JSON array of custom endpoints
|
||||||
|
|
||||||
LinuxDoOAuthEnabled bool
|
LinuxDoOAuthEnabled bool
|
||||||
BackendModeEnabled bool
|
BackendModeEnabled bool
|
||||||
Version string
|
OIDCOAuthEnabled bool
|
||||||
|
OIDCOAuthProviderName string
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||||
|
|||||||
@@ -820,6 +820,46 @@ linuxdo_connect:
|
|||||||
userinfo_id_path: ""
|
userinfo_id_path: ""
|
||||||
userinfo_username_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
|
# Default Settings
|
||||||
# 默认设置
|
# 默认设置
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ export interface SystemSettings {
|
|||||||
linuxdo_connect_client_secret_configured: boolean
|
linuxdo_connect_client_secret_configured: boolean
|
||||||
linuxdo_connect_redirect_url: string
|
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
|
// Model fallback configuration
|
||||||
enable_model_fallback: boolean
|
enable_model_fallback: boolean
|
||||||
fallback_model_anthropic: string
|
fallback_model_anthropic: string
|
||||||
@@ -131,6 +155,28 @@ export interface UpdateSettingsRequest {
|
|||||||
linuxdo_connect_client_id?: string
|
linuxdo_connect_client_id?: string
|
||||||
linuxdo_connect_client_secret?: string
|
linuxdo_connect_client_secret?: string
|
||||||
linuxdo_connect_redirect_url?: 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
|
enable_model_fallback?: boolean
|
||||||
fallback_model_anthropic?: string
|
fallback_model_anthropic?: string
|
||||||
fallback_model_openai?: string
|
fallback_model_openai?: string
|
||||||
|
|||||||
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
|
|||||||
return data
|
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 = {
|
export const authAPI = {
|
||||||
login,
|
login,
|
||||||
login2FA,
|
login2FA,
|
||||||
@@ -380,7 +402,8 @@ export const authAPI = {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
revokeAllSessions,
|
revokeAllSessions,
|
||||||
completeLinuxDoOAuthRegistration
|
completeLinuxDoOAuthRegistration,
|
||||||
|
completeOIDCOAuthRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
export default authAPI
|
export default authAPI
|
||||||
|
|||||||
@@ -29,10 +29,10 @@
|
|||||||
{{ t('auth.linuxdo.signIn') }}
|
{{ t('auth.linuxdo.signIn') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div v-if="showDivider" class="flex items-center gap-3">
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
{{ t('auth.linuxdo.orContinue') }}
|
{{ t('auth.oauthOrContinue') }}
|
||||||
</span>
|
</span>
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,9 +43,12 @@
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
showDivider?: boolean
|
||||||
|
}>(), {
|
||||||
|
showDivider: true
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -58,4 +61,3 @@ function startLogin(): void {
|
|||||||
window.location.href = startURL
|
window.location.href = startURL
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||||
|
<span
|
||||||
|
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||||
|
>
|
||||||
|
{{ providerInitial }}
|
||||||
|
</span>
|
||||||
|
{{ t('auth.oidc.signIn', { providerName: normalizedProviderName }) }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showDivider" class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
disabled?: boolean
|
||||||
|
providerName?: string
|
||||||
|
showDivider?: boolean
|
||||||
|
}>(), {
|
||||||
|
providerName: 'OIDC',
|
||||||
|
showDivider: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const normalizedProviderName = computed(() => {
|
||||||
|
const name = props.providerName?.trim()
|
||||||
|
return name || 'OIDC'
|
||||||
|
})
|
||||||
|
|
||||||
|
const providerInitial = computed(() => normalizedProviderName.value.charAt(0).toUpperCase() || 'O')
|
||||||
|
|
||||||
|
function startLogin(): void {
|
||||||
|
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||||
|
const normalized = apiBase.replace(/\/$/, '')
|
||||||
|
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||||
|
window.location.href = startURL
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -428,6 +428,7 @@ export default {
|
|||||||
invitationCodeInvalid: 'Invalid or used invitation code',
|
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||||
invitationCodeValidating: 'Validating invitation code...',
|
invitationCodeValidating: 'Validating invitation code...',
|
||||||
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||||
|
oauthOrContinue: 'or continue with email',
|
||||||
linuxdo: {
|
linuxdo: {
|
||||||
signIn: 'Continue with Linux.do',
|
signIn: 'Continue with Linux.do',
|
||||||
orContinue: 'or continue with email',
|
orContinue: 'or continue with email',
|
||||||
@@ -442,6 +443,20 @@ export default {
|
|||||||
completing: 'Completing registration…',
|
completing: 'Completing registration…',
|
||||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
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: {
|
oauth: {
|
||||||
code: 'Code',
|
code: 'Code',
|
||||||
state: 'State',
|
state: 'State',
|
||||||
@@ -4227,6 +4242,57 @@ export default {
|
|||||||
quickSetCopy: 'Generate & Copy (current site)',
|
quickSetCopy: 'Generate & Copy (current site)',
|
||||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
|
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: {
|
defaults: {
|
||||||
title: 'Default User Settings',
|
title: 'Default User Settings',
|
||||||
description: 'Default values for new users',
|
description: 'Default values for new users',
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export default {
|
|||||||
invitationCodeInvalid: '邀请码无效或已被使用',
|
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||||
invitationCodeValidating: '正在验证邀请码...',
|
invitationCodeValidating: '正在验证邀请码...',
|
||||||
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||||
|
oauthOrContinue: '或使用邮箱密码继续',
|
||||||
linuxdo: {
|
linuxdo: {
|
||||||
signIn: '使用 Linux.do 登录',
|
signIn: '使用 Linux.do 登录',
|
||||||
orContinue: '或使用邮箱密码继续',
|
orContinue: '或使用邮箱密码继续',
|
||||||
@@ -441,6 +442,19 @@ export default {
|
|||||||
completing: '正在完成注册...',
|
completing: '正在完成注册...',
|
||||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||||
},
|
},
|
||||||
|
oidc: {
|
||||||
|
signIn: '使用 {providerName} 登录',
|
||||||
|
callbackTitle: '正在完成 {providerName} 登录',
|
||||||
|
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
|
||||||
|
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||||
|
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||||
|
backToLogin: '返回登录',
|
||||||
|
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||||
|
invalidPendingToken: '注册凭证已失效,请重新登录。',
|
||||||
|
completeRegistration: '完成注册',
|
||||||
|
completing: '正在完成注册...',
|
||||||
|
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||||
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
code: '授权码',
|
code: '授权码',
|
||||||
state: '状态',
|
state: '状态',
|
||||||
@@ -4393,6 +4407,57 @@ export default {
|
|||||||
quickSetCopy: '使用当前站点生成并复制',
|
quickSetCopy: '使用当前站点生成并复制',
|
||||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
|
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: {
|
defaults: {
|
||||||
title: '用户默认设置',
|
title: '用户默认设置',
|
||||||
description: '新用户的默认值',
|
description: '新用户的默认值',
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: 'LinuxDo OAuth Callback'
|
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',
|
path: '/forgot-password',
|
||||||
name: 'ForgotPassword',
|
name: 'ForgotPassword',
|
||||||
|
|||||||
@@ -332,6 +332,9 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
custom_menu_items: [],
|
custom_menu_items: [],
|
||||||
custom_endpoints: [],
|
custom_endpoints: [],
|
||||||
linuxdo_oauth_enabled: false,
|
linuxdo_oauth_enabled: false,
|
||||||
|
oidc_oauth_enabled: false,
|
||||||
|
oidc_oauth_provider_name: 'OIDC',
|
||||||
|
sora_client_enabled: false,
|
||||||
backend_mode_enabled: false,
|
backend_mode_enabled: false,
|
||||||
version: siteVersion.value
|
version: siteVersion.value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export interface PublicSettings {
|
|||||||
custom_menu_items: CustomMenuItem[]
|
custom_menu_items: CustomMenuItem[]
|
||||||
custom_endpoints: CustomEndpoint[]
|
custom_endpoints: CustomEndpoint[]
|
||||||
linuxdo_oauth_enabled: boolean
|
linuxdo_oauth_enabled: boolean
|
||||||
|
oidc_oauth_enabled: boolean
|
||||||
|
oidc_oauth_provider_name: string
|
||||||
|
sora_client_enabled: boolean
|
||||||
backend_mode_enabled: boolean
|
backend_mode_enabled: boolean
|
||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1124,7 +1124,327 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo -->
|
|
||||||
|
<!-- Generic OIDC OAuth 登录 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.oidc.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.oidc.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
t('admin.settings.oidc.enable')
|
||||||
|
}}</label>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.oidc.enableHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.oidc_connect_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="form.oidc_connect_enabled"
|
||||||
|
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.providerName') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_provider_name"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.settings.oidc.providerNamePlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.clientId') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_client_id"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.clientIdPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.clientSecret') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_client_secret"
|
||||||
|
type="password"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="
|
||||||
|
form.oidc_connect_client_secret_configured
|
||||||
|
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
|
||||||
|
: t('admin.settings.oidc.clientSecretPlaceholder')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
form.oidc_connect_client_secret_configured
|
||||||
|
? t('admin.settings.oidc.clientSecretConfiguredHint')
|
||||||
|
: t('admin.settings.oidc.clientSecretHint')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.issuerUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_issuer_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.issuerUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.discoveryUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_discovery_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.discoveryUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.authorizeUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_authorize_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.authorizeUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.tokenUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_token_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.tokenUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.userinfoUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_userinfo_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.userinfoUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.jwksUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_jwks_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.jwksUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.scopes') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_scopes"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.scopesPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.oidc.scopesHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.redirectUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_redirect_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.redirectUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm w-fit"
|
||||||
|
@click="setAndCopyOIDCRedirectUrl"
|
||||||
|
>
|
||||||
|
{{ t('admin.settings.oidc.quickSetCopy') }}
|
||||||
|
</button>
|
||||||
|
<code
|
||||||
|
v-if="oidcRedirectUrlSuggestion"
|
||||||
|
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ oidcRedirectUrlSuggestion }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.oidc.redirectUrlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.frontendRedirectUrl') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_frontend_redirect_url"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.frontendRedirectUrlPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.oidc.frontendRedirectUrlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.tokenAuthMethod') }}
|
||||||
|
</label>
|
||||||
|
<select v-model="form.oidc_connect_token_auth_method" class="input font-mono text-sm">
|
||||||
|
<option value="client_secret_post">client_secret_post</option>
|
||||||
|
<option value="client_secret_basic">client_secret_basic</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.clockSkewSeconds') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.oidc_connect_clock_skew_seconds"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="600"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.allowedSigningAlgs') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_allowed_signing_algs"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.allowedSigningAlgsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.oidc.usePkce') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.oidc_connect_use_pkce" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.oidc.validateIdToken') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.oidc_connect_validate_id_token" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.oidc.requireEmailVerified') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.oidc_connect_require_email_verified" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.userinfoEmailPath') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_userinfo_email_path"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.userinfoEmailPathPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.userinfoIdPath') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_userinfo_id_path"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.userinfoIdPathPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.oidc.userinfoUsernamePath') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.oidc_connect_userinfo_username_path"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.settings.oidc.userinfoUsernamePathPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo, OIDC -->
|
||||||
|
|
||||||
<!-- Tab: Users -->
|
<!-- Tab: Users -->
|
||||||
<div v-show="activeTab === 'users'" class="space-y-6">
|
<div v-show="activeTab === 'users'" class="space-y-6">
|
||||||
@@ -2193,6 +2513,7 @@ type SettingsForm = SystemSettings & {
|
|||||||
smtp_password: string
|
smtp_password: string
|
||||||
turnstile_secret_key: string
|
turnstile_secret_key: string
|
||||||
linuxdo_connect_client_secret: string
|
linuxdo_connect_client_secret: string
|
||||||
|
oidc_connect_client_secret: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive<SettingsForm>({
|
const form = reactive<SettingsForm>({
|
||||||
@@ -2240,6 +2561,30 @@ const form = reactive<SettingsForm>({
|
|||||||
linuxdo_connect_client_secret: '',
|
linuxdo_connect_client_secret: '',
|
||||||
linuxdo_connect_client_secret_configured: false,
|
linuxdo_connect_client_secret_configured: false,
|
||||||
linuxdo_connect_redirect_url: '',
|
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
|
// Model fallback
|
||||||
enable_model_fallback: false,
|
enable_model_fallback: false,
|
||||||
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
||||||
@@ -2360,6 +2705,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
|||||||
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
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
|
// Custom menu item management
|
||||||
function addMenuItem() {
|
function addMenuItem() {
|
||||||
form.custom_menu_items.push({
|
form.custom_menu_items.push({
|
||||||
@@ -2425,6 +2785,7 @@ async function loadSettings() {
|
|||||||
smtpPasswordManuallyEdited.value = false
|
smtpPasswordManuallyEdited.value = false
|
||||||
form.turnstile_secret_key = ''
|
form.turnstile_secret_key = ''
|
||||||
form.linuxdo_connect_client_secret = ''
|
form.linuxdo_connect_client_secret = ''
|
||||||
|
form.oidc_connect_client_secret = ''
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
loadFailed.value = true
|
loadFailed.value = true
|
||||||
appStore.showError(
|
appStore.showError(
|
||||||
@@ -2559,6 +2920,28 @@ async function saveSettings() {
|
|||||||
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||||
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
||||||
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
|
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,
|
enable_model_fallback: form.enable_model_fallback,
|
||||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||||
fallback_model_openai: form.fallback_model_openai,
|
fallback_model_openai: form.fallback_model_openai,
|
||||||
@@ -2583,6 +2966,7 @@ async function saveSettings() {
|
|||||||
smtpPasswordManuallyEdited.value = false
|
smtpPasswordManuallyEdited.value = false
|
||||||
form.turnstile_secret_key = ''
|
form.turnstile_secret_key = ''
|
||||||
form.linuxdo_connect_client_secret = ''
|
form.linuxdo_connect_client_secret = ''
|
||||||
|
form.oidc_connect_client_secret = ''
|
||||||
// Refresh cached settings so sidebar/header update immediately
|
// Refresh cached settings so sidebar/header update immediately
|
||||||
await appStore.fetchPublicSettings(true)
|
await appStore.fetchPublicSettings(true)
|
||||||
await adminSettingsStore.fetch(true)
|
await adminSettingsStore.fetch(true)
|
||||||
|
|||||||
@@ -11,8 +11,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LinuxDo Connect OAuth 登录 -->
|
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled && !backendModeEnabled" :disabled="isLoading" />
|
<LinuxDoOAuthSection
|
||||||
|
v-if="linuxdoOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<OidcOAuthSection
|
||||||
|
v-if="oidcOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:provider-name="oidcOAuthProviderName"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||||
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
|
|||||||
const turnstileSiteKey = ref<string>('')
|
const turnstileSiteKey = ref<string>('')
|
||||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||||
const backendModeEnabled = ref<boolean>(false)
|
const backendModeEnabled = ref<boolean>(false)
|
||||||
|
const oidcOAuthEnabled = ref<boolean>(false)
|
||||||
|
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||||
const passwordResetEnabled = ref<boolean>(false)
|
const passwordResetEnabled = ref<boolean>(false)
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
@@ -247,6 +268,9 @@ onMounted(async () => {
|
|||||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||||
backendModeEnabled.value = settings.backend_mode_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
|
passwordResetEnabled.value = settings.password_reset_enabled
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', error)
|
console.error('Failed to load public settings:', error)
|
||||||
|
|||||||
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<AuthLayout>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{
|
||||||
|
isProcessing
|
||||||
|
? t('auth.oidc.callbackProcessing', { providerName })
|
||||||
|
: t('auth.oidc.callbackHint')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="needsInvitation" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="invitationCode"
|
||||||
|
type="text"
|
||||||
|
class="input w-full"
|
||||||
|
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleSubmitInvitation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ invitationError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !invitationCode.trim()"
|
||||||
|
@click="handleSubmitInvitation"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isSubmitting
|
||||||
|
? t('auth.oidc.completing')
|
||||||
|
: t('auth.oidc.completeRegistration')
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-400">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<router-link to="/login" class="btn btn-primary">
|
||||||
|
{{ t('auth.oidc.backToLogin') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
|
import {
|
||||||
|
completeOIDCOAuthRegistration,
|
||||||
|
getPublicSettings
|
||||||
|
} from '@/api/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const isProcessing = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const needsInvitation = ref(false)
|
||||||
|
const pendingOAuthToken = ref('')
|
||||||
|
const invitationCode = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const invitationError = ref('')
|
||||||
|
const redirectTo = ref('/dashboard')
|
||||||
|
const providerName = ref('OIDC')
|
||||||
|
|
||||||
|
function parseFragmentParams(): URLSearchParams {
|
||||||
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
|
return new URLSearchParams(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||||
|
if (!path) return '/dashboard'
|
||||||
|
if (!path.startsWith('/')) return '/dashboard'
|
||||||
|
if (path.startsWith('//')) return '/dashboard'
|
||||||
|
if (path.includes('://')) return '/dashboard'
|
||||||
|
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviderName() {
|
||||||
|
try {
|
||||||
|
const settings = await getPublicSettings()
|
||||||
|
const name = settings.oidc_oauth_provider_name?.trim()
|
||||||
|
if (name) {
|
||||||
|
providerName.value = name
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore; fallback remains OIDC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitInvitation() {
|
||||||
|
invitationError.value = ''
|
||||||
|
if (!invitationCode.value.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const tokenData = await completeOIDCOAuthRegistration(
|
||||||
|
pendingOAuthToken.value,
|
||||||
|
invitationCode.value.trim()
|
||||||
|
)
|
||||||
|
if (tokenData.refresh_token) {
|
||||||
|
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||||
|
}
|
||||||
|
if (tokenData.expires_in) {
|
||||||
|
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||||
|
}
|
||||||
|
await authStore.setToken(tokenData.access_token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirectTo.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
|
invitationError.value =
|
||||||
|
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
void loadProviderName()
|
||||||
|
|
||||||
|
const params = parseFragmentParams()
|
||||||
|
const token = params.get('access_token') || ''
|
||||||
|
const refreshToken = params.get('refresh_token') || ''
|
||||||
|
const expiresInStr = params.get('expires_in') || ''
|
||||||
|
const redirect = sanitizeRedirectPath(
|
||||||
|
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||||
|
)
|
||||||
|
const error = params.get('error')
|
||||||
|
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error === 'invitation_required') {
|
||||||
|
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||||
|
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||||
|
if (!pendingOAuthToken.value) {
|
||||||
|
errorMessage.value = t('auth.oidc.invalidPendingToken')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needsInvitation.value = true
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errorMessage.value = errorDesc || error
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
errorMessage.value = t('auth.oidc.callbackMissingToken')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem('refresh_token', refreshToken)
|
||||||
|
}
|
||||||
|
if (expiresInStr) {
|
||||||
|
const expiresIn = parseInt(expiresInStr, 10)
|
||||||
|
if (!isNaN(expiresIn)) {
|
||||||
|
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await authStore.setToken(token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirect)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||||
|
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,8 +11,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LinuxDo Connect OAuth 登录 -->
|
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
|
<LinuxDoOAuthSection
|
||||||
|
v-if="linuxdoOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<OidcOAuthSection
|
||||||
|
v-if="oidcOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:provider-name="oidcOAuthProviderName"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Registration Disabled Message -->
|
<!-- Registration Disabled Message -->
|
||||||
<div
|
<div
|
||||||
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
|
|||||||
const turnstileSiteKey = ref<string>('')
|
const turnstileSiteKey = ref<string>('')
|
||||||
const siteName = ref<string>('Sub2API')
|
const siteName = ref<string>('Sub2API')
|
||||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||||
|
const oidcOAuthEnabled = ref<boolean>(false)
|
||||||
|
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
@@ -376,6 +397,8 @@ onMounted(async () => {
|
|||||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||||
siteName.value = settings.site_name || 'Sub2API'
|
siteName.value = settings.site_name || 'Sub2API'
|
||||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||||
|
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||||
|
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||||
settings.registration_email_suffix_whitelist || []
|
settings.registration_email_suffix_whitelist || []
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user