Add opt-in built-in Feishu OIDC issuer for OpenVPN
This commit is contained in:
1 parent
6ca609fde4
commit
63cdda4ae1
23 files changed
+1244
-17
No files matched your search
@@ -16,6 +16,8 @@
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
|
||||
<el-button v-if="readiness?.built_in_enabled" type="primary" plain class="guide-alert" @click="useBuiltIn">{{ $t('oidcGuide.useBuiltIn') }}</el-button>
|
||||
<el-alert v-if="builtInSelected && !readiness?.built_in_gateway_ready" type="warning" :closable="false" :title="$t('oidcGuide.builtInGatewayPending')" class="guide-alert" />
|
||||
<el-form-item :label="$t('oidcGuide.issuer')" required>
|
||||
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
|
||||
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
|
||||
@@ -24,10 +26,11 @@
|
||||
<el-input v-model.trim="config.oidc_client_id" />
|
||||
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
|
||||
<el-form-item v-if="!builtInSelected" :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')" />
|
||||
<div class="field-tip">{{ $t('oidcGuide.secretTip') }}</div>
|
||||
</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-input v-model.trim="config.oidc_base_url" :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>
|
||||
@@ -62,6 +65,13 @@ const props = defineProps({ config: { type: Object, required: true }, editing: {
|
||||
const { t } = useI18n()
|
||||
const portLoading = ref(false)
|
||||
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)
|
||||
function useBuiltIn() {
|
||||
props.config.oidc_issuer = readiness.value.built_in_issuer
|
||||
props.config.oidc_client_id = readiness.value.built_in_client_id
|
||||
props.config.oidc_client_secret = ''
|
||||
props.config.oidc_provider = 'builtin'
|
||||
}
|
||||
async function loadReadiness() {
|
||||
try {
|
||||
const { data } = await api.get('/tunnels/openvpn/oidc-readiness')
|
||||
@@ -103,7 +113,7 @@ onMounted(() => {
|
||||
})
|
||||
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
|
||||
const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
||||
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing) ? 1
|
||||
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing && !builtInSelected.value) ? 1
|
||||
: !props.config.oidc_base_url ? 2 : 3)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@
|
||||
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
|
||||
"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.",
|
||||
"providerTip": "A Feishu app alone is not an OIDC issuer. Verify that your provider publishes standard discovery and ID tokens; otherwise use an identity broker such as Keycloak or Authentik. OpsX VPN does not currently issue OIDC identity tokens.",
|
||||
"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.",
|
||||
"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.",
|
||||
"clientId": "OIDC Client ID",
|
||||
@@ -566,6 +567,14 @@
|
||||
"loadFailed": "Failed to load logs",
|
||||
"noAlerts": "No traffic alerts"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
"syncGateway": "Sync identity gateway", "gatewaySynced": "Gateway routes updated", "gatewayFailed": "Gateway update failed", "loadFailed": "Could not load identity settings", "saved": "Identity settings saved", "saveFailed": "Could not save identity settings"
|
||||
},
|
||||
"settings": {
|
||||
"pageDescription": "Manage account security, the public gateway, and server setup.",
|
||||
"securityTab": "Account Security", "gatewayTab": "Public Gateway", "hostTab": "Server Setup",
|
||||
|
||||
@@ -244,7 +244,8 @@
|
||||
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
||||
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
||||
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
|
||||
"providerTip": "这张飞书应用页面只证明已创建应用,不能作为 OIDC 发行者。先确认飞书是否提供标准发现文档和 ID Token;否则需要 Keycloak / Authentik 等身份代理。当前 opsx-vpn 不签发 OIDC 身份令牌。",
|
||||
"providerTip": "可使用设置页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。",
|
||||
"useBuiltIn": "使用内置飞书身份服务", "builtInSecret": "内置 VPN 客户端密钥由服务端自动提供,不在页面回显。", "builtInGatewayPending": "内置发行者尚未更新公网 Nginx 路由;到设置页“飞书身份”同步入口后再联调。",
|
||||
"issuer": "OIDC 发行者地址",
|
||||
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
||||
"clientId": "OIDC Client ID",
|
||||
@@ -566,6 +567,14 @@
|
||||
"loadFailed": "加载日志失败",
|
||||
"noAlerts": "暂无流量告警"
|
||||
},
|
||||
"identity": {
|
||||
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
|
||||
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "从飞书用户信息确认企业 tenant_key", "enabled": "启用内置发行者",
|
||||
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
|
||||
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
|
||||
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。",
|
||||
"syncGateway": "同步身份公网路由", "gatewaySynced": "公网路由已更新", "gatewayFailed": "公网路由更新失败", "loadFailed": "读取身份配置失败", "saved": "身份配置已保存", "saveFailed": "保存身份配置失败"
|
||||
},
|
||||
"settings": {
|
||||
"pageDescription": "管理个人安全、公网认证入口与服务器环境。",
|
||||
"securityTab": "个人安全", "gatewayTab": "公网入口", "hostTab": "服务器环境",
|
||||
|
||||
@@ -37,6 +37,35 @@
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('identity.tab')" name="identity">
|
||||
<div class="settings-column settings-column-wide">
|
||||
<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-form 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.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.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>
|
||||
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
|
||||
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>{{ $t('identity.addresses') }}</b></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
|
||||
<el-button :loading="identityGatewayLoading" :disabled="!identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
|
||||
<div class="settings-column settings-column-wide">
|
||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||
@@ -162,12 +191,44 @@ const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('security')
|
||||
const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security'
|
||||
const visibleTab = (name) => auth.isAdmin() && ['identity', 'gateway', 'host'].includes(name) ? name : 'security'
|
||||
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
|
||||
function changeTab(name) {
|
||||
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
|
||||
}
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
const identityStatus = ref(null)
|
||||
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
|
||||
const identityLoading = ref(false)
|
||||
const identitySaving = ref(false)
|
||||
const identityGatewayLoading = ref(false)
|
||||
async function loadIdentity() {
|
||||
identityLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/system/identity')
|
||||
identityStatus.value = data
|
||||
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||
finally { identityLoading.value = false }
|
||||
}
|
||||
async function saveIdentity() {
|
||||
identitySaving.value = true
|
||||
try {
|
||||
await api.put('/system/identity', identityForm.value)
|
||||
await loadIdentity()
|
||||
ElMessage.success(t('identity.saved'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
|
||||
finally { identitySaving.value = false }
|
||||
}
|
||||
async function syncIdentityGateway() {
|
||||
identityGatewayLoading.value = true
|
||||
try {
|
||||
await api.post('/system/identity/gateway')
|
||||
await loadIdentity()
|
||||
ElMessage.success(t('identity.gatewaySynced'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
|
||||
finally { identityGatewayLoading.value = false }
|
||||
}
|
||||
const pwdLoading = ref(false)
|
||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||
const mfaCanvas = ref(null)
|
||||
@@ -365,7 +426,7 @@ async function changePwd() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness() }
|
||||
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness(); loadIdentity() }
|
||||
try {
|
||||
const status = await api.get('/mfa/status')
|
||||
mfa.value.enabled = status.data.enabled
|
||||
|
||||
@@ -159,7 +159,7 @@ async function load() {
|
||||
async function save() {
|
||||
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
|
||||
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
|
||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' || form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) {
|
||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||
}
|
||||
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||
|
||||
@@ -231,7 +231,7 @@ function selectProtocol(key) {
|
||||
async function create() {
|
||||
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
|
||||
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_base_url']) {
|
||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) {
|
||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||
}
|
||||
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||
|
||||
Reference in new issue
Block a user