Improve Feishu permission checks and OpenVPN auth result page
This commit is contained in:
1 parent
0231c376c6
commit
373a0904be
9 files changed
+95
-31
No files matched your search
@@ -162,6 +162,7 @@ func (p *Provider) Status(c *gin.Context) {
|
||||
"tenant_key": cfg.TenantKey, "client_id": cfg.ClientID, "client_secret_configured": cfg.ClientSecret != "",
|
||||
"issuer": base, "feishu_callback": callback, "vpn_callback": vpnCallback,
|
||||
"tls_ready": tlsError == nil, "gateway_ready": issuerRoute,
|
||||
"user_scope_verified": cfg.UserScopeVerifiedAt != nil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,7 +219,7 @@ func (p *Provider) Configure(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if cfg.AppID != req.AppID && req.Enabled && !autoTenant {
|
||||
if (cfg.AppID != req.AppID || req.AppSecret != "") && req.Enabled && !autoTenant {
|
||||
creds, err := resolveTenantCredentials(tenantCredentials{req.AppID, req.AppSecret}, cfg.AppID, cfg.AppSecret)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
@@ -245,6 +246,9 @@ func (p *Provider) Configure(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if cfg.AppID != req.AppID || req.AppSecret != "" || cfg.TenantKey != req.TenantKey {
|
||||
cfg.UserScopeVerifiedAt = nil
|
||||
}
|
||||
if req.Enabled && cfg.AppSecret == "" {
|
||||
c.JSON(400, gin.H{"error": "飞书 App Secret 未配置"})
|
||||
return
|
||||
|
||||
@@ -222,6 +222,9 @@ func (p *Provider) FeishuCallback(c *gin.Context) {
|
||||
oidcError(c, 503)
|
||||
return
|
||||
}
|
||||
// A successful user-info lookup confirms this application's browser scope.
|
||||
// Do not store user identifiers; reset this evidence when credentials change.
|
||||
_ = store.DB.Model(&model.IdentityProvider{}).Where("id = ? AND app_id = ? AND tenant_key = ?", cfg.ID, cfg.AppID, cfg.TenantKey).Update("user_scope_verified_at", time.Now()).Error
|
||||
u, _ := url.Parse(state.Redirect)
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
|
||||
@@ -13,6 +13,7 @@ type IdentityProvider struct {
|
||||
TenantKey string `gorm:"size:128" json:"tenant_key"`
|
||||
ClientID string `gorm:"size:128" json:"client_id"`
|
||||
ClientSecret string `gorm:"type:text" json:"-"`
|
||||
UserScopeVerifiedAt *time.Time `json:"-"` // Set only after a successful Feishu user-info callback.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package openvpn
|
||||
|
||||
// This page is intentionally outcome-neutral: the auth component can render its
|
||||
// end template for different outcomes. Only the VPN client knows connection state.
|
||||
const authResultPage = `<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light"><title>返回 VPN 客户端 · OPSX VPN</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 0%,#e8f2ff 0%,#f5f8fc 48%,#f3f6fa 100%);color:#1c334e;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
.card{width:min(100%,440px);padding:44px 36px 38px;background:#fff;border:1px solid #e3eaf3;border-radius:22px;box-shadow:0 22px 65px rgba(29,68,113,.09);text-align:center}
|
||||
.brand{color:#3975bb;letter-spacing:.16em;font-size:12px;font-weight:800}.mark{display:grid;place-items:center;width:72px;height:72px;margin:28px auto 22px;background:#ebf4ff;border-radius:22px;color:#3479c8}
|
||||
h1{font-size:25px;line-height:1.3;margin:0 0 12px}p{margin:0;color:#62758a}.tip{margin-top:30px;padding:14px;background:#f4f8fd;border-radius:12px;font-size:14px;color:#526a84}
|
||||
@media(max-width:480px){.card{padding:36px 24px}}
|
||||
</style></head><body><main class="card"><div class="brand">OPSX VPN</div>
|
||||
<div class="mark" aria-hidden="true"><svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 3 33 9v9c0 9-5.8 14.6-14 17C10.8 32.6 5 27 5 18V9L19 3Z" stroke="currentColor" stroke-width="2.5"/><path d="m12.5 19 4.5 4.5 8.5-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h1>请返回 VPN 客户端</h1><p>浏览器认证步骤已结束。连接状态请以客户端显示为准。</p>
|
||||
<div class="tip">此页面可以关闭</div></main></body></html>`
|
||||
@@ -62,10 +62,11 @@ func yamlString(s string) string {
|
||||
|
||||
func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string {
|
||||
dir := service.InstanceDir(t)
|
||||
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n",
|
||||
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n",
|
||||
yamlString(service.Str(cfg, "oidc_base_url")),
|
||||
yamlString(fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090)))),
|
||||
yamlString(service.Str(cfg, "oidc_http_secret")),
|
||||
yamlString(filepath.Join(dir, "auth-result.html")),
|
||||
yamlString(service.Str(cfg, "oidc_issuer")),
|
||||
yamlString(service.Str(cfg, "oidc_client_id")),
|
||||
yamlString(service.Str(cfg, "oidc_client_secret")),
|
||||
@@ -140,6 +141,13 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
if err := os.Chmod(passwordPath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
pagePath, err := service.WriteConfig(t, "auth-result.html", authResultPage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(pagePath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -87,3 +87,9 @@
|
||||
生产中 OpenVPN 客户端已接入 TCP 1194,认证组件发送 WebAuth 并等待 60 秒;此前 `/oauth2/` 入口的 444 已修复。隔离浏览器对内置发行者的合成请求跳到飞书错误页 `20043`:`user_profile 有误`,实际返回 `invalid_scope`。代码在 `handlers.go` 将 `user_profile` 写入飞书授权链接,设置页也错误地指导管理员启用它。参考项目 `/Users/logan/devops/demo/id/Feishu-OIDC-Workers` 使用飞书权限 `contact:user.base:readonly` 获取用户基础资料,替换无效范围,并在页面清楚区分企业信息查询权限与浏览器登录授权权限。OpenVPN OIDC 模式通过飞书 `tenant_key` 和 `open_id` 识别用户,不自动读取 VPN 拨号用户目录;如需逐用户允许名单须另行设计,不在本次导入飞书用户。
|
||||
|
||||
验收:单元测试验证生成的飞书授权链接包含新的权限范围、不再包含 `user_profile`;现有令牌/JWKS/用户信息测试、前端构建通过;上线后授权链接跳转路径和无 state 入口验证通过。真实飞书授权仍需应用批准该权限、发布并由用户本人完成登录;未经本人授权不得宣称已跑通完整 VPN 连接。发布前备份旧二进制、发布后检查健康与 Nginx;可回退到旧二进制但将恢复无效范围,须停止测试专线或等待修复。
|
||||
|
||||
## 11. 权限检测与浏览器结果页(S3)
|
||||
|
||||
真实飞书授权及 OpenVPN 建链现已在实例日志中验证。配置页输入 App ID/Secret 后延迟调用已有 HTTPS 管理员查询接口:成功读取企业信息才显示 `tenant:tenant:readonly` 已验证,并自动填入 tenant_key;失败仅展示脱敏错误并允许重试。`contact:user.base:readonly` 是用户授权范围,不能仅靠应用凭据证明;首次成功换取用户资料并校验租户后记录无用户标识的验证时间,换应用凭据或租户时清除,设置页只展示是否曾通过。服务端启动或页面刷新不重复发起 OAuth,也不把手填 tenant_key 当作验证通过。密钥只经 HTTPS 管理接口传输,页面异步请求需忽略被新输入覆盖的旧响应。
|
||||
|
||||
认证组件通过原生 `http.template` 加载每实例的 0600 HTML 页面;页面只提示返回客户端查看连接状态,不在可能复用的终态模板上错误宣称 VPN 已连接。不额外放宽 Nginx 路径。验收:凭据变更后状态清零,查询正确/错误与旧响应均不误报;首次真实登录后状态可见;插件能正常加载模板并保持浏览器认证、OpenVPN 建链。发布时先构建测试,再替换二进制并重启受影响的 OIDC 实例以应用新模板;若插件不兼容模板,回退之前的配置与二进制、重启实例,旧认证仍可使用。数据库新列由 AutoMigrate 添加,回退版本忽略该列。
|
||||
@@ -575,8 +575,9 @@
|
||||
},
|
||||
"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.",
|
||||
"guideTitle": "Configure your Feishu app", "guideApp": "In Feishu Open Platform → Enterprise apps, open your app. Copy the App ID and App Secret from Credentials & Basic Information into the fields below. Use enterprise app credentials.", "guidePermission": "For automatic tenant lookup, request enterprise information read access (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu). For browser login, request the basic user information permission contact:user.base:readonly (use Feishu's actual console label).", "guidePublish": "Publish or enable the new app version, complete administrator review if required, and add VPN users to the app's availability scope. Creating the app or adding a permission alone does not activate it.", "guideCallback": "In Security Settings → Redirect URLs, add this exact Feishu sign-in callback:", "guideCallbackPending": "Configure the HTTPS domain under Public Gateway first to see the callback URL.", "guideVerify": "Save and enable the identity service, then sync its public routes. Configure and start an OIDC OpenVPN tunnel, verify openvpn-auth-oauth2 is installed, sync the routes again, and test with a user in the app's availability scope.", "guideManual": "If automatic lookup fails, enter a tenant_key verified from user information in the same enterprise. The Feishu error code appears below the field; retry after changing credentials or permissions.", "guideScope": "Finding tenant_key only proves the enterprise information API works; it does not confirm Feishu browser sign-in or VPN access. Browser login also needs the basic user information permission contact:user.base:readonly, the callback URL, app publication, and user availability. VPN also needs a tunnel and auth component. The Feishu callback differs from the VPN callback.",
|
||||
"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": "Fetch automatically or enter manually", "fetchTenant": "Fetch tenant ID", "fetchingTenant": "Requesting Feishu ({seconds}s)…", "tenantFetched": "Feishu tenant ID retrieved", "tenantFailed": "Could not fetch Feishu tenant", "tenantNotEnabled": "Tenant ID is set; enable the identity service and finish the callback, app publication, and tunnel configuration.", "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",
|
||||
"guideTitle": "Feishu app setup", "guideApp": "Enter the enterprise app ID and secret; enable both permissions below.", "guidePermission": "", "guidePublish": "Publish the app and include VPN users in its availability scope.", "guideCallback": "Security Settings → Redirect URL:", "guideCallbackPending": "Configure an HTTPS domain first.", "guideVerify": "", "guideManual": "", "guideScope": "",
|
||||
"tenantPermission": "tenant:tenant:readonly · enterprise", "userPermission": "contact:user.base:readonly · user", "checking": "Checking", "checked": "Verified", "checkFailed": "Check failed", "checkPending": "Not checked", "checkedByLogin": "Login verified", "loginPending": "Verify on first login",
|
||||
"appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank if saved; enter a new secret to rotate", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Fetched automatically or enter manually", "fetchTenant": "Check again", "fetchingTenant": "Checking ({seconds}s)…", "tenantFetched": "Enterprise permission verified", "tenantFailed": "Enterprise permission check failed", "tenantNotEnabled": "", "enabled": "Enable built-in issuer", "httpsOnly": "Use HTTPS to enter app credentials.", "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.",
|
||||
"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.",
|
||||
|
||||
@@ -575,8 +575,9 @@
|
||||
},
|
||||
"identity": {
|
||||
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
|
||||
"guideTitle": "飞书开放平台配置步骤", "guideApp": "在飞书开放平台 → 企业自建应用中打开你的应用;在「凭证与基础信息」复制 App ID 和 App Secret,填入下方。不要使用商店应用或智能体的其他凭据。", "guidePermission": "自动获取企业标识:在「权限管理」申请企业信息读取权限(搜索 tenant:tenant:readonly 或“获取企业信息”,以飞书控制台实际名称为准);浏览器登录:申请用户基础信息权限 contact:user.base:readonly(以飞书后台实际名称为准),本系统通过该授权读取登录用户。", "guidePublish": "按企业要求发布/启用新版本,等待管理员审核,并在「应用可用范围」加入需要登录 VPN 的用户;只创建应用或添加权限不代表已生效。", "guideCallback": "在飞书应用的「安全设置 → 重定向 URL」添加飞书登录回调(完整地址):", "guideCallbackPending": "先在「公网入口」完成 HTTPS 域名配置,页面才会显示回调地址。", "guideVerify": "保存并启用本页身份服务,再同步身份公网路由;配置并启动支持 OIDC 的 OpenVPN 专线,确认 openvpn-auth-oauth2 已安装后再次同步公网路由,最后用应用可用范围内的账号试登录。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。", "guideScope": "查到 tenant_key 只说明企业信息接口可用,不代表飞书网页登录或 VPN 已可连接。网页登录还需要用户基础信息权限 contact:user.base:readonly、回调、发布与用户可用范围;VPN 还需配置专线和认证组件。飞书回调与 VPN 客户端回调是两个地址。",
|
||||
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "fetchingTenant": "正在请求飞书({seconds} 秒)…", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "tenantNotEnabled": "企业标识已填写;仍需启用身份服务并完成回调、应用发布及专线配置。", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口",
|
||||
"guideTitle": "飞书应用准备", "guideApp": "填入企业自建应用的 App ID 和 App Secret;在权限管理开通下方两项权限。", "guidePermission": "", "guidePublish": "发布应用,并将 VPN 用户加入应用可用范围。", "guideCallback": "安全设置 → 重定向 URL:", "guideCallbackPending": "请先配置 HTTPS 域名。", "guideVerify": "", "guideManual": "", "guideScope": "",
|
||||
"tenantPermission": "tenant:tenant:readonly · 企业信息", "userPermission": "contact:user.base:readonly · 用户信息", "checking": "检测中", "checked": "已验证", "checkFailed": "检测失败", "checkPending": "待检测", "checkedByLogin": "登录已验证", "loginPending": "首次登录后验证",
|
||||
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "已保存可留空;更换密钥请填写", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "自动读取,也可手填", "fetchTenant": "重新检测", "fetchingTenant": "正在检测({seconds} 秒)…", "tenantFetched": "企业信息权限已验证", "tenantFailed": "检测企业信息权限失败", "tenantNotEnabled": "", "enabled": "启用内置发行者", "httpsOnly": "请通过 HTTPS 管理入口填写密钥。", "openHttps": "打开 HTTPS 管理入口",
|
||||
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
|
||||
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
|
||||
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。",
|
||||
|
||||
@@ -42,30 +42,27 @@
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>{{ $t('identity.title') }}</b></template>
|
||||
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
|
||||
<el-card shadow="never" class="identity-guide">
|
||||
<template #header><b>{{ $t('identity.guideTitle') }}</b></template>
|
||||
<ol>
|
||||
<li>{{ $t('identity.guideApp') }}</li>
|
||||
<li>{{ $t('identity.guidePermission') }}</li>
|
||||
<li>{{ $t('identity.guidePublish') }}</li>
|
||||
<li>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></li>
|
||||
<li>{{ $t('identity.guideVerify') }}</li>
|
||||
</ol>
|
||||
<p class="tls-hint">{{ $t('identity.guideManual') }}</p>
|
||||
<el-alert type="info" :closable="false" :title="$t('identity.guideScope')" />
|
||||
</el-card>
|
||||
<div class="identity-guide">
|
||||
<b>{{ $t('identity.guideTitle') }}</b>
|
||||
<p>{{ $t('identity.guideApp') }}</p>
|
||||
<p>{{ $t('identity.guidePublish') }}</p>
|
||||
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
|
||||
</div>
|
||||
<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" @change="onIdentityAppChange" /></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>
|
||||
<div class="identity-checks" aria-live="polite">
|
||||
<div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div>
|
||||
<div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
|
||||
</div>
|
||||
<el-form-item :label="$t('identity.tenant')">
|
||||
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
|
||||
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant">{{ $t('identity.fetchTenant') }}</el-button>
|
||||
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
|
||||
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
|
||||
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
|
||||
</el-form-item>
|
||||
<el-alert v-if="identityForm.tenant_key && !identityForm.enabled" type="info" :closable="false" :title="$t('identity.tenantNotEnabled')" style="margin-bottom:16px" />
|
||||
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:16px" />
|
||||
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
|
||||
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
|
||||
@@ -229,19 +226,33 @@ const identityGatewayLoading = ref(false)
|
||||
const tenantLoading = ref(false)
|
||||
const tenantName = ref('')
|
||||
const tenantError = ref('')
|
||||
const tenantChecked = ref(false)
|
||||
const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key)
|
||||
const tenantSeconds = ref(0)
|
||||
let tenantTimer
|
||||
onUnmounted(() => clearInterval(tenantTimer))
|
||||
function onIdentityAppChange() {
|
||||
let identityCheckTimer
|
||||
let tenantRequest = 0
|
||||
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
|
||||
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
|
||||
if (appId === oldId && secret === oldSecret) return
|
||||
tenantRequest++
|
||||
clearInterval(tenantTimer)
|
||||
tenantLoading.value = false
|
||||
tenantChecked.value = false
|
||||
tenantError.value = ''
|
||||
if (identityForm.value.app_id !== identityStatus.value?.app_id) {
|
||||
clearTimeout(identityCheckTimer)
|
||||
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
|
||||
identityForm.value.tenant_key = ''
|
||||
tenantName.value = ''
|
||||
}
|
||||
if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) {
|
||||
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
|
||||
}
|
||||
async function fetchIdentityTenant() {
|
||||
})
|
||||
async function fetchIdentityTenant(silent = false) {
|
||||
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
|
||||
if (tenantLoading.value) return
|
||||
const request = ++tenantRequest
|
||||
clearInterval(tenantTimer)
|
||||
tenantError.value = ''
|
||||
tenantLoading.value = true
|
||||
tenantSeconds.value = 0
|
||||
@@ -249,16 +260,21 @@ async function fetchIdentityTenant() {
|
||||
tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000)
|
||||
try {
|
||||
const appId = identityForm.value.app_id
|
||||
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: identityForm.value.app_secret })
|
||||
if (identityForm.value.app_id === appId) {
|
||||
const secret = identityForm.value.app_secret
|
||||
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret })
|
||||
if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) {
|
||||
identityForm.value.tenant_key = data.tenant_key
|
||||
tenantName.value = data.name || ''
|
||||
ElMessage.success(t('identity.tenantFetched'))
|
||||
tenantChecked.value = true
|
||||
if (!silent) ElMessage.success(t('identity.tenantFetched'))
|
||||
}
|
||||
} catch (e) { tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
|
||||
finally { clearInterval(tenantTimer); tenantLoading.value = false }
|
||||
} catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
|
||||
finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } }
|
||||
}
|
||||
async function loadIdentity() {
|
||||
tenantRequest++
|
||||
clearInterval(tenantTimer)
|
||||
tenantLoading.value = false
|
||||
identityLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/system/identity')
|
||||
@@ -266,6 +282,7 @@ async function loadIdentity() {
|
||||
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||
tenantName.value = ''
|
||||
tenantError.value = ''
|
||||
tenantChecked.value = false
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||
finally { identityLoading.value = false }
|
||||
}
|
||||
@@ -539,6 +556,12 @@ function copyBackup() {
|
||||
.settings-column-wide { max-width: 850px; }
|
||||
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
|
||||
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
|
||||
.identity-guide { padding: 16px 18px; margin-bottom: 20px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
|
||||
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
|
||||
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.identity-guide code { color: #246cb7; }
|
||||
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
|
||||
.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; }
|
||||
@media (max-width: 640px) { .settings-tabs { padding: 0 14px 16px; } .settings-tabs :deep(.el-tabs__item) { font-size: 12px; padding: 0 10px; } }
|
||||
.backup-codes {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
|
||||
|
||||
Reference in new issue
Block a user