Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
74302f60
Unverified
Commit
74302f60
authored
Apr 09, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 09, 2026
Browse files
Merge pull request #1010 from Glorhop/pr/oidc-login
feat(auth): support OIDC login and prefer IdP real email on sign-in
parents
1b79f6a7
311f0674
Changes
28
Hide whitespace changes
Inline
Side-by-side
backend/internal/config/config.go
View file @
74302f60
...
@@ -65,6 +65,7 @@ type Config struct {
...
@@ -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 {
...
@@ -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
{
// 是否启用自动刷新
// 是否启用自动刷新
...
@@ -972,6 +1001,23 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
...
@@ -972,6 +1001,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
)
...
@@ -1142,6 +1188,30 @@ func setDefaults() {
...
@@ -1142,6 +1188,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
)
...
@@ -1578,6 +1648,87 @@ func (c *Config) Validate() error {
...
@@ -1578,6 +1648,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"
)
...
@@ -2196,6 +2347,15 @@ func ValidateFrontendRedirectURL(raw string) error {
...
@@ -2196,6 +2347,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"
)
...
...
backend/internal/config/config_test.go
View file @
74302f60
...
@@ -351,6 +351,60 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
...
@@ -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
)
...
...
backend/internal/handler/admin/setting_handler.go
View file @
74302f60
...
@@ -35,6 +35,15 @@ func generateMenuItemID() (string, error) {
...
@@ -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) {
...
@@ -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 {
...
@@ -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) {
...
@@ -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) {
...
@@ -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) {
...
@@ -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,
...
@@ -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"
)
}
}
...
...
backend/internal/handler/auth_oidc_oauth.go
0 → 100644
View file @
74302f60
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
:=
oidcSelectLoginEmail
(
userInfoClaims
.
Email
,
idClaims
.
Email
,
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
oidcSelectLoginEmail
(
userInfoEmail
,
idTokenEmail
,
identityKey
string
)
string
{
email
:=
strings
.
TrimSpace
(
firstNonEmpty
(
userInfoEmail
,
idTokenEmail
))
if
email
!=
""
{
return
email
}
return
oidcSyntheticEmailFromIdentityKey
(
identityKey
)
}
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
,
})
}
backend/internal/handler/auth_oidc_oauth_test.go
0 → 100644
View file @
74302f60
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
TestOIDCSelectLoginEmailPrefersRealEmail
(
t
*
testing
.
T
)
{
identityKey
:=
oidcIdentityKey
(
"https://issuer.example.com"
,
"subject-a"
)
email
:=
oidcSelectLoginEmail
(
"user@example.com"
,
"idtoken@example.com"
,
identityKey
)
require
.
Equal
(
t
,
"user@example.com"
,
email
)
email
=
oidcSelectLoginEmail
(
""
,
"idtoken@example.com"
,
identityKey
)
require
.
Equal
(
t
,
"idtoken@example.com"
,
email
)
email
=
oidcSelectLoginEmail
(
""
,
""
,
identityKey
)
require
.
Contains
(
t
,
email
,
"@oidc-connect.invalid"
)
require
.
Equal
(
t
,
oidcSyntheticEmailFromIdentityKey
(
identityKey
),
email
)
}
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
,
}
}
backend/internal/handler/dto/settings.go
View file @
74302f60
...
@@ -51,6 +51,29 @@ type SystemSettings struct {
...
@@ -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 {
...
@@ -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"`
}
}
...
...
backend/internal/handler/setting_handler.go
View file @
74302f60
...
@@ -54,6 +54,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
...
@@ -54,6 +54,8 @@ 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
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
Version
:
h
.
version
,
Version
:
h
.
version
,
})
})
...
...
backend/internal/server/api_contract_test.go
View file @
74302f60
...
@@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) {
...
@@ -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) {
...
@@ -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",
...
...
backend/internal/server/routes/auth.go
View file @
74302f60
...
@@ -70,6 +70,14 @@ func RegisterAuthRoutes(
...
@@ -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
,
)
}
}
// 公开设置(无需认证)
// 公开设置(无需认证)
...
...
backend/internal/service/auth_service.go
View file @
74302f60
...
@@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) {
...
@@ -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
...
...
backend/internal/service/domain_constants.go
View file @
74302f60
...
@@ -71,6 +71,9 @@ const (
...
@@ -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 (
...
@@ -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)
...
...
backend/internal/service/setting_service.go
View file @
74302f60
...
@@ -16,6 +16,7 @@ import (
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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 {
...
@@ -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
...
@@ -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
...
@@ -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
)
...
...
backend/internal/service/setting_service_oidc_config_test.go
0 → 100644
View file @
74302f60
//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
)
}
backend/internal/service/settings_view.go
View file @
74302f60
...
@@ -31,6 +31,31 @@ type SystemSettings struct {
...
@@ -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 {
...
@@ -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 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
...
...
deploy/config.example.yaml
View file @
74302f60
...
@@ -826,6 +826,46 @@ linuxdo_connect:
...
@@ -826,6 +826,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
# 默认设置
# 默认设置
...
...
frontend/src/api/admin/settings.ts
View file @
74302f60
...
@@ -62,6 +62,30 @@ export interface SystemSettings {
...
@@ -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 {
...
@@ -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
...
...
frontend/src/api/auth.ts
View file @
74302f60
...
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
...
@@ -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 = {
...
@@ -380,7 +402,8 @@ export const authAPI = {
resetPassword
,
resetPassword
,
refreshToken
,
refreshToken
,
revokeAllSessions
,
revokeAllSessions
,
completeLinuxDoOAuthRegistration
completeLinuxDoOAuthRegistration
,
completeOIDCOAuthRegistration
}
}
export
default
authAPI
export
default
authAPI
frontend/src/components/auth/LinuxDoOAuthSection.vue
View file @
74302f60
...
@@ -29,10 +29,10 @@
...
@@ -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.o
rContinue
'
)
}}
{{
t
(
'
auth.
oauthO
rContinue
'
)
}}
</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 @@
...
@@ -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 {
...
@@ -58,4 +61,3 @@ function startLogin(): void {
window
.
location
.
href
=
startURL
window
.
location
.
href
=
startURL
}
}
</
script
>
</
script
>
frontend/src/components/auth/OidcOAuthSection.vue
0 → 100644
View file @
74302f60
<
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
>
frontend/src/i18n/locales/en.ts
View file @
74302f60
...
@@ -428,6 +428,7 @@ export default {
...
@@ -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 {
...
@@ -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 {
...
@@ -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
'
,
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment