feat(openvpn): add OIDC browser authentication and setup guide
This commit is contained in:
1 parent
5107f134dd
commit
d6a9a92ad0
13 files changed
+817
-14
No files matched your search
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<section class="oidc-guide">
|
||||
<div class="guide-title"><b>{{ $t('oidcGuide.title') }}</b><el-tag type="warning" effect="plain">{{ $t('oidcGuide.externalService') }}</el-tag></div>
|
||||
<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.identity')" :description="$t('oidcGuide.identityShort')" />
|
||||
<el-step :title="$t('oidcGuide.callback')" :description="$t('oidcGuide.callbackShort')" />
|
||||
<el-step :title="$t('oidcGuide.test')" :description="$t('oidcGuide.testShort')" />
|
||||
</el-steps>
|
||||
<el-alert type="info" :closable="false" :title="$t('oidcGuide.appTip')" class="guide-alert">
|
||||
<template #default>
|
||||
<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>
|
||||
<p>{{ $t('oidcGuide.scopeTip') }}</p>
|
||||
</template>
|
||||
</el-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>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('oidcGuide.clientId')" required>
|
||||
<el-input v-model.trim="config.oidc_client_id" />
|
||||
</el-form-item>
|
||||
<el-form-item :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-form-item :label="$t('oidcGuide.baseUrl')" required>
|
||||
<el-input v-model.trim="config.oidc_base_url" placeholder="https://vpn-login.example.com" />
|
||||
<div class="field-tip">{{ $t('oidcGuide.baseTip') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('oidcGuide.listenPort')">
|
||||
<el-input-number v-model="config.oidc_http_port" :min="1024" :max="65535" />
|
||||
<div class="field-tip">{{ $t('oidcGuide.portTip') }}</div>
|
||||
</el-form-item>
|
||||
<el-alert type="warning" :closable="false" class="guide-alert" :title="$t('oidcGuide.callbackTitle')">
|
||||
<template #default>
|
||||
<code>{{ callbackUrl }}</code>
|
||||
<p>{{ $t('oidcGuide.callbackTip', { port: config.oidc_http_port || 19090 }) }}</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-alert type="info" :closable="false" :title="$t('oidcGuide.testTip')" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
|
||||
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_base_url ? 2 : 3)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oidc-guide { margin: 10px 0 24px; padding: 18px; border: 1px solid var(--border-base); border-radius: var(--radius-sm); background: var(--bg-inset); }
|
||||
.guide-title { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.guide-steps { margin: 24px 0; }
|
||||
.guide-alert { margin: 16px 0; }
|
||||
.guide-alert p { margin: 8px 0 0; line-height: 1.5; }
|
||||
.guide-alert ol { padding-left: 20px; line-height: 1.7; }
|
||||
.field-tip { margin-top: 4px; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .guide-steps { overflow-x: auto; justify-content: flex-start; } .guide-steps :deep(.el-step) { min-width: 120px; } }
|
||||
</style>
|
||||
@@ -221,6 +221,40 @@
|
||||
"trafficTrend": "Traffic Trend",
|
||||
"noTrafficData": "No traffic data yet (waiting for samples…)"
|
||||
},
|
||||
"oidcGuide": {
|
||||
"title": "Feishu / OIDC browser authentication",
|
||||
"externalService": "Requires openvpn-auth-oauth2",
|
||||
"app": "Create app",
|
||||
"appShort": "Feishu developer console",
|
||||
"identity": "Enter identity settings",
|
||||
"identityShort": "OIDC and credentials",
|
||||
"callback": "Set public callback",
|
||||
"callbackShort": "HTTPS reverse proxy",
|
||||
"test": "Test client",
|
||||
"testShort": "Browser sign-in",
|
||||
"appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.",
|
||||
"openConsole": "Open Feishu app console",
|
||||
"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.",
|
||||
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
|
||||
"scopeTip": "Explicitly restrict who can use the app. Existing LDAP dial-in users are not automatically linked to Feishu identities. If your tenant lacks a standard OIDC discovery endpoint, use a compatible Keycloak / Authentik broker.",
|
||||
"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",
|
||||
"clientSecret": "OIDC Client Secret",
|
||||
"copySecret": "Copy from the app; hidden after saving",
|
||||
"keepSecret": "Leave blank to retain current secret",
|
||||
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
|
||||
"baseUrl": "Public sign-in URL",
|
||||
"baseTip": "Use a dedicated HTTPS root URL reachable before VPN connection. Give each OIDC tunnel its own endpoint.",
|
||||
"listenPort": "Local auth service port",
|
||||
"portTip": "The service listens on 127.0.0.1 only; each tunnel needs a distinct port.",
|
||||
"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.",
|
||||
"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.",
|
||||
"fillRequired": "Enter the OIDC issuer, app credentials, and public sign-in URL"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "Edit Tunnel",
|
||||
"backToList": "Back to List",
|
||||
@@ -993,7 +1027,8 @@
|
||||
"options": {
|
||||
"certificate": "Certificate Only",
|
||||
"password": "Username + Password",
|
||||
"certificate+password": "Certificate + Password"
|
||||
"certificate+password": "Certificate + Password",
|
||||
"oidc": "Third-party OAuth2 / OIDC (Feishu)"
|
||||
}
|
||||
},
|
||||
"mfa_mode": {
|
||||
|
||||
@@ -221,6 +221,40 @@
|
||||
"trafficTrend": "流量趋势",
|
||||
"noTrafficData": "暂无流量数据(等待采样中…)"
|
||||
},
|
||||
"oidcGuide": {
|
||||
"title": "飞书 / OIDC 浏览器认证配置",
|
||||
"externalService": "需要 openvpn-auth-oauth2",
|
||||
"app": "创建应用",
|
||||
"appShort": "飞书开放平台",
|
||||
"identity": "填写身份信息",
|
||||
"identityShort": "OIDC 和应用凭据",
|
||||
"callback": "设置公网回调",
|
||||
"callbackShort": "HTTPS 与反向代理",
|
||||
"test": "客户端试连",
|
||||
"testShort": "浏览器登录验证",
|
||||
"appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。",
|
||||
"openConsole": "打开飞书开放平台的应用管理",
|
||||
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
|
||||
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
||||
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
||||
"scopeTip": "在飞书侧明确设置应用可用人员与授权范围。现有 LDAP 拨号用户不会自动关联飞书身份;如飞书未提供兼容的标准 OIDC 发现地址,请先部署 Keycloak / Authentik 身份代理。",
|
||||
"issuer": "OIDC 发行者地址",
|
||||
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
||||
"clientId": "OIDC Client ID",
|
||||
"clientSecret": "OIDC Client Secret",
|
||||
"copySecret": "从应用凭据复制,保存后不回显",
|
||||
"keepSecret": "留空保持原密钥",
|
||||
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
|
||||
"baseUrl": "认证入口公网地址",
|
||||
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
|
||||
"listenPort": "本机认证服务端口",
|
||||
"portTip": "认证服务仅监听 127.0.0.1;多条专线各用不同端口。",
|
||||
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
|
||||
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
|
||||
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
|
||||
"startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动",
|
||||
"fillRequired": "请填写 OIDC 地址、应用凭据和公网认证入口"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "编辑专线",
|
||||
"backToList": "返回列表",
|
||||
@@ -993,7 +1027,8 @@
|
||||
"options": {
|
||||
"certificate": "仅证书",
|
||||
"password": "用户名 + 密码",
|
||||
"certificate+password": "证书 + 密码"
|
||||
"certificate+password": "证书 + 密码",
|
||||
"oidc": "第三方 OAuth2 / OIDC(飞书登录)"
|
||||
}
|
||||
},
|
||||
"mfa_mode": {
|
||||
|
||||
@@ -161,13 +161,14 @@ export const PROTOCOL_META = {
|
||||
{ key: 'auth_mode', label: 'protocols.openvpn.fields.auth_mode.label', type: 'select', options: [
|
||||
{ value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' },
|
||||
{ value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' },
|
||||
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' }
|
||||
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' },
|
||||
{ value: 'oidc', label: 'protocols.openvpn.fields.auth_mode.options.oidc' }
|
||||
], default: 'password', tip: 'protocols.openvpn.fields.auth_mode.tip' },
|
||||
{ key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [
|
||||
{ value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' },
|
||||
{ value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' },
|
||||
{ value: 'required', label: 'protocols.openvpn.fields.mfa_mode.options.required' }
|
||||
], default: 'off', tip: 'protocols.openvpn.fields.mfa_mode.tip', showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
], default: 'off', tip: 'protocols.openvpn.fields.mfa_mode.tip', showIf: { auth_mode: ['certificate', 'password', 'certificate+password'] } },
|
||||
{ key: 'otp_slop', label: 'protocols.openvpn.fields.otp_slop.label', type: 'number', default: 90, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_max_failures', label: 'protocols.openvpn.fields.otp_max_failures.label', type: 'number', default: 5, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_lockout_sec', label: 'protocols.openvpn.fields.otp_lockout_sec.label', type: 'number', default: 300, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
|
||||
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
|
||||
</el-form-item>
|
||||
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" :editing="originalAuthMode === 'oidc'" />
|
||||
<template v-if="isOpenVPN">
|
||||
<el-form-item :label="$t('tunnelEditor.trafficMode')">
|
||||
<el-radio-group v-model="trafficMode">
|
||||
@@ -77,6 +78,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
import RouteListInput from '../components/RouteListInput.vue'
|
||||
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -85,6 +87,7 @@ const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const protocol = ref('')
|
||||
const originalAuthMode = ref('')
|
||||
const form = ref(null)
|
||||
const advancedOpen = ref([])
|
||||
const routeListRef = ref(null)
|
||||
@@ -134,6 +137,7 @@ async function load() {
|
||||
const tunnel = data.tunnel
|
||||
protocol.value = tunnel.protocol
|
||||
const config = parseConfig(tunnel.config)
|
||||
originalAuthMode.value = config.auth_mode || ''
|
||||
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
||||
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
||||
}
|
||||
@@ -154,6 +158,11 @@ 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'])]) {
|
||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||
}
|
||||
}
|
||||
trafficMode.value = trafficMode.value
|
||||
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
||||
saving.value = true
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
|
||||
</el-form-item>
|
||||
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" />
|
||||
<template v-if="isOpenVPN">
|
||||
<el-form-item :label="$t('tunnelWizard.trafficMode')">
|
||||
<el-radio-group v-model="trafficMode">
|
||||
@@ -113,6 +114,8 @@
|
||||
<!-- 第三步:结果 -->
|
||||
<div v-if="step === 2" style="max-width:720px;margin:0 auto">
|
||||
<el-result icon="success" :title="$t('tunnelWizard.createSuccess')" :sub-title="$t('tunnelWizard.configGenerated', { name: created?.name })" />
|
||||
<el-alert v-if="created?.protocol === 'openvpn' && created?.state === 'error'" type="warning" :closable="false"
|
||||
:title="$t('oidcGuide.startFailed')" :description="created.last_error" style="margin-bottom:18px" />
|
||||
<h4>{{ $t('tunnelWizard.clientConfig') }}</h4>
|
||||
<pre class="mono conf-box">{{ clientConfig }}</pre>
|
||||
<div style="display:flex;gap:12px;justify-content:center;margin-top:16px">
|
||||
@@ -159,6 +162,7 @@ import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import RouteListInput from '../components/RouteListInput.vue'
|
||||
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -225,6 +229,11 @@ 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']) {
|
||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||
}
|
||||
}
|
||||
trafficMode.value = trafficMode.value
|
||||
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
||||
for (const f of visibleFields.value) {
|
||||
|
||||
Reference in new issue
Block a user