From d6a9a92ad044a1531f61c64a90a589ae9ef4b8b0 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 20 Oct 2026 23:31:46 +0800 Subject: [PATCH] feat(openvpn): add OIDC browser authentication and setup guide --- AGENTS.md | 23 ++ README.md | 12 + backend/internal/service/openvpn/oidc.go | 254 ++++++++++++++++++ backend/internal/service/openvpn/openvpn.go | 107 +++++++- .../internal/service/openvpn/openvpn_test.go | 138 ++++++++++ docs/sdd-template.md | 66 +++++ docs/specs/SDD-001-openvpn-oidc.md | 68 +++++ frontend/src/components/OpenVPNOIDCGuide.vue | 66 +++++ frontend/src/locales/en-US.json | 37 ++- frontend/src/locales/zh-CN.json | 37 ++- frontend/src/protocols.js | 5 +- frontend/src/views/TunnelEditor.vue | 9 + frontend/src/views/TunnelWizard.vue | 9 + 13 files changed, 817 insertions(+), 14 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/internal/service/openvpn/oidc.go create mode 100644 docs/sdd-template.md create mode 100644 docs/specs/SDD-001-openvpn-oidc.md create mode 100644 frontend/src/components/OpenVPNOIDCGuide.vue diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..de9b135 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# opsx-vpn 协作说明 + +本项目是 Go(Gin)后端、Vue 3 前端与 SQLite 组成的 VPN 专线管理平台。先理解现有行为和需求边界,再按变更影响决定设计与验证的深度。 + +## 变更分级与设计 + +| 级别 | 场景 | 要求 | +|------|------|------| +| S0 | 文案、格式等无行为变化 | 简要说明即可 | +| S1 | 缺陷修复、现有契约内重构 | 说明问题、依据、需保留的行为和验收方式 | +| S2 | 新功能、接口/配置/数据模型变化、跨模块行为变化 | 实施前按 `docs/sdd-template.md` 写完整 SDD;每项需求对应可验证的验收标准 | +| S3 | 权限、安全、数据迁移/删除、生产部署变化 | 满足 S2 要求,并写清风险、发布顺序、验证与恢复方式 | + +- 按实际影响分级;调查中发现契约或风险扩大时,更新分级和设计。 +- 设计可以先在任务中形成;需要长期维护或用户要求入库时,存入 `docs/specs/`。不要为了套模板阻断已经明确授权的实现工作。 +- 先引用已有代码、接口、数据库结构和配置作为当前行为的依据;不编造业务规则、指标、批准记录或测试结果。未知或需决策的事项明确标记。 +- SDD 是设计与验收依据,不代表已经批准、实现或验证;只有实际执行过的检查才能标为通过。 + +## 实施与验证 + +- 用户要求设计时交付设计;用户要求修改或开发时,完成获授权的实现并按风险验证。需要用户决定的阻塞事项单独提出,独立工作继续推进。 +- 修改接口、数据或协议行为时,核对前后端调用、兼容性及相关测试;安全和部署变更还应检查失败场景与恢复路径。 +- 保留用户未提交的文件和改动;不要用生成文件覆盖现有工作。 diff --git a/README.md b/README.md index c0629b9..ce93ee3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,18 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交 ## 快速开始 +### OpenVPN 飞书 / OIDC 浏览器认证 + +在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。 + +1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用,启用网页登录授权,在应用安全设置中登记回调、发布应用并限定可用人员。先核实飞书租户是否提供标准 OIDC discovery (`/.well-known/openid-configuration`) 与 ID Token;若没有,用 Keycloak / Authentik 等身份代理对接飞书,再把代理的 OIDC issuer 及其客户端凭据填入向导。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。 +2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。创建时填 HTTPS issuer、Client ID/Secret、独立公网 HTTPS 入口(例如 `https://vpn-login.example.com`)以及本机服务端口(默认 `19090`)。每条 OIDC 隧道使用不同的公网入口和本机端口。 +3. 在最终的 OIDC 身份提供方注册引导页显示的 `https://vpn-login.example.com/oauth2/callback`(直连飞书时登记在飞书应用;经代理时登记在代理的 OIDC 客户端)。为该公网入口配置 TLS 和反向代理,将**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。回调由 `openvpn-auth-oauth2` 接收,**不需要**在平台的 `:18080` 增加 `/oauth2/callback`。 +4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。 +5. 下载 `.ovpn` 并在支持 WebAuth 的客户端试连。上游[客户端兼容性说明](https://github.com/jkroepke/openvpn-auth-oauth2/wiki/OpenVPN)列出 Windows OpenVPN Community Client 2.6+、macOS Tunnelblick 4.0 beta 10+ 和 Viscosity;OpenVPN Connect v3 仅部分兼容,应先实测。客户端需要在 VPN 建立**之前**访问公网 HTTPS 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。 + +首次上线先创建独立测试隧道;不要直接切换现有密码隧道。OIDC 登录身份的授权由飞书应用范围或身份代理规则控制,管理端的 VPN 拨号用户列表目前仍是独立的数据模型。需要将飞书身份与 LDAP 用户逐一绑定时,应增加按稳定 `sub` 映射的授权设计,不能只按相同邮箱/用户名匹配。服务日志位于实例目录的 `oidc.log`,可能包含认证诊断信息,按敏感日志管理。 + ### 本地构建与运行 ```bash diff --git a/backend/internal/service/openvpn/oidc.go b/backend/internal/service/openvpn/oidc.go new file mode 100644 index 0000000..535f4b7 --- /dev/null +++ b/backend/internal/service/openvpn/oidc.go @@ -0,0 +1,254 @@ +package openvpn + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/service" + "vpn-platform/backend/internal/store" +) + +func oidcBinary() (string, bool) { + p := filepath.Join(service.BaseDir(), "bin", "openvpn-auth-oauth2") + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + return p, true + } + return service.LookBin("openvpn-auth-oauth2") +} + +func checkOIDCDependencies(openvpnBin string) error { + if _, ok := oidcBinary(); !ok { + return fmt.Errorf("OIDC 认证需要先安装 openvpn-auth-oauth2 二进制(PATH 或 data/bin/)") + } + out, err := exec.Command(openvpnBin, "--version").Output() + if err != nil { + return fmt.Errorf("无法检查 OpenVPN 版本: %w", err) + } + return validateOIDCOpenVPNVersion(out) +} + +func validateOIDCOpenVPNVersion(out []byte) error { + m := regexp.MustCompile(`OpenVPN\s+(\d+)\.(\d+)\.(\d+)`).FindSubmatch(out) + if len(m) != 4 { + return fmt.Errorf("无法识别 OpenVPN 版本;OIDC 模式要求至少 2.6.2") + } + major, _ := strconv.Atoi(string(m[1])) + minor, _ := strconv.Atoi(string(m[2])) + patch, _ := strconv.Atoi(string(m[3])) + if major < 2 || (major == 2 && minor < 6) || (major == 2 && minor == 6 && patch < 2) { + return fmt.Errorf("OpenVPN %d.%d.%d 不支持此 OIDC 集成;至少需要 2.6.2", major, minor, patch) + } + return nil +} + +// yamlString uses the JSON string grammar (also valid YAML) so administrator +// values cannot add unexpected keys or directives to the generated config. +func yamlString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +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", + 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(service.Str(cfg, "oidc_issuer")), + yamlString(service.Str(cfg, "oidc_client_id")), + yamlString(service.Str(cfg, "oidc_client_secret")), + yamlString("unix://"+filepath.Join(dir, "management.sock")), + yamlString(service.Str(cfg, "oidc_management_password"))) +} + +func checkOIDCConflicts(t *model.Tunnel, cfg map[string]interface{}) error { + if store.DB == nil { + return nil + } + var others []model.Tunnel + if err := store.DB.Where("protocol = ? AND id <> ?", "openvpn", t.ID).Find(&others).Error; err != nil { + return err + } + for _, other := range others { + otherCfg, err := store.TunnelConfig(&other) + if err != nil || service.Str(otherCfg, "auth_mode") != "oidc" { + continue + } + if strings.EqualFold(strings.TrimRight(service.Str(otherCfg, "oidc_base_url"), "/"), strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/")) { + return fmt.Errorf("认证入口已由 OpenVPN 专线 %q 使用;每条 OIDC 专线须使用不同 HTTPS 入口", other.Name) + } + if service.Num(otherCfg, "oidc_http_port", 19090) == service.Num(cfg, "oidc_http_port", 19090) { + return fmt.Errorf("本机 OIDC 认证端口已由 OpenVPN 专线 %q 使用;请为每条专线指定不同端口", other.Name) + } + } + return nil +} + +func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error { + old, err := store.TunnelSecrets(t) + if err != nil { + return err + } + if service.Str(cfg, "oidc_client_secret") == "" { + cfg["oidc_client_secret"] = old["oidc_client_secret"] + } + if service.Str(cfg, "oidc_client_secret") == "" { + return fmt.Errorf("oidc_client_secret 必填;请在飞书应用凭据中复制 Client Secret") + } + for _, key := range []string{"oidc_http_secret", "oidc_management_password"} { + cfg[key] = old[key] // internal secrets are never supplied by the API caller + if service.Str(cfg, key) == "" { + secret, err := service.RandomSecret(24) + if err != nil { + return err + } + cfg[key] = secret + } + } + passwordPath, err := service.WriteConfig(t, "management-password.txt", service.Str(cfg, "oidc_management_password")+"\n") + if err != nil { + return err + } + if err := os.Chmod(passwordPath, 0o600); err != nil { + return err + } + configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg)) + if err != nil { + return err + } + return os.Chmod(configPath, 0o600) +} + +func oidcPidFile(t *model.Tunnel) string { + return filepath.Join(service.InstanceDir(t), "oidc.pid") +} + +func waitForManagement(t *model.Tunnel) error { + address := filepath.Join(service.InstanceDir(t), "management.sock") + for i := 0; i < 50; i++ { + if _, alive := service.ReadPid(t); !alive { + return fmt.Errorf("OpenVPN 在管理接口就绪前退出;请查看 instance.log") + } + conn, err := net.DialTimeout("unix", address, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("OpenVPN 管理接口未就绪;请查看 instance.log") +} + +func startOIDC(t *model.Tunnel, cfg map[string]interface{}) error { + bin, ok := oidcBinary() + if !ok { + return fmt.Errorf("openvpn-auth-oauth2 未安装") + } + logFile, err := os.OpenFile(filepath.Join(service.InstanceDir(t), "oidc.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err := logFile.Chmod(0o600); err != nil { + _ = logFile.Close() + return err + } + cmd := exec.Command(bin, "--config", filepath.Join(service.InstanceDir(t), "openvpn-auth-oauth2.yaml")) + cmd.Dir = service.InstanceDir(t) + cmd.Stdout, cmd.Stderr = logFile, logFile + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("无法启动 OIDC 服务: %w", err) + } + if err := os.WriteFile(oidcPidFile(t), []byte(strconv.Itoa(cmd.Process.Pid)), 0o600); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + logFile.Close() + return err + } + if err := os.Chmod(oidcPidFile(t), 0o600); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + _ = logFile.Close() + _ = os.Remove(oidcPidFile(t)) + return err + } + go func() { _ = cmd.Wait(); _ = logFile.Close() }() + address := fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090))) + for i := 0; i < 80; i++ { + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + if !oidcRunning(t) { + _ = stopOIDC(t) + return fmt.Errorf("OIDC 认证服务提前退出;请检查实例目录中的 oidc.log(issuer、凭据及网络)") + } + time.Sleep(100 * time.Millisecond) + } + _ = stopOIDC(t) + return fmt.Errorf("OIDC 认证服务启动超时;请检查 oidc.log") +} + +func oidcRunning(t *model.Tunnel) bool { + raw, err := os.ReadFile(oidcPidFile(t)) + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil || pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + return err == nil && proc.Signal(syscall.Signal(0)) == nil && oidcProcessMatches(t, pid) +} + +// On Linux a PID may be reused after an unexpected exit. Never send a signal +// to an unrelated process based only on a stale pid file. +func oidcProcessMatches(t *model.Tunnel, pid int) bool { + if runtime.GOOS != "linux" { + return true + } + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + return err == nil && strings.Contains(string(raw), "openvpn-auth-oauth2") && strings.Contains(string(raw), filepath.Join(service.InstanceDir(t), "openvpn-auth-oauth2.yaml")) +} + +func stopOIDC(t *model.Tunnel) error { + path := oidcPidFile(t) + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil || pid <= 0 { + return fmt.Errorf("OIDC PID 文件损坏") + } + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + if proc.Signal(syscall.Signal(0)) == nil && oidcProcessMatches(t, pid) { + _ = proc.Signal(syscall.SIGTERM) + for i := 0; i < 30 && proc.Signal(syscall.Signal(0)) == nil; i++ { + time.Sleep(100 * time.Millisecond) + } + if proc.Signal(syscall.Signal(0)) == nil { + _ = proc.Kill() + } + } + return os.Remove(path) +} diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index 3861a61..b4a6045 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -11,6 +11,7 @@ import ( "flag" "fmt" "net" + "net/url" "os" "os/exec" "path/filepath" @@ -109,9 +110,32 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, out["auth_mode"] = "password" } switch service.Str(out, "auth_mode") { - case "certificate", "password", "certificate+password": + case "certificate", "password", "certificate+password", "oidc": default: - return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password") + return nil, fmt.Errorf("auth_mode 必须为 certificate、password、certificate+password 或 oidc") + } + if service.Str(out, "auth_mode") == "oidc" { + for _, key := range []string{"oidc_issuer", "oidc_base_url"} { + 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 != "/") { + return nil, fmt.Errorf("%s 必须是无用户信息、查询和片段的 HTTPS 地址;回调地址使用独立公网域名", key) + } + } + if service.Str(out, "oidc_client_id") == "" { + return nil, fmt.Errorf("oidc_client_id 必填") + } + if service.Num(out, "oidc_http_port", 0) == 0 { + out["oidc_http_port"] = 19090 + } + p := service.Num(out, "oidc_http_port", 0) + if p < 1024 || p > 65535 || p != float64(int(p)) || p == service.Num(out, "port", 1194) { + return nil, fmt.Errorf("oidc_http_port 必须为 1024-65535 且不与 VPN 端口相同") + } + out["mfa_mode"] = "off" // 飞书 MFA 由身份提供方负责 + } 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"} { + delete(out, key) + } } // MFA 模式:off 关闭;optional 用户启用后校验;required 强制。 if service.Str(out, "mfa_mode") == "" { @@ -206,7 +230,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, } if _, ok := out["duplicate_cn"]; !ok { // 密码拨号多终端同用户名时生产常开;证书模式默认关(安全)。 - out["duplicate_cn"] = service.Str(out, "auth_mode") == "password" + out["duplicate_cn"] = service.Str(out, "auth_mode") == "password" || service.Str(out, "auth_mode") == "oidc" } if _, ok := out["reneg_sec"]; !ok { // 生产常见 reneg-sec 0;MFA/密码场景避免周期性 TLS 重协商。 @@ -312,6 +336,14 @@ func (s *Service) Create(t *model.Tunnel) error { if err != nil { return err } + if service.Str(cfg, "auth_mode") == "oidc" { + if err := checkOIDCConflicts(t, cfg); err != nil { + return err + } + if err := prepareOIDC(t, cfg); err != nil { + return err + } + } if _, err := service.WriteConfig(t, "server.conf", s.serverConfig(t, cfg)); err != nil { return err } @@ -323,7 +355,10 @@ func (s *Service) Create(t *model.Tunnel) error { return err } _ = os.Chmod(filepath.Join(service.InstanceDir(t), "init-pki.sh"), 0o700) - public, secrets := service.MergeSecrets(cfg) + if service.Str(cfg, "auth_mode") != "oidc" { + t.Secrets = "" // OpenVPN 只有 OIDC 配置使用 Secrets;切换模式时清除旧凭据 + } + public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password") if err := service.SaveTunnelConfig(t, public, secrets); err != nil { return err } @@ -400,6 +435,9 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri b.WriteString("verify-client-cert none\nusername-as-common-name\n") case "certificate+password": b.WriteString("verify-client-cert require\n") + case "oidc": + b.WriteString("verify-client-cert none\n") + fmt.Fprintf(&b, "management %q unix %q\nmanagement-client-auth\nauth-user-pass-optional\n", filepath.Join(dir, "management.sock"), filepath.Join(dir, "management-password.txt")) default: b.WriteString("verify-client-cert require\n") } @@ -611,7 +649,7 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri return "", err } mode := service.StrOr(cfg, "auth_mode", "certificate") - if mode != "password" { + if mode != "password" && mode != "oidc" { if err := s.IssueClientCertificate(t, user); err != nil { return "", err } @@ -627,7 +665,7 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri return "", e } var cert, key string - if mode != "password" { + if mode != "password" && mode != "oidc" { cert, e = read(filepath.Join(dir, "pki", "issued", user.Certificate+".crt")) if e != nil { return "", e @@ -677,7 +715,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { if username == "" { // Password-only authentication has no per-user certificate. Generate a // reusable profile so operators can distribute it before creating users. - if service.StrOr(cfg, "auth_mode", "certificate") == "password" { + if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "oidc" { return s.GenerateUserConfig(t, &model.VPNUser{}) } return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置") @@ -690,13 +728,28 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { } func (s *Service) Start(t *model.Tunnel) error { - if _, ok := findOpenVPNBin(); !ok { + openvpnBin, ok := findOpenVPNBin() + if !ok { return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn") } cfg, err := store.TunnelConfig(t) if err != nil { return err } + if service.Str(cfg, "auth_mode") == "oidc" { + if _, alive := service.ReadPid(t); alive { + if oidcRunning(t) { + return nil + } + if err := checkOIDCDependencies(openvpnBin); err != nil { + return err + } + if err := service.RequireTCPPortAvailable(int(service.Num(cfg, "oidc_http_port", 19090)), "OIDC 认证服务"); err != nil { + return err + } + return startOIDC(t, cfg) // OpenVPN still runs: restore browser login without rebinding VPN/NAT + } + } if err := pkiReady(t); err != nil { if !service.Bool(cfg, "pki_auto_init", true) { return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err) @@ -708,6 +761,14 @@ func (s *Service) Start(t *model.Tunnel) error { if err := service.RequireRoot("OpenVPN"); err != nil { return err } + if service.Str(cfg, "auth_mode") == "oidc" { + if err := checkOIDCDependencies(openvpnBin); err != nil { + return err + } + if err := service.RequireTCPPortAvailable(int(service.Num(cfg, "oidc_http_port", 19090)), "OIDC 认证服务"); err != nil { + return err + } + } if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil { return err } @@ -724,15 +785,34 @@ func (s *Service) Start(t *model.Tunnel) error { return err } } - if err := service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil { + if err := service.StartDaemon(t, openvpnBin, "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil { _ = s.removeNAT(t, cfg) return err } + if service.Str(cfg, "auth_mode") == "oidc" { + if err := waitForManagement(t); err != nil { + _ = service.KillPid(t) + _ = s.removeNAT(t, cfg) + return err + } + if err := startOIDC(t, cfg); err != nil { + _ = service.KillPid(t) + _ = s.removeNAT(t, cfg) + return err + } + if _, alive := service.ReadPid(t); !alive { + _ = stopOIDC(t) + _ = s.removeNAT(t, cfg) + return fmt.Errorf("OIDC 服务就绪时 OpenVPN 已退出;请查看 instance.log") + } + } return nil } -func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p } func (s *Service) Stop(t *model.Tunnel) error { var firstErr error + if err := stopOIDC(t); err != nil { + firstErr = err + } if err := service.KillPid(t); err != nil { firstErr = err } @@ -834,6 +914,13 @@ func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error { } func (s *Service) Status(t *model.Tunnel) (core.Status, error) { if _, alive := service.ReadPid(t); alive { + if cfg, err := store.TunnelConfig(t); err == nil && service.Str(cfg, "auth_mode") == "oidc" && !oidcRunning(t) { + st := service.NowStatus(true, "error", "OpenVPN 仍在运行,但 OIDC 认证服务离线;新连接将被拒绝,现有连接可能继续") + rx, tx := s.collectTraffic(t) + st.RxBytes = t.RxBytes + rx + st.TxBytes = t.TxBytes + tx + return st, nil + } st := service.NowStatus(true, "running", "OpenVPN 进程运行中") rx, tx := s.collectTraffic(t) st.RxBytes = t.RxBytes + rx diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index 4aca29c..a3a91e0 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -2,15 +2,153 @@ package openvpn import ( "encoding/base64" + "os" + "path/filepath" "strings" "testing" "time" + "github.com/glebarez/sqlite" "github.com/pquerna/otp/totp" + "gorm.io/gorm" "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/service" + "vpn-platform/backend/internal/store" ) +func TestOIDCConfigAndSecrets(t *testing.T) { + t.Setenv("VPN_DATA_DIR", t.TempDir()) + tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"} + input := map[string]interface{}{ + "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/team", + "oidc_base_url": "https://vpn-login.example.com", "oidc_client_id": "vpn-client", + "oidc_client_secret": "secret-\nadmin: true", "oidc_http_port": 19242, + } + cfg, err := New().Validate(input) + if err != nil { + t.Fatal(err) + } + if err := prepareOIDC(tunnel, cfg); err != nil { + t.Fatal(err) + } + conf := New().serverConfig(tunnel, cfg) + for _, required := range []string{"management-client-auth", "auth-user-pass-optional", "verify-client-cert none"} { + if !strings.Contains(conf, required) { + t.Fatalf("OIDC server config missing %q", required) + } + } + if strings.Contains(conf, "auth-user-pass-verify") || strings.Contains(conf, "secret-") { + t.Fatal("OIDC server config must not use password helper or leak secrets") + } + path := filepath.Join(service.InstanceDir(tunnel), "openvpn-auth-oauth2.yaml") + bytes, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(bytes), `secret: "secret-\nadmin: true"`) || strings.Contains(string(bytes), "\nadmin: true\n") { + t.Fatal("OIDC client secret must remain one quoted YAML value") + } + if stat, err := os.Stat(path); err != nil || stat.Mode().Perm() != 0o600 { + t.Fatalf("auth configuration must be 0600: %v %v", stat, err) + } + passwordPath := filepath.Join(service.InstanceDir(tunnel), "management-password.txt") + if stat, err := os.Stat(passwordPath); err != nil || stat.Mode().Perm() != 0o600 { + t.Fatalf("management password must be 0600: %v %v", stat, err) + } + public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password") + if public["oidc_client_secret"] != nil || secrets["oidc_client_secret"] == nil { + t.Fatal("OIDC credentials must not be kept in public config") + } + tunnel.Config = service.ConfigJSON(public) + dir := service.InstanceDir(tunnel) + if err := os.MkdirAll(filepath.Join(dir, "pki"), 0o700); err != nil { + t.Fatal(err) + } + for path, content := range map[string]string{"pki/ca.crt": "test-ca\n", "ta.key": "test-tls-key\n"} { + if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + client, err := New().GenerateClientConfig(tunnel) + if err != nil { + t.Fatal(err) + } + if strings.Contains(client, "auth-user-pass") || strings.Contains(client, "") || !strings.Contains(client, "") { + t.Fatal("OIDC client profile must use WebAuth without password or client certificate") + } +} + +func TestOIDCRejectsUnsafeURLs(t *testing.T) { + base := map[string]interface{}{ + "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a", + "oidc_base_url": "https://login.example.com", "oidc_client_id": "vpn", + } + for _, value := range []string{"http://login.example.com", "https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc"} { + cfg := service.CloneConfig(base) + cfg["oidc_base_url"] = value + if _, err := New().Validate(cfg); err == nil { + t.Errorf("expected invalid OIDC callback base URL: %q", value) + } + } + if _, err := New().Validate(base); err != nil { + t.Fatal(err) + } + base["auth_mode"] = "password" + base["oidc_client_secret"] = "must-not-survive" + cfg, err := New().Validate(base) + if err != nil { + t.Fatal(err) + } + if _, exists := cfg["oidc_client_secret"]; exists { + t.Fatal("switching away from OIDC must remove its credentials") + } +} + +func TestOIDCRequiresOpenVPN262(t *testing.T) { + for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} { + if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil { + t.Errorf("expected %q to fail minimum version check", version) + } + } + for _, version := range []string{"OpenVPN 2.6.2 x86_64", "OpenVPN 2.7.0 x86_64", "OpenVPN 3.0.0 x86_64"} { + if err := validateOIDCOpenVPNVersion([]byte(version)); err != nil { + t.Errorf("expected %q to pass: %v", version, err) + } + } +} + +func TestOIDCTunnelsNeedDistinctCallbackAndPort(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + oldDB := store.DB + store.DB = db + t.Cleanup(func() { store.DB = oldDB }) + if err := db.AutoMigrate(&model.Tunnel{}); err != nil { + t.Fatal(err) + } + first := model.Tunnel{Name: "first", Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{ + "auth_mode": "oidc", "oidc_base_url": "https://login.example.com", "oidc_http_port": 19090, + })} + if err := db.Create(&first).Error; err != nil { + t.Fatal(err) + } + other := &model.Tunnel{ID: first.ID + 1, Protocol: "openvpn"} + for _, cfg := range []map[string]interface{}{ + {"oidc_base_url": "https://login.example.com/", "oidc_http_port": 19091}, + {"oidc_base_url": "https://another.example.com", "oidc_http_port": 19090}, + } { + if err := checkOIDCConflicts(other, cfg); err == nil { + t.Errorf("expected conflict: %#v", cfg) + } + } + if err := checkOIDCConflicts(other, map[string]interface{}{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19091}); err != nil { + t.Fatal(err) + } +} + func TestValidateOpenVPNModes(t *testing.T) { cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"}) if err != nil { diff --git a/docs/sdd-template.md b/docs/sdd-template.md new file mode 100644 index 0000000..57ad579 --- /dev/null +++ b/docs/sdd-template.md @@ -0,0 +1,66 @@ +# SDD 模板(S2 / S3 必填) + +**何时必填:** S2(新功能、接口/配置/数据模型、跨模块行为)和 S3(权限、安全、迁移/删除、生产变更)动工前填本模板。S0 文案与 S1 契约内修复直接改代码,不套本模板。 + +**FR → AC:** 第 2 节每条 FR/NFR 在第 5 节有对应 AC(前置、操作、预期、验证方式)。S3 另填第 6 节发布与恢复。 + +本文件是当前项目的 SDD 模板。需要长期留存的设计文档可按本模板存入 `docs/specs/`(目录按需创建);无需另找模板或索引。设计状态、批准状态、实现状态和验证结果应分别记录,不能互相替代。 + +# SDD-xxx:<功能名称> + +| 字段 | 内容 | +|----------------|-----------------------| +| 版本 / 变更级别 | 0.1 / S2 或 S3 | +| 设计状态 | Draft | +| 实现状态 | Not Started | +| 负责人 / 日期 | 待指定 / YYYY-MM-DD | +| 关联需求 | - | +| 批准状态 | 未批准 | + +## 1. 背景、目标与范围 +- **当前行为与证据**: +- **本次目标**: +- **非目标**: +- **必须保留的行为**: + +## 2. 需求与约束 +- FR-01:当 <条件> 时,系统必须 <行为>。 +- NFR-01:<性能 / 安全 / 兼容要求及测量方式> +- 边界条件、异常场景、权限要求 + +## 3. 架构与方案 +- 涉及模块及职责 +- 关键数据流 / 调用关系 +- 复用哪些现有能力 +- 关键取舍及理由 +- Agent 可自主决定的实现细节 + +## 4. 接口、数据与配置契约 +(已有权威文件时直接引用路径,仅说明差异) +- 接口变更: +- 数据模型 / 迁移: +- 配置变更: +- 兼容性: + +## 5. 风险与验收 + +### 风险 +| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 | +|----------------|--------------------|------------| +| | | | + +### 验收标准 +| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | +|-------|----------|----------------|----------|----------|----------| +| AC-01 | FR-01 | | | | Not Run | + +## 6. 发布与恢复(S3 必填) +- 前置条件(配置、迁移、备份等) +- 发布顺序与健康检查 +- 回滚或前向修复步骤 +- 不可逆操作说明 + +## 7. 待决问题 +| 问题 | 是否阻塞 | 推荐方案 | +|------|----------|----------| +| | | | diff --git a/docs/specs/SDD-001-openvpn-oidc.md b/docs/specs/SDD-001-openvpn-oidc.md new file mode 100644 index 0000000..8bc07a7 --- /dev/null +++ b/docs/specs/SDD-001-openvpn-oidc.md @@ -0,0 +1,68 @@ +# SDD-001:OpenVPN 浏览器 OIDC 登录 + +| 字段 | 内容 | +|------|------| +| 版本 / 变更级别 | 0.1 / S3(身份认证、部署) | +| 设计状态 | Draft | +| 实现状态 | 已实现本地集成;待 Linux + 飞书环境联调 | +| 负责人 / 日期 | 待指定 / 2026-10-20 | +| 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 | +| 批准状态 | 未批准;用户已明确要求开始开发 | + +## 1. 背景、目标与范围 +- **当前行为与证据**:`backend/internal/service/openvpn/openvpn.go` 使用 `auth-user-pass-verify` 验证本地或 LDAP 密码,`frontend/src/views/TunnelWizard.vue` 的创建向导只有证书、密码、证书+密码模式。 +- **本次目标**:增加独立 OIDC 浏览器认证模式、飞书应用配置引导、OpenVPN WebAuth 所需管理接口、辅助认证服务的可部署配置与失效保护。 +- **非目标**:不把飞书网页登录等同 LDAP Bind;不声称现有 LDAP 用户名与飞书身份自动绑定;不让管理平台 `/api/login` 接受飞书令牌。 +- **必须保留的行为**:原有三种认证方式与客户端配置保持兼容。 + +## 2. 需求与约束 +- FR-01:选择 OIDC 模式时,向导显示飞书应用创建、OIDC 设置、客户端及网络验收步骤;旧模式不受影响。 +- FR-02:OIDC 模式生成 OpenVPN 管理接口 + WebAuth 配置,客户端不再要求本地/LDAP 密码;认证服务缺失时不能退回弱认证。 +- FR-03:使用独立的 `openvpn-auth-oauth2` 服务;浏览器通过外网可达 HTTPS 地址接收 OIDC 回调,管理接口只在本机使用。 +- FR-04:身份提供方必须显式限制可访问该应用的人员;VPN 拨号用户列表仍独立,不能凭同名邮箱自动认作 LDAP 用户。 +- NFR-01:OIDC 凭据及管理密码不可出现在隧道公开 JSON、客户端配置或普通日志,配置文件权限为 0600。 +- NFR-02:不支持浏览器 WebAuth 的客户端应有明确兼容性提示;OpenVPN 服务端版本需满足上游最低 2.6.2 要求。 + +## 3. 架构与方案 +- 在原有 `auth_mode` 增加 `oidc`,单独生成 `management-client-auth`、`auth-user-pass-optional` 和本机 Unix 管理 socket,禁用密码校验脚本;OpenVPN 基础 PKI 和 `tls-crypt` 沿用。 +- `openvpn-auth-oauth2` 使用独立 HTTPS 反代入口、飞书(或兼容身份代理)的 OIDC issuer、Client ID/Secret 与本机管理 socket。每个 OIDC 隧道使用独立回调主机或端口、管理 socket 与认证服务实例。 +- 在向导和编辑页面提供步骤提示,告知回调地址属于认证服务,并标明管理员应在飞书开放平台创建应用、配置登录与人员授权,确认标准 OIDC 发现文档及客户端支持。 +- 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。 + +## 4. 接口、数据与配置契约 +- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;不新增虚假的 `/oauth2/callback` 后端接口。 +- 数据模型 / 迁移:沿用隧道配置 JSON 与 Secrets 密文,无强制数据库迁移。 +- 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。 +- 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。 + +## 5. 风险与验收 + +### 风险 +| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 | +|----------------|--------------------|------------| +| issuer 不是标准 OIDC 或飞书未授权应用 | 认证失败关闭;允许通过兼容身份代理接入 | 认证服务启动/登录错误 | +| 认证服务停机 / 回调不可达 | 不能绕过认证;恢复服务与反代 | 客户端连接失败、服务日志 | +| 使用不支持 WebAuth 的客户端 | 在向导提示已验证的客户端与版本 | 客户端不弹浏览器或登录超时 | +| 未绑定本地 LDAP 用户 | 明确由身份源应用授权控制准入;后续按稳定 subject 制定绑定设计 | 管理端用户数与实际 OIDC 会话不一致 | + +### 验收标准 +| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | +|----|----------|----------------|----------|----------|------| +| AC-01 | FR-01 | 选择 OIDC / 切回密码模式 | 仅 OIDC 显示飞书和 WebAuth 提示 | 前端构建与页面检查 | 构建通过;浏览器手动检查待做 | +| AC-02 | FR-02 | 生成 OIDC 服务端和客户端配置 | 管理模式开启、无密码脚本、客户端可发起 WebAuth | Go 测试 | 单元测试通过;WebAuth 联调待做 | +| AC-03 | FR-03 | 启动认证服务,完成真实 OIDC 登录 | 浏览器回调并放行 OpenVPN | 真实 Linux 服务端 + 飞书应用 + 客户端联调 | Not Run | +| AC-04 | FR-04 | 未获应用授权的用户登录 | 被身份提供方或代理拒绝 | 真实联调 | Not Run | +| AC-05 | NFR-01 | 创建/编辑 OIDC 专线并检查文件及 API | 密钥不公开,文件 0600 | Go 测试与文件检查 | 单元测试通过;真实 API 交互待做 | +| AC-06 | NFR-02 | OpenVPN 版本过低或客户端无 WebAuth | 明确错误与提示 | Go 测试及手动客户端检查 | 版本单元测试通过;客户端手动检查待做 | + +## 6. 发布与恢复(S3 必填) +- 前置条件:飞书开放平台应用获管理员发布与用户授权;OIDC discovery 可达;公网 HTTPS 域名/证书及反代;OpenVPN >=2.6.2、支持 WebAuth 的客户端、认证服务二进制。 +- 发布顺序:先在测试隧道配置 OIDC 与回调,再验证失败拒绝、正确授权和断线重连;最后逐隧道切换,不批量改变现有密码隧道。 +- 回滚:停用测试隧道,改回原有认证方式并重启;恢复旧客户端配置。原有隧道的密钥、PKI 与用户数据保持不变。 +- 不可逆操作:本次不删除用户或凭据,不自动迁移 LDAP 认证。 + +## 7. 待决问题 +| 问题 | 是否阻塞 | 推荐方案 | +|------|----------|----------| +| 飞书租户是否公开标准 OIDC discovery / ID token | 阻塞真实联调,不阻塞本地实现 | 先从飞书应用或身份代理获取 issuer 并校验 discovery;不兼容时使用 Keycloak/Authentik 代理 | +| 是否需要按 LDAP 用户逐一授权 OIDC | 不阻塞第一版 | 首版仅信任身份源应用的可用范围;逐一绑定必须引入稳定 subject 的独立授权方案 | diff --git a/frontend/src/components/OpenVPNOIDCGuide.vue b/frontend/src/components/OpenVPNOIDCGuide.vue new file mode 100644 index 0000000..b3af088 --- /dev/null +++ b/frontend/src/components/OpenVPNOIDCGuide.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index bdf3006..84485ab 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -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": { diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index ce600ae..43ed07d 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -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": { diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js index ca6ee98..098f2d7 100644 --- a/frontend/src/protocols.js +++ b/frontend/src/protocols.js @@ -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'] } }, diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue index 5435326..303ebb9 100644 --- a/frontend/src/views/TunnelEditor.vue +++ b/frontend/src/views/TunnelEditor.vue @@ -29,6 +29,7 @@
{{ $t(field.tip) }}
+