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
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
74302f60
...
...
@@ -427,6 +427,7 @@ export default {
invitationCodeInvalid
:
'
邀请码无效或已被使用
'
,
invitationCodeValidating
:
'
正在验证邀请码...
'
,
invitationCodeInvalidCannotRegister
:
'
邀请码无效,请检查后重试
'
,
oauthOrContinue
:
'
或使用邮箱密码继续
'
,
linuxdo
:
{
signIn
:
'
使用 Linux.do 登录
'
,
orContinue
:
'
或使用邮箱密码继续
'
,
...
...
@@ -441,6 +442,19 @@ export default {
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oidc
:
{
signIn
:
'
使用 {providerName} 登录
'
,
callbackTitle
:
'
正在完成 {providerName} 登录
'
,
callbackProcessing
:
'
正在验证 {providerName} 登录信息,请稍候...
'
,
callbackHint
:
'
如果页面未自动跳转,请返回登录页重试。
'
,
callbackMissingToken
:
'
登录信息缺失,请返回重试。
'
,
backToLogin
:
'
返回登录
'
,
invitationRequired
:
'
该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。
'
,
invalidPendingToken
:
'
注册凭证已失效,请重新登录。
'
,
completeRegistration
:
'
完成注册
'
,
completing
:
'
正在完成注册...
'
,
completeRegistrationFailed
:
'
注册失败,请检查邀请码后重试。
'
},
oauth
:
{
code
:
'
授权码
'
,
state
:
'
状态
'
,
...
...
@@ -4393,6 +4407,57 @@ export default {
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
},
oidc
:
{
title
:
'
OIDC 登录
'
,
description
:
'
配置标准 OIDC Provider(例如 Keycloak)
'
,
enable
:
'
启用 OIDC 登录
'
,
enableHint
:
'
在登录/注册页面显示 OIDC 登录入口
'
,
providerName
:
'
Provider 名称
'
,
providerNamePlaceholder
:
'
例如 Keycloak
'
,
clientId
:
'
Client ID
'
,
clientIdPlaceholder
:
'
OIDC client id
'
,
clientSecret
:
'
Client Secret
'
,
clientSecretPlaceholder
:
'
********
'
,
clientSecretHint
:
'
用于后端交换 token(请保密)
'
,
clientSecretConfiguredPlaceholder
:
'
********
'
,
clientSecretConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
,
issuerUrl
:
'
Issuer URL
'
,
issuerUrlPlaceholder
:
'
https://id.example.com/realms/main
'
,
discoveryUrl
:
'
Discovery URL
'
,
discoveryUrlPlaceholder
:
'
可选,留空将基于 issuer 自动推导
'
,
authorizeUrl
:
'
Authorize URL
'
,
authorizeUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
tokenUrl
:
'
Token URL
'
,
tokenUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
userinfoUrl
:
'
UserInfo URL
'
,
userinfoUrlPlaceholder
:
'
可选,可通过 discovery 自动获取
'
,
jwksUrl
:
'
JWKS URL
'
,
jwksUrlPlaceholder
:
'
可选;启用严格 ID Token 校验时必填
'
,
scopes
:
'
Scopes
'
,
scopesPlaceholder
:
'
openid email profile
'
,
scopesHint
:
'
必须包含 openid
'
,
redirectUrl
:
'
后端回调地址(Redirect URL)
'
,
redirectUrlPlaceholder
:
'
https://your-domain.com/api/v1/auth/oauth/oidc/callback
'
,
redirectUrlHint
:
'
必须与 OIDC Provider 中配置的回调地址一致
'
,
quickSetCopy
:
'
使用当前站点生成并复制
'
,
redirectUrlSetAndCopied
:
'
已使用当前站点生成回调地址并复制到剪贴板
'
,
frontendRedirectUrl
:
'
前端回调路径
'
,
frontendRedirectUrlPlaceholder
:
'
/auth/oidc/callback
'
,
frontendRedirectUrlHint
:
'
后端回调完成后重定向到此前端路径
'
,
tokenAuthMethod
:
'
Token 鉴权方式
'
,
clockSkewSeconds
:
'
时钟偏移(秒)
'
,
allowedSigningAlgs
:
'
允许的签名算法
'
,
allowedSigningAlgsPlaceholder
:
'
RS256,ES256,PS256
'
,
usePkce
:
'
启用 PKCE
'
,
validateIdToken
:
'
校验 ID Token
'
,
requireEmailVerified
:
'
要求邮箱已验证
'
,
userinfoEmailPath
:
'
UserInfo 邮箱字段路径
'
,
userinfoEmailPathPlaceholder
:
'
例如 data.email
'
,
userinfoIdPath
:
'
UserInfo ID 字段路径
'
,
userinfoIdPathPlaceholder
:
'
例如 data.id
'
,
userinfoUsernamePath
:
'
UserInfo 用户名字段路径
'
,
userinfoUsernamePathPlaceholder
:
'
例如 data.username
'
},
defaults
:
{
title
:
'
用户默认设置
'
,
description
:
'
新用户的默认值
'
,
...
...
frontend/src/router/index.ts
View file @
74302f60
...
...
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
LinuxDo OAuth Callback
'
}
},
{
path
:
'
/auth/oidc/callback
'
,
name
:
'
OIDCOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/OidcCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
OIDC OAuth Callback
'
}
},
{
path
:
'
/forgot-password
'
,
name
:
'
ForgotPassword
'
,
...
...
frontend/src/stores/app.ts
View file @
74302f60
...
...
@@ -332,6 +332,9 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
oidc_oauth_enabled
:
false
,
oidc_oauth_provider_name
:
'
OIDC
'
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
...
...
frontend/src/types/index.ts
View file @
74302f60
...
...
@@ -109,6 +109,9 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
oidc_oauth_enabled
:
boolean
oidc_oauth_provider_name
:
string
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
frontend/src/views/admin/SettingsView.vue
View file @
74302f60
...
...
@@ -1124,7 +1124,327 @@
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
-->
<!--
Generic
OIDC
OAuth
登录
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-5 p-6
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.enable
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.enableHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
form.oidc_connect_enabled
"
class
=
"
space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.providerName
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_provider_name
"
type
=
"
text
"
class
=
"
input
"
:
placeholder
=
"
t('admin.settings.oidc.providerNamePlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientId
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_id
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.clientIdPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clientSecret
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_client_secret
"
type
=
"
password
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
form.oidc_connect_client_secret_configured
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
: t('admin.settings.oidc.clientSecretPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
form
.
oidc_connect_client_secret_configured
?
t
(
'
admin.settings.oidc.clientSecretConfiguredHint
'
)
:
t
(
'
admin.settings.oidc.clientSecretHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.issuerUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_issuer_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.issuerUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.discoveryUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_discovery_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.discoveryUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.authorizeUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_authorize_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.authorizeUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_token_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.tokenUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUrlPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.jwksUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_jwks_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.jwksUrlPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.scopes
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_scopes
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.scopesPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.scopesHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.redirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_redirect_url
"
type
=
"
url
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.redirectUrlPlaceholder')
"
/>
<
div
class
=
"
mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm w-fit
"
@
click
=
"
setAndCopyOIDCRedirectUrl
"
>
{{
t
(
'
admin.settings.oidc.quickSetCopy
'
)
}}
<
/button
>
<
code
v
-
if
=
"
oidcRedirectUrlSuggestion
"
class
=
"
select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300
"
>
{{
oidcRedirectUrlSuggestion
}}
<
/code
>
<
/div
>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.redirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
lg:col-span-2
"
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrl
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_frontend_redirect_url
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.frontendRedirectUrlPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.oidc.frontendRedirectUrlHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.tokenAuthMethod
'
)
}}
<
/label
>
<
select
v
-
model
=
"
form.oidc_connect_token_auth_method
"
class
=
"
input font-mono text-sm
"
>
<
option
value
=
"
client_secret_post
"
>
client_secret_post
<
/option
>
<
option
value
=
"
client_secret_basic
"
>
client_secret_basic
<
/option
>
<
option
value
=
"
none
"
>
none
<
/option
>
<
/select
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.clockSkewSeconds
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.oidc_connect_clock_skew_seconds
"
type
=
"
number
"
min
=
"
0
"
max
=
"
600
"
class
=
"
input
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.allowedSigningAlgs
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_allowed_signing_algs
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.allowedSigningAlgsPlaceholder')
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.usePkce
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_use_pkce
"
/>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.validateIdToken
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_validate_id_token
"
/>
<
/div
>
<
div
class
=
"
flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.oidc.requireEmailVerified
'
)
}}
<
/label
>
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_require_email_verified
"
/>
<
/div
>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoEmailPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_email_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoEmailPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoIdPath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_id_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoIdPathPlaceholder')
"
/>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.oidc.userinfoUsernamePath
'
)
}}
<
/label
>
<
input
v
-
model
=
"
form.oidc_connect_userinfo_username_path
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.oidc.userinfoUsernamePathPlaceholder')
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Registration
,
Turnstile
,
LinuxDo
,
OIDC
-->
<!--
Tab
:
Users
-->
<
div
v
-
show
=
"
activeTab === 'users'
"
class
=
"
space-y-6
"
>
...
...
@@ -2193,6 +2513,7 @@ type SettingsForm = SystemSettings & {
smtp_password
:
string
turnstile_secret_key
:
string
linuxdo_connect_client_secret
:
string
oidc_connect_client_secret
:
string
}
const
form
=
reactive
<
SettingsForm
>
({
...
...
@@ -2240,6 +2561,30 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret
:
''
,
linuxdo_connect_client_secret_configured
:
false
,
linuxdo_connect_redirect_url
:
''
,
// Generic OIDC OAuth 登录
oidc_connect_enabled
:
false
,
oidc_connect_provider_name
:
'
OIDC
'
,
oidc_connect_client_id
:
''
,
oidc_connect_client_secret
:
''
,
oidc_connect_client_secret_configured
:
false
,
oidc_connect_issuer_url
:
''
,
oidc_connect_discovery_url
:
''
,
oidc_connect_authorize_url
:
''
,
oidc_connect_token_url
:
''
,
oidc_connect_userinfo_url
:
''
,
oidc_connect_jwks_url
:
''
,
oidc_connect_scopes
:
'
openid email profile
'
,
oidc_connect_redirect_url
:
''
,
oidc_connect_frontend_redirect_url
:
'
/auth/oidc/callback
'
,
oidc_connect_token_auth_method
:
'
client_secret_post
'
,
oidc_connect_use_pkce
:
false
,
oidc_connect_validate_id_token
:
true
,
oidc_connect_allowed_signing_algs
:
'
RS256,ES256,PS256
'
,
oidc_connect_clock_skew_seconds
:
120
,
oidc_connect_require_email_verified
:
false
,
oidc_connect_userinfo_email_path
:
''
,
oidc_connect_userinfo_id_path
:
''
,
oidc_connect_userinfo_username_path
:
''
,
// Model fallback
enable_model_fallback
:
false
,
fallback_model_anthropic
:
'
claude-3-5-sonnet-20241022
'
,
...
...
@@ -2360,6 +2705,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
}
const
oidcRedirectUrlSuggestion
=
computed
(()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
''
const
origin
=
window
.
location
.
origin
||
`${window.location.protocol
}
//${window.location.host
}
`
return
`${origin
}
/api/v1/auth/oauth/oidc/callback`
}
)
async
function
setAndCopyOIDCRedirectUrl
()
{
const
url
=
oidcRedirectUrlSuggestion
.
value
if
(
!
url
)
return
form
.
oidc_connect_redirect_url
=
url
await
copyToClipboard
(
url
,
t
(
'
admin.settings.oidc.redirectUrlSetAndCopied
'
))
}
// Custom menu item management
function
addMenuItem
()
{
form
.
custom_menu_items
.
push
({
...
...
@@ -2425,6 +2785,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
}
catch
(
error
:
any
)
{
loadFailed
.
value
=
true
appStore
.
showError
(
...
...
@@ -2559,6 +2920,28 @@ async function saveSettings() {
linuxdo_connect_client_id
:
form
.
linuxdo_connect_client_id
,
linuxdo_connect_client_secret
:
form
.
linuxdo_connect_client_secret
||
undefined
,
linuxdo_connect_redirect_url
:
form
.
linuxdo_connect_redirect_url
,
oidc_connect_enabled
:
form
.
oidc_connect_enabled
,
oidc_connect_provider_name
:
form
.
oidc_connect_provider_name
,
oidc_connect_client_id
:
form
.
oidc_connect_client_id
,
oidc_connect_client_secret
:
form
.
oidc_connect_client_secret
||
undefined
,
oidc_connect_issuer_url
:
form
.
oidc_connect_issuer_url
,
oidc_connect_discovery_url
:
form
.
oidc_connect_discovery_url
,
oidc_connect_authorize_url
:
form
.
oidc_connect_authorize_url
,
oidc_connect_token_url
:
form
.
oidc_connect_token_url
,
oidc_connect_userinfo_url
:
form
.
oidc_connect_userinfo_url
,
oidc_connect_jwks_url
:
form
.
oidc_connect_jwks_url
,
oidc_connect_scopes
:
form
.
oidc_connect_scopes
,
oidc_connect_redirect_url
:
form
.
oidc_connect_redirect_url
,
oidc_connect_frontend_redirect_url
:
form
.
oidc_connect_frontend_redirect_url
,
oidc_connect_token_auth_method
:
form
.
oidc_connect_token_auth_method
,
oidc_connect_use_pkce
:
form
.
oidc_connect_use_pkce
,
oidc_connect_validate_id_token
:
form
.
oidc_connect_validate_id_token
,
oidc_connect_allowed_signing_algs
:
form
.
oidc_connect_allowed_signing_algs
,
oidc_connect_clock_skew_seconds
:
form
.
oidc_connect_clock_skew_seconds
,
oidc_connect_require_email_verified
:
form
.
oidc_connect_require_email_verified
,
oidc_connect_userinfo_email_path
:
form
.
oidc_connect_userinfo_email_path
,
oidc_connect_userinfo_id_path
:
form
.
oidc_connect_userinfo_id_path
,
oidc_connect_userinfo_username_path
:
form
.
oidc_connect_userinfo_username_path
,
enable_model_fallback
:
form
.
enable_model_fallback
,
fallback_model_anthropic
:
form
.
fallback_model_anthropic
,
fallback_model_openai
:
form
.
fallback_model_openai
,
...
...
@@ -2583,6 +2966,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited
.
value
=
false
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
form
.
oidc_connect_client_secret
=
''
// Refresh cached settings so sidebar/header update immediately
await
appStore
.
fetchPublicSettings
(
true
)
await
adminSettingsStore
.
fetch
(
true
)
...
...
frontend/src/views/auth/LoginView.vue
View file @
74302f60
...
...
@@ -11,8 +11,26 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled && !backendModeEnabled"
:disabled=
"isLoading"
/>
<div
v-if=
"!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)"
class=
"space-y-4"
>
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
:show-divider=
"false"
/>
<OidcOAuthSection
v-if=
"oidcOAuthEnabled"
:disabled=
"isLoading"
:provider-name=
"oidcOAuthProviderName"
:show-divider=
"false"
/>
<div
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
...
...
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
...
...
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
backendModeEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
...
...
@@ -247,6 +268,9 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
...
...
frontend/src/views/auth/OidcCallbackView.vue
0 → 100644
View file @
74302f60
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.oidc.callbackTitle
'
,
{
providerName
}
)
}}
<
/h2
>
<
p
class
=
"
mt-2 text-sm text-gray-500 dark:text-dark-400
"
>
{{
isProcessing
?
t
(
'
auth.oidc.callbackProcessing
'
,
{
providerName
}
)
:
t
(
'
auth.oidc.callbackHint
'
)
}}
<
/p
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation
"
class
=
"
space-y-4
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
auth.oidc.invitationRequired
'
,
{
providerName
}
)
}}
<
/p
>
<
div
>
<
input
v
-
model
=
"
invitationCode
"
type
=
"
text
"
class
=
"
input w-full
"
:
placeholder
=
"
t('auth.invitationCodePlaceholder')
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleSubmitInvitation
"
/>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
invitationError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
invitationError
}}
<
/p
>
<
/transition
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !invitationCode.trim()
"
@
click
=
"
handleSubmitInvitation
"
>
{{
isSubmitting
?
t
(
'
auth.oidc.completing
'
)
:
t
(
'
auth.oidc.completeRegistration
'
)
}}
<
/button
>
<
/div
>
<
/transition
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex-shrink-0
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
<
router
-
link
to
=
"
/login
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
auth.oidc.backToLogin
'
)
}}
<
/router-link
>
<
/div
>
<
/div
>
<
/div
>
<
/transition
>
<
/div
>
<
/AuthLayout
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
completeOIDCOAuthRegistration
,
getPublicSettings
}
from
'
@/api/auth
'
const
route
=
useRoute
()
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
isProcessing
=
ref
(
true
)
const
errorMessage
=
ref
(
''
)
const
needsInvitation
=
ref
(
false
)
const
pendingOAuthToken
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
invitationError
=
ref
(
''
)
const
redirectTo
=
ref
(
'
/dashboard
'
)
const
providerName
=
ref
(
'
OIDC
'
)
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
return
new
URLSearchParams
(
hash
)
}
function
sanitizeRedirectPath
(
path
:
string
|
null
|
undefined
):
string
{
if
(
!
path
)
return
'
/dashboard
'
if
(
!
path
.
startsWith
(
'
/
'
))
return
'
/dashboard
'
if
(
path
.
startsWith
(
'
//
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
://
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
\n
'
)
||
path
.
includes
(
'
\r
'
))
return
'
/dashboard
'
return
path
}
async
function
loadProviderName
()
{
try
{
const
settings
=
await
getPublicSettings
()
const
name
=
settings
.
oidc_oauth_provider_name
?.
trim
()
if
(
name
)
{
providerName
.
value
=
name
}
}
catch
{
// Ignore; fallback remains OIDC
}
}
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
isSubmitting
.
value
=
true
try
{
const
tokenData
=
await
completeOIDCOAuthRegistration
(
pendingOAuthToken
.
value
,
invitationCode
.
value
.
trim
()
)
if
(
tokenData
.
refresh_token
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
tokenData
.
refresh_token
)
}
if
(
tokenData
.
expires_in
)
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
tokenData
.
expires_in
*
1000
))
}
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.oidc.completeRegistrationFailed
'
)
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
void
loadProviderName
()
const
params
=
parseFragmentParams
()
const
token
=
params
.
get
(
'
access_token
'
)
||
''
const
refreshToken
=
params
.
get
(
'
refresh_token
'
)
||
''
const
expiresInStr
=
params
.
get
(
'
expires_in
'
)
||
''
const
redirect
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
if
(
error
===
'
invitation_required
'
)
{
pendingOAuthToken
.
value
=
params
.
get
(
'
pending_oauth_token
'
)
||
''
redirectTo
.
value
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
))
if
(
!
pendingOAuthToken
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.invalidPendingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
if
(
!
token
)
{
errorMessage
.
value
=
t
(
'
auth.oidc.callbackMissingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
if
(
refreshToken
)
{
localStorage
.
setItem
(
'
refresh_token
'
,
refreshToken
)
}
if
(
expiresInStr
)
{
const
expiresIn
=
parseInt
(
expiresInStr
,
10
)
if
(
!
isNaN
(
expiresIn
))
{
localStorage
.
setItem
(
'
token_expires_at
'
,
String
(
Date
.
now
()
+
expiresIn
*
1000
))
}
}
await
authStore
.
setToken
(
token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
}
)
<
/script
>
<
style
scoped
>
.
fade
-
enter
-
active
,
.
fade
-
leave
-
active
{
transition
:
all
0.3
s
ease
;
}
.
fade
-
enter
-
from
,
.
fade
-
leave
-
to
{
opacity
:
0
;
transform
:
translateY
(
-
8
px
);
}
<
/style
>
frontend/src/views/auth/RegisterView.vue
View file @
74302f60
...
...
@@ -11,8 +11,26 @@
<
/p
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
登录
-->
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
/>
<
div
v
-
if
=
"
linuxdoOAuthEnabled || oidcOAuthEnabled
"
class
=
"
space-y-4
"
>
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
show
-
divider
=
"
false
"
/>
<
OidcOAuthSection
v
-
if
=
"
oidcOAuthEnabled
"
:
disabled
=
"
isLoading
"
:
provider
-
name
=
"
oidcOAuthProviderName
"
:
show
-
divider
=
"
false
"
/>
<
div
class
=
"
flex items-center gap-3
"
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
span
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<!--
Registration
Disabled
Message
-->
<
div
...
...
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
OidcOAuthSection
from
'
@/components/auth/OidcOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
oidcOAuthProviderName
=
ref
<
string
>
(
'
OIDC
'
)
const
registrationEmailSuffixWhitelist
=
ref
<
string
[]
>
([])
// Turnstile
...
...
@@ -376,6 +397,8 @@ onMounted(async () => {
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
oidcOAuthEnabled
.
value
=
settings
.
oidc_oauth_enabled
oidcOAuthProviderName
.
value
=
settings
.
oidc_oauth_provider_name
||
'
OIDC
'
registrationEmailSuffixWhitelist
.
value
=
normalizeRegistrationEmailSuffixWhitelist
(
settings
.
registration_email_suffix_whitelist
||
[]
)
...
...
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