Clarify Feishu app setup and OpenVPN OIDC source

This commit is contained in:
opsx committed 2026-10-22 10:12:06 +08:00
1 parent 63cdda4ae1
commit 5ef1cb48cf
10 files changed
+154 -34

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。 在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。
1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建并发布企业自建应用,启用网页登录,限定可用人员。**内置方案**:在「系统设置 → 飞书身份」保存飞书 App ID、轮换后的 App Secret 与允许的 `tenant_key`,把页面所示 `https://<公网域名>/id/feishu/callback` 登记到飞书应用;启用后系统通过 `https://<公网域名>/id` 提供标准 OIDC 发现文档、JWKS 和令牌签发。管理员在专线向导点击「使用内置飞书身份服务」,VPN Client Secret 由服务端自动配置,不会回显。也可以继续使用外部标准 OIDC issuer:此时飞书 App 凭据留在外部代理,向导填写代理生成的 VPN Client ID/Secret。 1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建并发布企业自建应用,启用网页登录,限定可用人员。**内置方案**:通过可信 HTTPS 管理入口打开「设置 → 飞书身份」,只在此处保存飞书 App ID、轮换后的 App Secret 与允许的 `tenant_key`,把页面所示 `https://<公网域名>/id/feishu/callback` 登记到飞书应用。启用后系统通过 `https://<公网域名>/id` 提供标准 OIDC 发现文档、JWKS 和令牌签发。在专线新建或编辑页选择「内置飞书身份」,发行者与 VPN Client ID 自动引用,VPN Client Secret 由服务端自动配置,不会回显。也可以继续使用外部标准 OIDC issuer:此时飞书 App 凭据留在外部代理,专线填写代理生成的 VPN Client ID/Secret。
2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。内置方案在向导中选择内置 issuer,公网认证入口可复用管理域名(已有证书);独立服务端口默认 `19090`。外部 OIDC 方案仍需手动填写发行者、VPN 客户端凭据与独立 HTTPS 入口。每条专线的认证入口和本机端口必须唯一。 2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。内置方案在向导中选择内置 issuer,公网认证入口可复用管理域名(已有证书);独立服务端口默认 `19090`。外部 OIDC 方案仍需手动填写发行者、VPN 客户端凭据与独立 HTTPS 入口。每条专线的认证入口和本机端口必须唯一。
3. 内置方案在设置页同步身份公网路由,Nginx 只转发固定 `/id/` 端点;启动**唯一一条使用该管理域名**的测试 OpenVPN OIDC 专线后再次同步,Nginx 才将 `/oauth2/` 转给本机认证组件。该 `https://<公网域名>/oauth2/callback` 属于 OpenVPN 组件,**不是飞书应用的回调**。外部发行者方案仍需在外部 OIDC 客户端登记此 VPN 回调。公网只开放 HTTPS 与 VPN 协议端口,认证端口 `127.0.0.1:19090+` 和 OpenVPN 管理 socket 不对公网开放。 3. 内置方案在设置页同步身份公网路由,Nginx 只转发固定 `/id/` 端点;启动**唯一一条使用该管理域名**的测试 OpenVPN OIDC 专线后再次同步,Nginx 才将 `/oauth2/` 转给本机认证组件。该 `https://<公网域名>/oauth2/callback` 属于 OpenVPN 组件,**不是飞书应用的回调**。外部发行者方案仍需在外部 OIDC 客户端登记此 VPN 回调。公网只开放 HTTPS 与 VPN 协议端口,认证端口 `127.0.0.1:19090+` 和 OpenVPN 管理 socket 不对公网开放。
4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。 4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。
+15 -2
View File
@@ -101,10 +101,23 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
if err != nil { if err != nil {
return err return err
} }
if service.Str(cfg, "oidc_client_secret") == "" { oldCfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
builtin := service.Str(cfg, "oidc_provider") == "builtin"
if !builtin && service.Str(oldCfg, "oidc_provider") != "builtin" && service.Str(cfg, "oidc_client_secret") == "" {
cfg["oidc_client_secret"] = old["oidc_client_secret"] cfg["oidc_client_secret"] = old["oidc_client_secret"]
} }
if secret, ok := identityprovider.ClientForIssuer(service.Str(cfg, "oidc_issuer"), service.Str(cfg, "oidc_client_id")); ok { if builtin {
issuer, clientID, ready := identityprovider.PublicClient()
if !ready || issuer != service.Str(cfg, "oidc_issuer") || clientID != service.Str(cfg, "oidc_client_id") || strings.TrimSuffix(issuer, "/id") != strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/") {
return fmt.Errorf("内置飞书身份服务未启用或与当前专线配置不匹配;请在系统设置中启用后重新选择")
}
secret, ok := identityprovider.ClientForIssuer(issuer, clientID)
if !ok {
return fmt.Errorf("无法获取内置 VPN 客户端凭据")
}
cfg["oidc_client_secret"] = secret cfg["oidc_client_secret"] = secret
} }
if service.Str(cfg, "oidc_client_secret") == "" { if service.Str(cfg, "oidc_client_secret") == "" {
+6 -1
View File
@@ -115,6 +115,11 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
return nil, fmt.Errorf("auth_mode 必须为 certificate、password、certificate+password 或 oidc") return nil, fmt.Errorf("auth_mode 必须为 certificate、password、certificate+password 或 oidc")
} }
if service.Str(out, "auth_mode") == "oidc" { if service.Str(out, "auth_mode") == "oidc" {
switch service.Str(out, "oidc_provider") {
case "", "external", "builtin":
default:
return nil, fmt.Errorf("oidc_provider 必须为内置飞书或外部 OIDC")
}
for _, key := range []string{"oidc_issuer", "oidc_base_url"} { for _, key := range []string{"oidc_issuer", "oidc_base_url"} {
u, err := url.Parse(service.Str(out, key)) u, err := url.Parse(service.Str(out, key))
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (key == "oidc_base_url" && u.Path != "" && u.Path != "/") { if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (key == "oidc_base_url" && u.Path != "" && u.Path != "/") {
@@ -133,7 +138,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
} }
out["mfa_mode"] = "off" // 飞书 MFA 由身份提供方负责 out["mfa_mode"] = "off" // 飞书 MFA 由身份提供方负责
} else { } else {
for _, key := range []string{"oidc_issuer", "oidc_base_url", "oidc_client_id", "oidc_client_secret", "oidc_http_port", "oidc_http_secret", "oidc_management_password"} { for _, key := range []string{"oidc_provider", "oidc_issuer", "oidc_base_url", "oidc_client_id", "oidc_client_secret", "oidc_http_port", "oidc_http_secret", "oidc_management_password"} {
delete(out, key) delete(out, key)
} }
} }
@@ -105,6 +105,31 @@ func TestOIDCRejectsUnsafeURLs(t *testing.T) {
} }
} }
func TestSwitchingAwayFromBuiltInCannotReuseItsClientSecret(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
secret, err := store.EncryptSecret(`{"oidc_client_secret":"old-built-in-secret"}`)
if err != nil {
t.Fatal(err)
}
tunnel := &model.Tunnel{ID: 43, Protocol: "openvpn", Config: `{"oidc_provider":"builtin"}`, Secrets: secret}
cfg := map[string]interface{}{
"oidc_provider": "external", "oidc_issuer": "https://external.example.com/id",
"oidc_base_url": "https://login.example.com", "oidc_client_id": "external-client",
}
if err := prepareOIDC(tunnel, cfg); err == nil {
t.Fatal("external provider reused a built-in client secret")
}
cfg["oidc_provider"] = "builtin"
cfg["oidc_client_secret"] = "forged"
if err := prepareOIDC(tunnel, cfg); err == nil {
t.Fatal("built-in provider accepted an unregistered issuer and client")
}
}
func TestOIDCRequiresOpenVPN262(t *testing.T) { func TestOIDCRequiresOpenVPN262(t *testing.T) {
for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} { for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} {
if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil { if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil {
@@ -40,3 +40,9 @@
| 网关修改影响管理入口 | 先预览、备份现有站点、`nginx -t` 后 reload;失败恢复旧站点 | | 网关修改影响管理入口 | 先预览、备份现有站点、`nginx -t` 后 reload;失败恢复旧站点 |
验收:禁用与错误配置拒绝授权;模拟飞书服务测试完整码流、重放、错误租户、错误回调/nonce/PKCE、JWT 验签;真实飞书应用授权和 OpenVPN WebAuth 需单独联调。发布顺序:完成本地测试 → 部署但默认禁用 → 管理员轮换并保存飞书凭据 → 预览并启用网关 → 测试专线正/负向登录 → 再决定生产切换。恢复:禁用 issuer、恢复 Nginx 旧站点和旧二进制;不删除现有专线或密钥。 验收:禁用与错误配置拒绝授权;模拟飞书服务测试完整码流、重放、错误租户、错误回调/nonce/PKCE、JWT 验签;真实飞书应用授权和 OpenVPN WebAuth 需单独联调。发布顺序:完成本地测试 → 部署但默认禁用 → 管理员轮换并保存飞书凭据 → 预览并启用网关 → 测试专线正/负向登录 → 再决定生产切换。恢复:禁用 issuer、恢复 Nginx 旧站点和旧二进制;不删除现有专线或密钥。
## 5. 专线与身份配置分工(编辑页迭代)
现有专线编辑页仍把外部 OIDC 的 issuer 与 VPN Client Secret 输入框作为主流程,容易让管理员误把飞书 App ID/Secret 填入专线。管理员仅在「设置 → 飞书身份」维护全局飞书 App ID、App Secret 和 tenant_key;专线的内置模式自动引用当前 issuer、独立 VPN Client ID 和公网入口。编辑既有外部 OIDC 专线时保持原始设置,只有明确切换来源才更改字段。外部 OIDC 仍允许手工配置,不把飞书上游凭据放入专线。
验收:专线编辑和新建都能直达身份设置;内置模式不展示飞书密钥或可编辑的 VPN 密钥,提示飞书回调与 VPN 回调的区别;外部模式保留原先的输入能力;服务端对内置标记校验已启用的发行者、Client ID 和当前域名,不因提交旧密钥而回退至外部模式。该迭代不自动切换、启用或重启已有专线;上线前构建前端并跑相关 Go 测试,上线后检查管理入口与禁用的身份入口,失败恢复上一版二进制。
+69 -18
View File
@@ -4,38 +4,52 @@
<el-alert v-if="readiness?.https_management_ready && !readiness?.authentication_entry_ready" type="warning" :closable="false" :title="$t('oidcGuide.authEntryPending', { domain: readiness.management_domain })" class="guide-alert" /> <el-alert v-if="readiness?.https_management_ready && !readiness?.authentication_entry_ready" type="warning" :closable="false" :title="$t('oidcGuide.authEntryPending', { domain: readiness.management_domain })" class="guide-alert" />
<el-steps :active="guideStep" finish-status="success" align-center class="guide-steps"> <el-steps :active="guideStep" finish-status="success" align-center class="guide-steps">
<el-step :title="$t('oidcGuide.app')" :description="$t('oidcGuide.appShort')" /> <el-step :title="$t('oidcGuide.app')" :description="$t('oidcGuide.appShort')" />
<el-step :title="$t('oidcGuide.identity')" :description="$t('oidcGuide.identityShort')" /> <el-step :title="$t('oidcGuide.identity')" :description="$t(source === 'builtin' ? 'oidcGuide.builtinIdentityShort' : 'oidcGuide.identityShort')" />
<el-step :title="$t('oidcGuide.callback')" :description="$t('oidcGuide.callbackShort')" /> <el-step :title="$t('oidcGuide.callback')" :description="$t('oidcGuide.callbackShort')" />
<el-step :title="$t('oidcGuide.test')" :description="$t('oidcGuide.testShort')" /> <el-step :title="$t('oidcGuide.test')" :description="$t('oidcGuide.testShort')" />
</el-steps> </el-steps>
<el-alert type="info" :closable="false" :title="$t('oidcGuide.appTip')" class="guide-alert"> <el-alert type="info" :closable="false" :title="$t(source === 'builtin' ? 'oidcGuide.builtinAppTip' : 'oidcGuide.appTip')" class="guide-alert">
<template #default> <template #default>
<a href="https://open.feishu.cn/app" target="_blank" rel="noopener noreferrer">{{ $t('oidcGuide.openConsole') }} ↗</a> <a href="https://open.feishu.cn/app" target="_blank" rel="noopener noreferrer">{{ $t('oidcGuide.openConsole') }} ↗</a>
<ol><li>{{ $t('oidcGuide.appInstruction1') }}</li><li>{{ $t('oidcGuide.appInstruction2') }}</li><li>{{ $t('oidcGuide.appInstruction3') }}</li></ol> <ol><li>{{ $t('oidcGuide.appInstruction1') }}</li><li>{{ $t(source === 'builtin' ? 'oidcGuide.builtinAppInstruction2' : 'oidcGuide.appInstruction2') }}</li><li>{{ $t('oidcGuide.appInstruction3') }}</li></ol>
<p>{{ $t('oidcGuide.scopeTip') }}</p> <p>{{ $t('oidcGuide.scopeTip') }}</p>
</template> </template>
</el-alert> </el-alert>
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" /> <el-form-item :label="$t('oidcGuide.source')">
<el-button v-if="readiness?.built_in_enabled" type="primary" plain class="guide-alert" @click="useBuiltIn">{{ $t('oidcGuide.useBuiltIn') }}</el-button> <el-radio-group v-model="source" @change="changeSource">
<el-alert v-if="builtInSelected && !readiness?.built_in_gateway_ready" type="warning" :closable="false" :title="$t('oidcGuide.builtInGatewayPending')" class="guide-alert" /> <el-radio-button value="builtin">{{ $t('oidcGuide.builtinSource') }}</el-radio-button>
<el-form-item :label="$t('oidcGuide.issuer')" required> <el-radio-button value="external">{{ $t('oidcGuide.externalSource') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="source === 'builtin'">
<el-alert v-if="!readiness?.built_in_enabled" type="warning" :closable="false" :title="$t('oidcGuide.configureBuiltIn')" class="guide-alert" />
<el-alert v-else-if="!readiness.built_in_gateway_ready" type="warning" :closable="false" :title="$t('oidcGuide.builtInGatewayPending')" class="guide-alert" />
<el-button type="primary" plain class="guide-alert" @click="openIdentitySettings">{{ $t('oidcGuide.manageFeishu') }}</el-button>
<div class="field-tip">{{ $t('oidcGuide.builtinSummary') }}</div>
<el-descriptions v-if="readiness?.built_in_enabled" :column="1" border class="guide-alert">
<el-descriptions-item :label="$t('oidcGuide.issuer')">{{ readiness.built_in_issuer }}</el-descriptions-item>
<el-descriptions-item :label="$t('oidcGuide.clientId')">{{ readiness.built_in_client_id }}</el-descriptions-item>
</el-descriptions>
<el-alert type="info" :closable="false" :title="$t('oidcGuide.builtInSecret')" class="guide-alert" />
</template>
<el-alert v-else type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
<el-form-item v-if="source === 'external'" :label="$t('oidcGuide.issuer')" required>
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" /> <el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div> <div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('oidcGuide.clientId')" required> <el-form-item v-if="source === 'external'" :label="$t('oidcGuide.clientId')" required>
<el-input v-model.trim="config.oidc_client_id" /> <el-input v-model.trim="config.oidc_client_id" />
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div> <div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
</el-form-item> </el-form-item>
<el-form-item v-if="!builtInSelected" :label="$t('oidcGuide.clientSecret')" :required="!editing"> <el-form-item v-if="source === 'external'" :label="$t('oidcGuide.clientSecret')" :required="!editing">
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" /> <el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
<div class="field-tip">{{ $t('oidcGuide.secretTip') }}</div> <div class="field-tip">{{ $t('oidcGuide.secretTip') }}</div>
</el-form-item> </el-form-item>
<el-alert v-else type="info" :closable="false" :title="$t('oidcGuide.builtInSecret')" class="guide-alert" />
<el-form-item :label="$t('oidcGuide.baseUrl')" required> <el-form-item :label="$t('oidcGuide.baseUrl')" required>
<el-input v-model.trim="config.oidc_base_url" :placeholder="suggestedBaseUrl || 'https://vpn-login.example.com'" /> <el-input v-model.trim="config.oidc_base_url" :disabled="source === 'builtin'" :placeholder="suggestedBaseUrl || 'https://vpn-login.example.com'" />
<el-button v-if="suggestedBaseUrl && config.oidc_base_url !== suggestedBaseUrl" class="reuse-domain" link type="primary" @click="config.oidc_base_url = suggestedBaseUrl">{{ $t('oidcGuide.reuseDomain') }}</el-button> <el-button v-if="source === 'external' && suggestedBaseUrl && config.oidc_base_url !== suggestedBaseUrl" class="reuse-domain" link type="primary" @click="config.oidc_base_url = suggestedBaseUrl">{{ $t('oidcGuide.reuseDomain') }}</el-button>
<div v-if="suggestedBaseUrl && config.oidc_base_url === suggestedBaseUrl" class="field-tip">{{ $t('oidcGuide.linkedDomain') }}</div> <div v-if="suggestedBaseUrl && config.oidc_base_url === suggestedBaseUrl" class="field-tip">{{ $t('oidcGuide.linkedDomain') }}</div>
<div class="field-tip">{{ $t('oidcGuide.baseTip') }}</div> <div class="field-tip">{{ $t(source === 'builtin' ? 'oidcGuide.builtinBaseTip' : 'oidcGuide.baseTip') }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('oidcGuide.listenPort')"> <el-form-item :label="$t('oidcGuide.listenPort')">
<div class="port-controls"> <div class="port-controls">
@@ -45,10 +59,11 @@
</div> </div>
<div class="field-tip">{{ $t('oidcGuide.portTip') }}</div> <div class="field-tip">{{ $t('oidcGuide.portTip') }}</div>
</el-form-item> </el-form-item>
<el-alert type="warning" :closable="false" class="guide-alert" :title="$t('oidcGuide.callbackTitle')"> <el-alert type="warning" :closable="false" class="guide-alert" :title="$t(source === 'builtin' ? 'oidcGuide.builtinCallbackTitle' : 'oidcGuide.callbackTitle')">
<template #default> <template #default>
<code>{{ callbackUrl }}</code> <code>{{ callbackUrl }}</code>
<p>{{ $t('oidcGuide.callbackTip', { port: config.oidc_http_port || 19090 }) }}</p> <p>{{ $t(source === 'builtin' ? 'oidcGuide.builtinCallbackTip' : 'oidcGuide.callbackTip', { port: config.oidc_http_port || 19090 }) }}</p>
<p v-if="source === 'builtin'">{{ $t('oidcGuide.feishuCallbackLabel') }}:<code>{{ feishuCallbackUrl }}</code></p>
</template> </template>
</el-alert> </el-alert>
<el-alert type="info" :closable="false" :title="$t('oidcGuide.testTip')" /> <el-alert type="info" :closable="false" :title="$t('oidcGuide.testTip')" />
@@ -59,24 +74,59 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import api from '../api' import api from '../api'
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } }) const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const portLoading = ref(false) const portLoading = ref(false)
const readiness = ref(null) const readiness = ref(null)
const builtInSelected = computed(() => readiness.value?.built_in_enabled && props.config.oidc_issuer === readiness.value.built_in_issuer && props.config.oidc_client_id === readiness.value.built_in_client_id) const source = ref(props.config.oidc_provider === 'builtin' || !props.config.oidc_issuer ? 'builtin' : 'external')
const externalDraft = ref(null)
function openIdentitySettings() {
const domain = readiness.value?.management_domain
if (window.location.protocol !== 'https:' && readiness.value?.https_management_ready && /^[a-z0-9.-]+$/i.test(domain || '')) {
window.location.assign(`https://${domain}/settings?tab=identity`)
} else router.push({ path: '/settings', query: { tab: 'identity' } })
}
function useBuiltIn() { function useBuiltIn() {
if (!readiness.value?.built_in_enabled) return
props.config.oidc_issuer = readiness.value.built_in_issuer props.config.oidc_issuer = readiness.value.built_in_issuer
props.config.oidc_client_id = readiness.value.built_in_client_id props.config.oidc_client_id = readiness.value.built_in_client_id
props.config.oidc_base_url = readiness.value.built_in_issuer.replace(/\/id$/, '')
props.config.oidc_client_secret = '' props.config.oidc_client_secret = ''
props.config.oidc_provider = 'builtin' props.config.oidc_provider = 'builtin'
} }
function changeSource(value) {
if (value === 'builtin') {
externalDraft.value = {
issuer: props.config.oidc_issuer,
clientId: props.config.oidc_client_id,
clientSecret: props.config.oidc_client_secret,
baseUrl: props.config.oidc_base_url,
}
props.config.oidc_provider = 'builtin'
props.config.oidc_client_secret = ''
props.config.oidc_issuer = ''
props.config.oidc_client_id = ''
props.config.oidc_base_url = ''
useBuiltIn()
}
else {
props.config.oidc_provider = 'external'
props.config.oidc_issuer = externalDraft.value?.issuer || ''
props.config.oidc_client_id = externalDraft.value?.clientId || ''
props.config.oidc_client_secret = externalDraft.value?.clientSecret || ''
props.config.oidc_base_url = externalDraft.value?.baseUrl || ''
}
}
async function loadReadiness() { async function loadReadiness() {
try { try {
const { data } = await api.get('/tunnels/openvpn/oidc-readiness') const { data } = await api.get('/tunnels/openvpn/oidc-readiness')
readiness.value = data readiness.value = data
if (!props.editing && !props.config.oidc_base_url && data.https_management_ready && /^[a-z0-9.-]+$/i.test(data.management_domain || '')) { if (source.value === 'builtin' && data.built_in_enabled && !props.editing) useBuiltIn()
else if (source.value === 'external' && !props.editing && !props.config.oidc_base_url && data.https_management_ready && /^[a-z0-9.-]+$/i.test(data.management_domain || '')) {
props.config.oidc_base_url = `https://${data.management_domain}` props.config.oidc_base_url = `https://${data.management_domain}`
} }
} catch { /* Old servers do not provide a readiness endpoint. */ } } catch { /* Old servers do not provide a readiness endpoint. */ }
@@ -112,8 +162,9 @@ onMounted(() => {
if (!props.editing) requestPort(null, 1) if (!props.editing) requestPort(null, 1)
}) })
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`) const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
const feishuCallbackUrl = computed(() => readiness.value?.built_in_issuer ? `${readiness.value.built_in_issuer}/feishu/callback` : '—')
const guideStep = computed(() => !props.config.oidc_client_id ? 0 const guideStep = computed(() => !props.config.oidc_client_id ? 0
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing && !builtInSelected.value) ? 1 : !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing && source.value === 'external') ? 1
: !props.config.oidc_base_url ? 2 : 3) : !props.config.oidc_base_url ? 2 : 3)
</script> </script>
+10 -4
View File
@@ -229,23 +229,27 @@
"title": "Feishu / OIDC browser authentication", "title": "Feishu / OIDC browser authentication",
"externalService": "Requires openvpn-auth-oauth2", "externalService": "Requires openvpn-auth-oauth2",
"componentReady": "Authentication component installed v{version}", "componentReady": "Authentication component installed v{version}",
"authEntryPending": "HTTPS is ready at {domain}; the auth component's public callback route is not yet connected and an OIDC issuer is still needed.", "authEntryPending": "HTTPS is ready at {domain}; sync the authentication route in Settings → Feishu Identity after starting the tunnel.",
"app": "Create app", "app": "Create app",
"appShort": "Feishu developer console", "appShort": "Feishu developer console",
"identity": "Enter identity settings", "identity": "Enter identity settings",
"identityShort": "OIDC and credentials", "identityShort": "OIDC and credentials",
"builtinIdentityShort": "Enter Feishu credentials in Settings",
"callback": "Set public callback", "callback": "Set public callback",
"callbackShort": "HTTPS reverse proxy", "callbackShort": "HTTPS reverse proxy",
"test": "Test client", "test": "Test client",
"testShort": "Browser sign-in", "testShort": "Browser sign-in",
"appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.", "appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.",
"builtinAppTip": "Manage Feishu App ID and App Secret in Settings → Feishu Identity, not in each tunnel.",
"openConsole": "Open Feishu app console", "openConsole": "Open Feishu app console",
"appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.", "appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.",
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.", "appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
"builtinAppInstruction2": "Register the Feishu app callback shown below in the app's redirect settings, not the VPN client callback.",
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.", "appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.", "scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
"providerTip": "Use the built-in Feishu issuer configured in Settings, or provide an external issuer with OIDC discovery and ID tokens. A Feishu app URL alone is not an issuer.", "providerTip": "Use the built-in Feishu issuer configured in Settings, or provide an external issuer with OIDC discovery and ID tokens. A Feishu app URL alone is not an issuer.",
"useBuiltIn": "Use built-in Feishu issuer", "builtInSecret": "The VPN client secret is supplied internally by the server and never returned on this page.", "builtInGatewayPending": "The public Nginx route for the built-in issuer is pending; sync it in Settings before testing.", "source": "Identity source", "builtinSource": "Built-in Feishu", "externalSource": "External OIDC", "configureBuiltIn": "The built-in Feishu issuer is not enabled. Save app credentials and enable it in Settings first.", "manageFeishu": "Open Settings → Feishu Identity", "builtinSummary": "The Feishu App ID, App Secret, and tenant are managed in Settings; this tunnel references the issuer and its own public authentication entry.",
"useBuiltIn": "Use built-in Feishu issuer", "builtInSecret": "The server supplies the VPN Client Secret. Do not enter the Feishu App Secret here.", "builtInGatewayPending": "The public Nginx route for the built-in issuer is pending; sync it in Settings before testing.",
"issuer": "OIDC issuer URL", "issuer": "OIDC issuer URL",
"issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.", "issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.",
"clientId": "OIDC Client ID", "clientId": "OIDC Client ID",
@@ -256,15 +260,17 @@
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.", "secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
"baseUrl": "Public sign-in URL", "baseUrl": "Public sign-in URL",
"baseTip": "Automatic filling reuses the domain and certificate only. The management site does not yet proxy OAuth2 paths to the auth component. Sign-in needs that Nginx route. Recheck saved tunnel callbacks if the domain changes.", "baseTip": "Automatic filling reuses the domain and certificate only. The management site does not yet proxy OAuth2 paths to the auth component. Sign-in needs that Nginx route. Recheck saved tunnel callbacks if the domain changes.",
"builtinBaseTip": "Uses the system HTTPS domain automatically. The auth component listens locally; sync public routes in Settings after starting the tunnel.",
"reuseDomain": "Use current public domain", "linkedDomain": "Using the HTTPS domain from system settings; you can edit it.", "reuseDomain": "Use current public domain", "linkedDomain": "Using the HTTPS domain from system settings; you can edit it.",
"listenPort": "Local auth service port", "listenPort": "Local auth service port",
"portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.", "portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.",
"portLookupFailed": "Could not find an available port. Retry or enter one manually.", "portLookupFailed": "Could not find an available port. Retry or enter one manually.",
"callbackTitle": "Register this callback with the final OIDC provider", "callbackTitle": "Register this callback with the final OIDC provider",
"callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.", "callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.",
"builtinCallbackTitle": "The VPN client and Feishu app use different callbacks", "builtinCallbackTip": "The VPN client callback above is handled by openvpn-auth-oauth2. Register only the Feishu app callback below in Feishu. After starting the tunnel, sync /oauth2/ to local port 127.0.0.1:{port} in Settings.", "feishuCallbackLabel": "Feishu app callback",
"testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.", "testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.",
"startFailed": "Configuration generated, but the tunnel did not start. Resolve the error below and retry from the tunnel list.", "startFailed": "Configuration generated, but the tunnel did not start. Resolve the error below and retry from the tunnel list.",
"fillRequired": "Enter the OIDC issuer, app credentials, and public sign-in URL" "fillRequired": "Configure an identity source, OIDC issuer, VPN client credentials, and public sign-in URL"
}, },
"tunnelEditor": { "tunnelEditor": {
"title": "Edit Tunnel", "title": "Edit Tunnel",
@@ -569,7 +575,7 @@
}, },
"identity": { "identity": {
"tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.", "tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.",
"appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank to retain the secret; use a rotated secret for new setup", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Verify the enterprise tenant_key from Feishu user info", "enabled": "Enable built-in issuer", "appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank to retain the secret; use a rotated secret for new setup", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Verify the enterprise tenant_key from Feishu user info", "enabled": "Enable built-in issuer", "httpsOnly": "Enter app credentials only through the HTTPS management site. Switch to its secure address.", "openHttps": "Open HTTPS management site",
"httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.", "httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.",
"addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID", "addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID",
"oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.", "routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.", "oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.", "routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.",
+10 -4
View File
@@ -229,23 +229,27 @@
"title": "飞书 / OIDC 浏览器认证配置", "title": "飞书 / OIDC 浏览器认证配置",
"externalService": "需要 openvpn-auth-oauth2", "externalService": "需要 openvpn-auth-oauth2",
"componentReady": "认证组件已安装 v{version}", "componentReady": "认证组件已安装 v{version}",
"authEntryPending": "{domain} 的 HTTPS 已就绪;认证组件的公网回调路由尚未接通,仍需配置 OIDC 发行者。", "authEntryPending": "{domain} 的 HTTPS 已就绪;专线启动后还需在“设置 → 飞书身份”同步认证入口路由。",
"app": "创建应用", "app": "创建应用",
"appShort": "飞书开放平台", "appShort": "飞书开放平台",
"identity": "填写身份信息", "identity": "填写身份信息",
"identityShort": "OIDC 和应用凭据", "identityShort": "OIDC 和应用凭据",
"builtinIdentityShort": "在系统设置中填写飞书凭据",
"callback": "设置公网回调", "callback": "设置公网回调",
"callbackShort": "HTTPS 与反向代理", "callbackShort": "HTTPS 与反向代理",
"test": "客户端试连", "test": "客户端试连",
"testShort": "浏览器登录验证", "testShort": "浏览器登录验证",
"appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。", "appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。",
"builtinAppTip": "飞书 App ID 和 App Secret 统一在“设置 → 飞书身份”填写,不在专线里填写。",
"openConsole": "打开飞书开放平台的应用管理", "openConsole": "打开飞书开放平台的应用管理",
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。", "appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。", "appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
"builtinAppInstruction2": "在飞书应用的安全/重定向地址中登记下方“飞书应用回调”,不要登记 VPN 客户端回调。",
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。", "appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。", "scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
"providerTip": "可使用设置页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。", "providerTip": "可使用设置页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。",
"useBuiltIn": "使用内置飞书身份服务", "builtInSecret": "内置 VPN 客户端密钥由服务端自动提供,不在页面回显。", "builtInGatewayPending": "内置发行者尚未更新公网 Nginx 路由;到设置页“飞书身份”同步入口后再联调。", "source": "身份来源", "builtinSource": "内置飞书身份", "externalSource": "外部 OIDC", "configureBuiltIn": "尚未启用内置飞书身份服务;请先到设置页保存应用凭据并启用。", "manageFeishu": "前往设置 → 飞书身份", "builtinSummary": "飞书 App ID、App Secret 与租户标识在设置页统一管理;专线只引用 OIDC 发行者和专属认证入口。",
"useBuiltIn": "使用内置飞书身份服务", "builtInSecret": "VPN Client Secret 由服务端自动关联,无需复制或填写飞书 App Secret。", "builtInGatewayPending": "内置身份入口尚未同步 Nginx;到“设置 → 飞书身份”同步后再联调。",
"issuer": "OIDC 发行者地址", "issuer": "OIDC 发行者地址",
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。", "issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
"clientId": "OIDC Client ID", "clientId": "OIDC Client ID",
@@ -256,15 +260,17 @@
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。", "secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
"baseUrl": "认证入口公网地址", "baseUrl": "认证入口公网地址",
"baseTip": "自动填写仅复用域名和证书;当前管理站点尚未转发认证组件的 OAuth2 路径。接通 Nginx 回调路由前不能完成登录。修改域名后需核对已保存专线的回调地址。", "baseTip": "自动填写仅复用域名和证书;当前管理站点尚未转发认证组件的 OAuth2 路径。接通 Nginx 回调路由前不能完成登录。修改域名后需核对已保存专线的回调地址。",
"builtinBaseTip": "自动使用系统 HTTPS 域名;认证组件仅监听本机,启动专线后到设置页同步公网路由。",
"reuseDomain": "使用当前公网域名", "linkedDomain": "已引用系统设置中的 HTTPS 域名;可手动修改。", "reuseDomain": "使用当前公网域名", "linkedDomain": "已引用系统设置中的 HTTPS 域名;可手动修改。",
"listenPort": "本机认证服务端口", "listenPort": "本机认证服务端口",
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。", "portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择", "portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址", "callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。", "callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
"builtinCallbackTitle": "专线与飞书使用不同的回调地址", "builtinCallbackTip": "上方是 VPN 客户端回调,由 openvpn-auth-oauth2 处理;飞书应用中只登记下方的飞书应用回调。启动专线后在设置页同步 /oauth2/ 路由至本机 127.0.0.1:{port}。", "feishuCallbackLabel": "飞书应用回调",
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。", "testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
"startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动", "startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动",
"fillRequired": "请填写 OIDC 地址、应用凭据和公网认证入口" "fillRequired": "请先配置身份来源,并填写 OIDC 地址、VPN 客户端凭据和公网认证入口"
}, },
"tunnelEditor": { "tunnelEditor": {
"title": "编辑专线", "title": "编辑专线",
@@ -569,7 +575,7 @@
}, },
"identity": { "identity": {
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。", "tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "从飞书用户信息确认企业 tenant_key", "enabled": "启用内置发行者", "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "从飞书用户信息确认企业 tenant_key", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口",
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。", "httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID", "addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。", "oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。",
+8 -3
View File
@@ -42,7 +42,9 @@
<el-card class="page-card" shadow="never"> <el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template> <template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" /> <el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<el-form label-position="top" @submit.prevent="saveIdentity"> <el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item> <el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item> <el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<el-form-item :label="$t('identity.tenant')"><el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" /></el-form-item> <el-form-item :label="$t('identity.tenant')"><el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" /></el-form-item>
@@ -62,7 +64,7 @@
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item> <el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<p class="tls-hint">{{ $t('identity.routeHint') }}</p> <p class="tls-hint">{{ $t('identity.routeHint') }}</p>
<el-button :loading="identityGatewayLoading" :disabled="!identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button> <el-button :loading="identityGatewayLoading" :disabled="!identitySecure || !identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
</el-card> </el-card>
</div> </div>
</el-tab-pane> </el-tab-pane>
@@ -180,7 +182,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick, watch } from 'vue' import { computed, ref, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -199,6 +201,8 @@ function changeTab(name) {
const pwd = ref({ old_password: '', new_password: '', confirm: '' }) const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const identityStatus = ref(null) const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' }) const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const identityLoading = ref(false) const identityLoading = ref(false)
const identitySaving = ref(false) const identitySaving = ref(false)
const identityGatewayLoading = ref(false) const identityGatewayLoading = ref(false)
@@ -212,6 +216,7 @@ async function loadIdentity() {
finally { identityLoading.value = false } finally { identityLoading.value = false }
} }
async function saveIdentity() { async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true identitySaving.value = true
try { try {
await api.put('/system/identity', identityForm.value) await api.put('/system/identity', identityForm.value)
+4 -1
View File
@@ -88,6 +88,7 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const protocol = ref('') const protocol = ref('')
const originalAuthMode = ref('') const originalAuthMode = ref('')
const originalOIDCProvider = ref('')
const form = ref(null) const form = ref(null)
const advancedOpen = ref([]) const advancedOpen = ref([])
const routeListRef = ref(null) const routeListRef = ref(null)
@@ -138,6 +139,7 @@ async function load() {
protocol.value = tunnel.protocol protocol.value = tunnel.protocol
const config = parseConfig(tunnel.config) const config = parseConfig(tunnel.config)
originalAuthMode.value = config.auth_mode || '' originalAuthMode.value = config.auth_mode || ''
originalOIDCProvider.value = config.oidc_provider || 'external'
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) { for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
} }
@@ -159,7 +161,8 @@ async function load() {
async function save() { async function save() {
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName')) if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') { if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' || form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) { const keepsExternalSecret = originalAuthMode.value === 'oidc' && originalOIDCProvider.value !== 'builtin'
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(keepsExternalSecret || form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) {
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired')) if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
} }
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed')) if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))