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,23 @@
|
||||
# opsx-vpn 协作说明
|
||||
|
||||
本项目是 Go(Gin)后端、Vue 3 前端与 SQLite 组成的 VPN 专线管理平台。先理解现有行为和需求边界,再按变更影响决定设计与验证的深度。
|
||||
|
||||
## 变更分级与设计
|
||||
|
||||
| 级别 | 场景 | 要求 |
|
||||
|------|------|------|
|
||||
| S0 | 文案、格式等无行为变化 | 简要说明即可 |
|
||||
| S1 | 缺陷修复、现有契约内重构 | 说明问题、依据、需保留的行为和验收方式 |
|
||||
| S2 | 新功能、接口/配置/数据模型变化、跨模块行为变化 | 实施前按 `docs/sdd-template.md` 写完整 SDD;每项需求对应可验证的验收标准 |
|
||||
| S3 | 权限、安全、数据迁移/删除、生产部署变化 | 满足 S2 要求,并写清风险、发布顺序、验证与恢复方式 |
|
||||
|
||||
- 按实际影响分级;调查中发现契约或风险扩大时,更新分级和设计。
|
||||
- 设计可以先在任务中形成;需要长期维护或用户要求入库时,存入 `docs/specs/`。不要为了套模板阻断已经明确授权的实现工作。
|
||||
- 先引用已有代码、接口、数据库结构和配置作为当前行为的依据;不编造业务规则、指标、批准记录或测试结果。未知或需决策的事项明确标记。
|
||||
- SDD 是设计与验收依据,不代表已经批准、实现或验证;只有实际执行过的检查才能标为通过。
|
||||
|
||||
## 实施与验证
|
||||
|
||||
- 用户要求设计时交付设计;用户要求修改或开发时,完成获授权的实现并按风险验证。需要用户决定的阻塞事项单独提出,独立工作继续推进。
|
||||
- 修改接口、数据或协议行为时,核对前后端调用、兼容性及相关测试;安全和部署变更还应检查失败场景与恢复路径。
|
||||
- 保留用户未提交的文件和改动;不要用生成文件覆盖现有工作。
|
||||
@@ -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/<ID>-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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, "<cert>") || !strings.Contains(client, "<tls-crypt>") {
|
||||
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 {
|
||||
|
||||
@@ -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. 待决问题
|
||||
| 问题 | 是否阻塞 | 推荐方案 |
|
||||
|------|----------|----------|
|
||||
| | | |
|
||||
@@ -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 的独立授权方案 |
|
||||
@@ -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