Add opt-in built-in Feishu OIDC issuer for OpenVPN
This commit is contained in:
1 parent
6ca609fde4
commit
63cdda4ae1
23 files changed
+1244
-17
No files matched your search
@@ -39,9 +39,9 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
|
|||||||
|
|
||||||
在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。
|
在「创建专线 → 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 OIDC 客户端的 ID/Secret**填入向导。飞书 App ID/Secret 只配置在代理的飞书上游,不能直接当成代理签发的 OIDC 客户端凭据。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。OpsX VPN 当前不提供 OIDC discovery、签名密钥及 ID Token 签发能力,管理地址不能充当 issuer。
|
1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建并发布企业自建应用,启用网页登录,限定可用人员。**内置方案**:在「系统设置 → 飞书身份」保存飞书 App ID、轮换后的 App Secret 与允许的 `tenant_key`,把页面所示 `https://<公网域名>/id/feishu/callback` 登记到飞书应用;启用后系统通过 `https://<公网域名>/id` 提供标准 OIDC 发现文档、JWKS 和令牌签发。管理员在专线向导点击「使用内置飞书身份服务」,VPN Client Secret 由服务端自动配置,不会回显。也可以继续使用外部标准 OIDC issuer:此时飞书 App 凭据留在外部代理,向导填写代理生成的 VPN Client ID/Secret。
|
||||||
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 隧道使用不同的公网入口和本机端口。
|
2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。内置方案在向导中选择内置 issuer,公网认证入口可复用管理域名(已有证书);独立服务端口默认 `19090`。外部 OIDC 方案仍需手动填写发行者、VPN 客户端凭据与独立 HTTPS 入口。每条专线的认证入口和本机端口必须唯一。
|
||||||
3. 在最终的 OIDC 身份提供方注册引导页显示的 `https://vpn-login.example.com/oauth2/callback`(直连飞书时登记在飞书应用;经代理时登记在代理的 OIDC 客户端)。公网认证站点可以与管理后台部署在**同一台 opsx-vpn 服务器**:按域名将管理站点转发到 `:18080`、将认证站点的**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。当前实现由 `openvpn-auth-oauth2` 接收回调,平台的 `:18080` 不处理该路径。
|
3. 内置方案在设置页同步身份公网路由,Nginx 只转发固定 `/id/` 端点;启动**唯一一条使用该管理域名**的测试 OpenVPN OIDC 专线后再次同步,Nginx 才将 `/oauth2/` 转给本机认证组件。该 `https://<公网域名>/oauth2/callback` 属于 OpenVPN 组件,**不是飞书应用的回调**。外部发行者方案仍需在外部 OIDC 客户端登记此 VPN 回调。公网只开放 HTTPS 与 VPN 协议端口,认证端口 `127.0.0.1:19090+` 和 OpenVPN 管理 socket 不对公网开放。
|
||||||
4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。
|
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 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。
|
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 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"vpn-platform/backend/internal/binmgr"
|
"vpn-platform/backend/internal/binmgr"
|
||||||
"vpn-platform/backend/internal/core"
|
"vpn-platform/backend/internal/core"
|
||||||
|
"vpn-platform/backend/internal/identityprovider"
|
||||||
"vpn-platform/backend/internal/model"
|
"vpn-platform/backend/internal/model"
|
||||||
"vpn-platform/backend/internal/service"
|
"vpn-platform/backend/internal/service"
|
||||||
"vpn-platform/backend/internal/service/custom"
|
"vpn-platform/backend/internal/service/custom"
|
||||||
@@ -213,6 +214,14 @@ func requirePasswordChanged() gin.HandlerFunc {
|
|||||||
func (s *Server) RegisterRoutes(r *gin.Engine) {
|
func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||||
s.router = r
|
s.router = r
|
||||||
r.Use(securityHeaders())
|
r.Use(securityHeaders())
|
||||||
|
idp := identityprovider.New()
|
||||||
|
id := r.Group("/id", identityprovider.SecureGateway(), identityprovider.RateLimit())
|
||||||
|
id.GET("/.well-known/openid-configuration", idp.Discovery)
|
||||||
|
id.GET("/jwks", idp.JWKS)
|
||||||
|
id.GET("/authorize", idp.Authorize)
|
||||||
|
id.GET("/feishu/callback", idp.FeishuCallback)
|
||||||
|
id.POST("/token", idp.Token)
|
||||||
|
id.GET("/userinfo", idp.UserInfo)
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
api.POST("/login", s.login)
|
api.POST("/login", s.login)
|
||||||
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||||
@@ -302,6 +311,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
admin.GET("/system/env", s.systemEnv)
|
admin.GET("/system/env", s.systemEnv)
|
||||||
admin.POST("/system/fix", s.systemFix)
|
admin.POST("/system/fix", s.systemFix)
|
||||||
admin.GET("/system/tls", s.tlsStatus)
|
admin.GET("/system/tls", s.tlsStatus)
|
||||||
|
admin.GET("/system/identity", idp.Status)
|
||||||
|
admin.PUT("/system/identity", idp.Configure)
|
||||||
|
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
|
||||||
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
||||||
admin.PUT("/system/tls/mode", s.setTLSMode)
|
admin.PUT("/system/tls/mode", s.setTLSMode)
|
||||||
admin.GET("/system/tls/nginx-preview", s.nginxPreview)
|
admin.GET("/system/tls/nginx-preview", s.nginxPreview)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/nginxgateway"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
"vpn-platform/backend/internal/tlsservice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only a single OpenVPN WebAuth instance may own /oauth2/ on the management
|
||||||
|
// hostname. Never route an arbitrary user-supplied address to a local port.
|
||||||
|
func authGatewayPort(domain string) (int, error) {
|
||||||
|
var tunnels []model.Tunnel
|
||||||
|
if err := store.DB.Where("protocol = ? AND state = ?", "openvpn", "running").Find(&tunnels).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
port := 0
|
||||||
|
for _, tunnel := range tunnels {
|
||||||
|
cfg, err := store.TunnelConfig(&tunnel)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "auth_mode") != "oidc" || !strings.EqualFold(strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/"), "https://"+domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if port != 0 {
|
||||||
|
return 0, &gatewayConflict{}
|
||||||
|
}
|
||||||
|
port = int(service.Num(cfg, "oidc_http_port", 0))
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatewayConflict struct{}
|
||||||
|
|
||||||
|
func (*gatewayConflict) Error() string {
|
||||||
|
return "当前域名存在多条运行中的 OIDC 专线,请先保留一条"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) identityGateway(c *gin.Context) {
|
||||||
|
status := tlsservice.Status()
|
||||||
|
if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, err := authGatewayPort(status.Domain)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Audit(c.GetString("username"), "identity_gateway_updated", status.Domain, "")
|
||||||
|
c.JSON(200, gin.H{"ok": true, "identity_route": true, "auth_route": port != 0, "auth_port": port})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"vpn-platform/backend/internal/identityprovider"
|
||||||
"vpn-platform/backend/internal/nginxgateway"
|
"vpn-platform/backend/internal/nginxgateway"
|
||||||
"vpn-platform/backend/internal/service"
|
"vpn-platform/backend/internal/service"
|
||||||
"vpn-platform/backend/internal/tlsservice"
|
"vpn-platform/backend/internal/tlsservice"
|
||||||
@@ -37,10 +38,15 @@ func (s *Server) openvpnOIDCReadiness(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tls := tlsservice.Status()
|
tls := tlsservice.Status()
|
||||||
|
builtInIssuer, builtInClientID, builtInEnabled := identityprovider.PublicClient()
|
||||||
|
port, _ := authGatewayPort(tls.Domain)
|
||||||
|
issuerRoute, callbackRoute := nginxgateway.AuthRouteReady(port)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"component_installed": path != "", "component_version": version,
|
"component_installed": path != "", "component_version": version,
|
||||||
"https_management_ready": tls.Mode == "nginx" && tls.Valid && nginxgateway.Stage() == "https",
|
"https_management_ready": tls.Mode == "nginx" && tls.Valid && nginxgateway.Stage() == "https",
|
||||||
"management_domain": tls.Domain,
|
"management_domain": tls.Domain,
|
||||||
"authentication_entry_ready": false, // Depends on a distinct callback site and a configured provider.
|
"authentication_entry_ready": port > 0 && callbackRoute,
|
||||||
|
"built_in_issuer": builtInIssuer, "built_in_client_id": builtInClientID,
|
||||||
|
"built_in_enabled": builtInEnabled, "built_in_gateway_ready": issuerRoute,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package identityprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"vpn-platform/backend/internal/nginxgateway"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Provider) Status(c *gin.Context) {
|
||||||
|
cfg, err := current()
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(500, gin.H{"error": "读取身份配置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, tlsError := issuer()
|
||||||
|
if tlsError != nil {
|
||||||
|
base = ""
|
||||||
|
}
|
||||||
|
issuerRoute, _ := nginxgateway.AuthRouteReady(0)
|
||||||
|
callback, vpnCallback := "", ""
|
||||||
|
if base != "" {
|
||||||
|
callback, vpnCallback = base+"/feishu/callback", strings.TrimSuffix(base, "/id")+"/oauth2/callback"
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"enabled": cfg.Enabled, "app_id": cfg.AppID, "app_secret_configured": cfg.AppSecret != "",
|
||||||
|
"tenant_key": cfg.TenantKey, "client_id": cfg.ClientID, "client_secret_configured": cfg.ClientSecret != "",
|
||||||
|
"issuer": base, "feishu_callback": callback, "vpn_callback": vpnCallback,
|
||||||
|
"tls_ready": tlsError == nil, "gateway_ready": issuerRoute,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Configure(c *gin.Context) {
|
||||||
|
if c.Request.TLS == nil {
|
||||||
|
peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||||
|
if err != nil || net.ParseIP(peer) == nil || !net.ParseIP(peer).IsLoopback() || c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
AppSecret string `json:"app_secret"`
|
||||||
|
TenantKey string `json:"tenant_key"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "配置格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.AppID = strings.TrimSpace(req.AppID)
|
||||||
|
req.TenantKey = strings.TrimSpace(req.TenantKey)
|
||||||
|
if len(req.AppID) > 128 || strings.ContainsAny(req.AppID, " \r\n\t") || (req.Enabled && (req.AppID == "" || !validTenant(req.TenantKey))) {
|
||||||
|
c.JSON(400, gin.H{"error": "请输入有效的飞书 App ID 和租户标识"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Enabled {
|
||||||
|
if _, err := issuer(); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg, err := current()
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(500, gin.H{"error": "读取身份配置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.AppSecret != "" {
|
||||||
|
if len(req.AppSecret) > 512 {
|
||||||
|
c.JSON(400, gin.H{"error": "密钥过长"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.AppSecret, err = store.EncryptSecret(req.AppSecret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "密钥保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Enabled && cfg.AppSecret == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "飞书 App Secret 未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var newClientSecret string
|
||||||
|
if cfg.ClientID == "" {
|
||||||
|
newClientSecret, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "凭据生成失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ClientID = "opsx-vpn"
|
||||||
|
cfg.ClientSecret, err = store.EncryptSecret(newClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "凭据保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Enabled {
|
||||||
|
if _, _, err := signingKey(); err != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "签名密钥初始化失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.ID, cfg.AppID, cfg.TenantKey, cfg.Enabled = 1, req.AppID, req.TenantKey, req.Enabled
|
||||||
|
if err := store.DB.Save(&cfg).Error; err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "身份配置保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Audit(c.GetString("username"), "identity_provider_configured", "feishu", "")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "client_id": cfg.ClientID})
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package identityprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("VPN_DATA_DIR", dir)
|
||||||
|
if err := store.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
server := gin.New()
|
||||||
|
p := New()
|
||||||
|
server.PUT("/config", func(c *gin.Context) { c.Set("username", "admin"); p.Configure(c) })
|
||||||
|
server.GET("/config", p.Status)
|
||||||
|
body := []byte(`{"app_id":"cli_test","app_secret":"feishu-private-value","tenant_key":"tenant_1","enabled":false}`)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body))
|
||||||
|
request.RemoteAddr = "127.0.0.1:12345"
|
||||||
|
request.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
server.ServeHTTP(resp, request)
|
||||||
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") {
|
||||||
|
t.Fatalf("unsafe response: %d %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
rejected := httptest.NewRecorder()
|
||||||
|
server.ServeHTTP(rejected, httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body)))
|
||||||
|
if rejected.Code != http.StatusForbidden {
|
||||||
|
t.Fatal("plaintext management request accepted credentials")
|
||||||
|
}
|
||||||
|
cfg, err := current()
|
||||||
|
if err != nil || cfg.AppSecret == "feishu-private-value" {
|
||||||
|
t.Fatal("app secret stored in plaintext")
|
||||||
|
}
|
||||||
|
plain, err := store.DecryptSecret(cfg.AppSecret)
|
||||||
|
if err != nil || plain != "feishu-private-value" {
|
||||||
|
t.Fatal("app secret not recoverable")
|
||||||
|
}
|
||||||
|
var saved map[string]any
|
||||||
|
if json.Unmarshal(resp.Body.Bytes(), &saved) != nil || saved["client_id"] == "" || saved["client_secret"] != nil {
|
||||||
|
t.Fatal("client secret must remain server-side")
|
||||||
|
}
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
server.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/config", nil))
|
||||||
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") || strings.Contains(resp.Body.String(), cfg.ClientSecret) {
|
||||||
|
t.Fatal("secret leaked from status")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package identityprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestState struct {
|
||||||
|
Redirect string `json:"redirect"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
}
|
||||||
|
type codeData struct {
|
||||||
|
requestState
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type accessData struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func noStore(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Header("Pragma", "no-cache") }
|
||||||
|
func oidcError(c *gin.Context, status int) {
|
||||||
|
noStore(c)
|
||||||
|
c.JSON(status, gin.H{"error": "invalid_request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Discovery(c *gin.Context) {
|
||||||
|
_, base, err := enabled()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noStore(c)
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"issuer": base, "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token",
|
||||||
|
"userinfo_endpoint": base + "/userinfo", "jwks_uri": base + "/jwks",
|
||||||
|
"response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"},
|
||||||
|
"grant_types_supported": []string{"authorization_code"},
|
||||||
|
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) JWKS(c *gin.Context) {
|
||||||
|
if _, _, err := enabled(); err != nil {
|
||||||
|
oidcError(c, 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, kid, err := signingKey()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noStore(c)
|
||||||
|
c.JSON(200, gin.H{"keys": []gin.H{{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid,
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Authorize(c *gin.Context) {
|
||||||
|
cfg, base, err := enabled()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirect := c.Query("redirect_uri")
|
||||||
|
domain := strings.TrimSuffix(strings.TrimPrefix(base, "https://"), "/id")
|
||||||
|
if !validateRedirect(redirect, domain) || c.Query("client_id") != cfg.ClientID || c.Query("response_type") != "code" || !strings.Contains(" "+c.Query("scope")+" ", " openid ") {
|
||||||
|
oidcError(c, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challenge, method := c.Query("code_challenge"), c.Query("code_challenge_method")
|
||||||
|
if (challenge != "" && (method != "S256" || len(challenge) != 43)) || (challenge == "" && method != "") || len(c.Query("state")) > 1024 || len(c.Query("nonce")) > 1024 {
|
||||||
|
oidcError(c, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending := requestState{Redirect: redirect, ClientID: cfg.ClientID, State: c.Query("state"), Nonce: c.Query("nonce"), Challenge: challenge}
|
||||||
|
if err := saveGrant("state", state, pending, 5*time.Minute); err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upstream := feishuAuthorize + "?" + url.Values{
|
||||||
|
"client_id": {cfg.AppID}, "redirect_uri": {base + "/feishu/callback"}, "response_type": {"code"}, "scope": {"user_profile"}, "state": {state},
|
||||||
|
}.Encode()
|
||||||
|
noStore(c)
|
||||||
|
c.Redirect(http.StatusFound, upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstreamClient = &http.Client{Timeout: 8 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
|
||||||
|
func postFeishuToken(ctx context.Context, appID, appSecret, code, callback string) (string, error) {
|
||||||
|
payload, _ := json.Marshal(gin.H{"grant_type": "authorization_code", "client_id": appID, "client_secret": appSecret, "code": code, "redirect_uri": callback})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", feishuToken, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := upstreamClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
return "", errors.New("upstream token rejected")
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Code *int `json:"code"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Data struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&data); err != nil || data.Code == nil || *data.Code != 0 {
|
||||||
|
return "", errors.New("upstream token invalid")
|
||||||
|
}
|
||||||
|
if data.AccessToken != "" {
|
||||||
|
return data.AccessToken, nil
|
||||||
|
}
|
||||||
|
if data.Data.AccessToken != "" {
|
||||||
|
return data.Data.AccessToken, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("upstream access token missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFeishuUser(ctx context.Context, token string) (accessData, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", feishuUserInfo, nil)
|
||||||
|
if err != nil {
|
||||||
|
return accessData{}, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
response, err := upstreamClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return accessData{}, "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
return accessData{}, "", errors.New("upstream user rejected")
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Code *int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
TenantKey string `json:"tenant_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil || result.Code == nil || *result.Code != 0 || result.Data.OpenID == "" || result.Data.TenantKey == "" {
|
||||||
|
return accessData{}, "", errors.New("upstream user missing stable identity")
|
||||||
|
}
|
||||||
|
return accessData{Subject: result.Data.TenantKey + ":" + result.Data.OpenID, Name: result.Data.Name}, result.Data.TenantKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) FeishuCallback(c *gin.Context) {
|
||||||
|
cfg, base, err := enabled()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var state requestState
|
||||||
|
if err := consumeGrant("state", c.Query("state"), &state); err != nil || c.Query("error") != "" || c.Query("code") == "" || len(c.Query("code")) > 2048 {
|
||||||
|
oidcError(c, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, err := store.DecryptSecret(cfg.AppSecret)
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := postFeishuToken(c.Request.Context(), cfg.AppID, secret, c.Query("code"), base+"/feishu/callback")
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 502)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, tenant, err := fetchFeishuUser(c.Request.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 502)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tenant != cfg.TenantKey {
|
||||||
|
oidcError(c, 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grant := codeData{requestState: state, Subject: user.Subject, Name: user.Name}
|
||||||
|
if err := saveGrant("code", code, grant, 2*time.Minute); err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(state.Redirect)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("code", code)
|
||||||
|
if state.State != "" {
|
||||||
|
q.Set("state", state.State)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
noStore(c)
|
||||||
|
c.Redirect(http.StatusFound, u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearer(c *gin.Context, cfg model.IdentityProvider) bool {
|
||||||
|
id, secret, ok := c.Request.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
id, secret = c.PostForm("client_id"), c.PostForm("client_secret")
|
||||||
|
}
|
||||||
|
stored, err := store.DecryptSecret(cfg.ClientSecret)
|
||||||
|
return err == nil && subtle.ConstantTimeCompare([]byte(id), []byte(cfg.ClientID)) == 1 && subtle.ConstantTimeCompare([]byte(secret), []byte(stored)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIDToken(key *rsa.PrivateKey, kid, base string, cfg model.IdentityProvider, grant codeData) (string, error) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
header, _ := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": kid})
|
||||||
|
claims := gin.H{"iss": base, "aud": cfg.ClientID, "sub": grant.Subject, "iat": now, "exp": now + 600, "auth_time": now}
|
||||||
|
if grant.Nonce != "" {
|
||||||
|
claims["nonce"] = grant.Nonce
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(claims)
|
||||||
|
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body)
|
||||||
|
hash := sha256.Sum256([]byte(unsigned))
|
||||||
|
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Token(c *gin.Context) {
|
||||||
|
cfg, base, err := enabled()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noStore(c)
|
||||||
|
if !bearer(c, cfg) || c.PostForm("grant_type") != "authorization_code" {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid_client"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var grant codeData
|
||||||
|
if err := consumeGrant("code", c.PostForm("code"), &grant); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid_grant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if grant.ClientID != cfg.ClientID || grant.Redirect != c.PostForm("redirect_uri") {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid_grant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if grant.Challenge != "" {
|
||||||
|
verifier := c.PostForm("code_verifier")
|
||||||
|
if len(verifier) < 43 || len(verifier) > 128 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid_grant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(grant.Challenge), []byte(base64.RawURLEncoding.EncodeToString(hash[:]))) != 1 {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid_grant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key, kid, err := signingKey()
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idToken, err := signIDToken(key, kid, base, cfg, grant)
|
||||||
|
if err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
access, err := randomToken()
|
||||||
|
if err != nil || saveGrant("access", access, accessData{Subject: grant.Subject, Name: grant.Name}, 10*time.Minute) != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"access_token": access, "id_token": idToken, "token_type": "Bearer", "expires_in": 600, "scope": "openid"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) UserInfo(c *gin.Context) {
|
||||||
|
if _, _, err := enabled(); err != nil {
|
||||||
|
oidcError(c, 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
if token == c.GetHeader("Authorization") || len(token) < 32 {
|
||||||
|
oidcError(c, 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var grant model.IdentityGrant
|
||||||
|
if err := store.DB.Where("kind = ? AND digest = ? AND expires_at > ?", "access", digest(token), time.Now()).First(&grant).Error; err != nil {
|
||||||
|
oidcError(c, 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user accessData
|
||||||
|
if json.Unmarshal([]byte(grant.Payload), &user) != nil {
|
||||||
|
oidcError(c, 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noStore(c)
|
||||||
|
c.JSON(200, gin.H{"sub": user.Subject, "name": user.Name})
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Package identityprovider implements a deliberately small, single-tenant OIDC
|
||||||
|
// authorization-code issuer backed by Feishu's web authorization API.
|
||||||
|
package identityprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
"vpn-platform/backend/internal/tlsservice"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
feishuAuthorize = "https://accounts.feishu.cn/open-apis/authen/v1/authorize"
|
||||||
|
feishuToken = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"
|
||||||
|
feishuUserInfo = "https://open.feishu.cn/open-apis/authen/v1/user_info"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
func New() *Provider { return &Provider{} }
|
||||||
|
|
||||||
|
var limits = struct {
|
||||||
|
sync.Mutex
|
||||||
|
hits map[string]struct {
|
||||||
|
count int
|
||||||
|
reset time.Time
|
||||||
|
}
|
||||||
|
}{hits: make(map[string]struct {
|
||||||
|
count int
|
||||||
|
reset time.Time
|
||||||
|
})}
|
||||||
|
|
||||||
|
// Limit browser/token attempts without retaining request bodies or credentials.
|
||||||
|
func RateLimit() func(*gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
limits.Lock()
|
||||||
|
if len(limits.hits) > 10000 {
|
||||||
|
for key, item := range limits.hits {
|
||||||
|
if time.Now().After(item.reset) {
|
||||||
|
delete(limits.hits, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(limits.hits) > 10000 {
|
||||||
|
limits.Unlock()
|
||||||
|
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peer, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
|
||||||
|
if ip := net.ParseIP(peer); ip != nil && ip.IsLoopback() {
|
||||||
|
if realIP := net.ParseIP(c.GetHeader("X-Real-IP")); realIP != nil {
|
||||||
|
peer = realIP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := peer + ":" + c.FullPath()
|
||||||
|
item := limits.hits[key]
|
||||||
|
if time.Now().After(item.reset) {
|
||||||
|
item.count = 0
|
||||||
|
item.reset = time.Now().Add(time.Minute)
|
||||||
|
}
|
||||||
|
item.count++
|
||||||
|
limits.hits[key] = item
|
||||||
|
blocked := item.count > 40
|
||||||
|
limits.Unlock()
|
||||||
|
if blocked {
|
||||||
|
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legacy management port is still reachable over HTTP. Never receive
|
||||||
|
// authorization codes or credentials through it from a remote client.
|
||||||
|
func SecureGateway() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||||
|
if err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
for range ticker.C {
|
||||||
|
if store.DB != nil {
|
||||||
|
store.DB.Where("expires_at < ?", time.Now().Add(-time.Hour)).Delete(&model.IdentityGrant{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuer() (string, error) {
|
||||||
|
status := tlsservice.Status()
|
||||||
|
if !status.Valid || status.RestartRequired || status.Domain == "" {
|
||||||
|
return "", errors.New("需要先启用并验证公网 HTTPS 域名")
|
||||||
|
}
|
||||||
|
return "https://" + status.Domain + "/id", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func current() (model.IdentityProvider, error) {
|
||||||
|
var cfg model.IdentityProvider
|
||||||
|
if store.DB == nil {
|
||||||
|
return cfg, errors.New("identity database not initialized")
|
||||||
|
}
|
||||||
|
err := store.DB.First(&cfg, 1).Error
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabled() (model.IdentityProvider, string, error) {
|
||||||
|
cfg, err := current()
|
||||||
|
if err != nil || !cfg.Enabled || cfg.AppID == "" || cfg.TenantKey == "" || cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||||
|
return cfg, "", errors.New("身份服务未配置或未启用")
|
||||||
|
}
|
||||||
|
base, err := issuer()
|
||||||
|
return cfg, base, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientForIssuer is an internal-only bridge to the OpenVPN plugin. It never
|
||||||
|
// exposes the generated confidential-client secret through a management API.
|
||||||
|
func ClientForIssuer(expectedIssuer, clientID string) (string, bool) {
|
||||||
|
cfg, base, err := enabled()
|
||||||
|
if err != nil || base != expectedIssuer || cfg.ClientID != clientID {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
secret, err := store.DecryptSecret(cfg.ClientSecret)
|
||||||
|
return secret, err == nil && secret != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func PublicClient() (string, string, bool) {
|
||||||
|
cfg, base, err := enabled()
|
||||||
|
return base, cfg.ClientID, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func digest(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveGrant(kind, token string, data any, duration time.Duration) error {
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return store.DB.Create(&model.IdentityGrant{Kind: kind, Digest: digest(token), Payload: string(payload), ExpiresAt: time.Now().Add(duration)}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeGrant(kind, token string, output any) error {
|
||||||
|
if len(token) < 32 || len(token) > 256 {
|
||||||
|
return errors.New("invalid grant")
|
||||||
|
}
|
||||||
|
return store.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var grant model.IdentityGrant
|
||||||
|
if err := tx.Where("kind = ? AND digest = ? AND consumed = ? AND expires_at > ?", kind, digest(token), false, time.Now()).First(&grant).Error; err != nil {
|
||||||
|
return errors.New("expired or used grant")
|
||||||
|
}
|
||||||
|
result := tx.Model(&model.IdentityGrant{}).Where("id = ? AND consumed = ?", grant.ID, false).Update("consumed", true)
|
||||||
|
if result.Error != nil || result.RowsAffected != 1 {
|
||||||
|
return errors.New("expired or used grant")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(grant.Payload), output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func signingKey() (*rsa.PrivateKey, string, error) {
|
||||||
|
dir := filepath.Join(service.BaseDir(), "identity")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "signing-key.pem")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
key, genErr := rsa.GenerateKey(rand.Reader, 3072)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, "", genErr
|
||||||
|
}
|
||||||
|
encoded, encErr := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, "", encErr
|
||||||
|
}
|
||||||
|
file, openErr := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if errors.Is(openErr, os.ErrExist) {
|
||||||
|
return signingKey()
|
||||||
|
}
|
||||||
|
if openErr != nil {
|
||||||
|
return nil, "", openErr
|
||||||
|
}
|
||||||
|
_, writeErr := file.Write(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))
|
||||||
|
closeErr := file.Close()
|
||||||
|
if writeErr != nil || closeErr != nil {
|
||||||
|
return nil, "", fmt.Errorf("write signing key: %v %v", writeErr, closeErr)
|
||||||
|
}
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm()&0o077 != 0 {
|
||||||
|
return nil, "", errors.New("签名密钥权限不安全,必须为 0600")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, "", errors.New("签名密钥无效")
|
||||||
|
}
|
||||||
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
key, ok := parsed.(*rsa.PrivateKey)
|
||||||
|
if !ok || key.N.BitLen() < 3072 {
|
||||||
|
return nil, "", errors.New("签名密钥强度不足")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(x509.MarshalPKCS1PublicKey(&key.PublicKey))
|
||||||
|
return key, base64.RawURLEncoding.EncodeToString(sum[:8]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRedirect(raw, domain string) bool {
|
||||||
|
return raw == "https://"+domain+"/oauth2/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTenant(value string) bool {
|
||||||
|
return value != "" && len(value) <= 128 && !strings.ContainsAny(value, " \r\n\t")
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package identityprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type transportFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f transportFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||||
|
|
||||||
|
func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) {
|
||||||
|
previous := upstreamClient
|
||||||
|
defer func() { upstreamClient = previous }()
|
||||||
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.String() == feishuToken {
|
||||||
|
if req.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatal("Feishu token exchange must use JSON")
|
||||||
|
}
|
||||||
|
var body map[string]string
|
||||||
|
if json.NewDecoder(req.Body).Decode(&body) != nil || body["client_id"] != "app-id" || body["redirect_uri"] != "https://vpn.example.com/id/feishu/callback" {
|
||||||
|
t.Fatal("wrong Feishu exchange request")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"feishu-token"}`)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
if req.URL.String() != feishuUserInfo || req.Header.Get("Authorization") != "Bearer feishu-token" {
|
||||||
|
t.Fatal("unexpected Feishu user request")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"open_id":"ou_1","tenant_key":"tenant_1","name":"Alice"}}`)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
token, err := postFeishuToken(context.Background(), "app-id", "app-secret", "feishu-code", "https://vpn.example.com/id/feishu/callback")
|
||||||
|
if err != nil || token != "feishu-token" {
|
||||||
|
t.Fatalf("exchange: %q %v", token, err)
|
||||||
|
}
|
||||||
|
user, tenant, err := fetchFeishuUser(context.Background(), token)
|
||||||
|
if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" {
|
||||||
|
t.Fatalf("identity: %+v %s %v", user, tenant, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuExchangeRejectsMalformedSuccess(t *testing.T) {
|
||||||
|
previous := upstreamClient
|
||||||
|
defer func() { upstreamClient = previous }()
|
||||||
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"access_token":"fake","data":{"open_id":"ou_1","tenant_key":"tenant_1"}}`)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
if _, err := postFeishuToken(context.Background(), "id", "secret", "code", "https://vpn.example.com/id/feishu/callback"); err == nil {
|
||||||
|
t.Fatal("missing Feishu success code accepted")
|
||||||
|
}
|
||||||
|
if _, _, err := fetchFeishuUser(context.Background(), "fake"); err == nil {
|
||||||
|
t.Fatal("missing Feishu user response code accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantSingleUseAndSignedToken(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("VPN_DATA_DIR", dir)
|
||||||
|
if err := store.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1"}
|
||||||
|
if err := saveGrant("code", code, grant, time.Minute); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var used codeData
|
||||||
|
if err := consumeGrant("code", code, &used); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := consumeGrant("code", code, &used); err == nil {
|
||||||
|
t.Fatal("grant replay accepted")
|
||||||
|
}
|
||||||
|
key, kid, err := signingKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jwt, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, used)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(jwt, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatal("invalid JWT")
|
||||||
|
}
|
||||||
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||||
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["nonce"] != "nonce-1" || claims["aud"] != "opsx-vpn" {
|
||||||
|
t.Fatalf("unexpected claims: %v", claims)
|
||||||
|
}
|
||||||
|
if info, err := filepath.Glob(filepath.Join(dir, "identity", "*.pem")); err != nil || len(info) != 1 {
|
||||||
|
t.Fatal("missing signing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) {
|
||||||
|
if !validateRedirect("https://vpn.example.com/oauth2/callback", "vpn.example.com") {
|
||||||
|
t.Fatal("valid callback rejected")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"http://vpn.example.com/oauth2/callback", "https://vpn.example.com.evil/oauth2/callback", "https://vpn.example.com/oauth2/callback/extra", "https://vpn.example.com/oauth2/callback?next=evil"} {
|
||||||
|
if validateRedirect(bad, "vpn.example.com") {
|
||||||
|
t.Errorf("accepted callback %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := consumeGrant("state", "short", new(requestState)); err == nil {
|
||||||
|
t.Fatal("accepted short state")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,30 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// IdentityProvider is the opt-in, single-tenant Feishu OIDC issuer configuration.
|
||||||
|
// Credentials are encrypted with the platform key and never serialized in API responses.
|
||||||
|
type IdentityProvider struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AppID string `gorm:"size:128" json:"app_id"`
|
||||||
|
AppSecret string `gorm:"type:text" json:"-"`
|
||||||
|
TenantKey string `gorm:"size:128" json:"tenant_key"`
|
||||||
|
ClientID string `gorm:"size:128" json:"client_id"`
|
||||||
|
ClientSecret string `gorm:"type:text" json:"-"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityGrant stores only a hash of each browser state, authorization code,
|
||||||
|
// or access token. A consumed code cannot be exchanged a second time.
|
||||||
|
type IdentityGrant struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Kind string `gorm:"size:12;index"`
|
||||||
|
Digest string `gorm:"size:64;uniqueIndex"`
|
||||||
|
Payload string `gorm:"type:text"`
|
||||||
|
ExpiresAt time.Time `gorm:"index"`
|
||||||
|
Consumed bool `gorm:"default:false"`
|
||||||
|
}
|
||||||
|
|
||||||
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
|
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
|
||||||
type TrafficSample struct {
|
type TrafficSample struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -40,10 +40,19 @@ func routePattern(path string) string {
|
|||||||
// Render returns the HTTP-01 bootstrap site and the HTTPS site separately.
|
// Render returns the HTTP-01 bootstrap site and the HTTPS site separately.
|
||||||
// Enable the second site only after Certbot has issued the certificate.
|
// Enable the second site only after Certbot has issued the certificate.
|
||||||
func Render(domain string, routes gin.RoutesInfo) (httpSite, httpsSite string, err error) {
|
func Render(domain string, routes gin.RoutesInfo) (httpSite, httpsSite string, err error) {
|
||||||
|
return RenderWithAuth(domain, routes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderWithAuth optionally exposes a single local OpenVPN browser-auth service.
|
||||||
|
// All other unknown paths retain the existing 444 catch-all.
|
||||||
|
func RenderWithAuth(domain string, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
if !tlsservice.ValidDomain(domain) {
|
if !tlsservice.ValidDomain(domain) {
|
||||||
return "", "", fmt.Errorf("请输入有效的单个完整域名")
|
return "", "", fmt.Errorf("请输入有效的单个完整域名")
|
||||||
}
|
}
|
||||||
|
if authPort != 0 && (authPort < 1024 || authPort > 65535) {
|
||||||
|
return "", "", fmt.Errorf("认证端口无效")
|
||||||
|
}
|
||||||
httpSite = fmt.Sprintf(`# Install this HTTP site first. Certbot webroot: /var/lib/opsx-vpn/acme
|
httpSite = fmt.Sprintf(`# Install this HTTP site first. Certbot webroot: /var/lib/opsx-vpn/acme
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
@@ -84,10 +93,22 @@ server {
|
|||||||
}
|
}
|
||||||
b.WriteString(" location ~ ^/tunnels/[0-9]+/edit$ {\n" + upstream + " }\n")
|
b.WriteString(" location ~ ^/tunnels/[0-9]+/edit$ {\n" + upstream + " }\n")
|
||||||
b.WriteString(" location ~ ^/assets/[A-Za-z0-9_.-]+\\.(js|css|svg|png|woff2)$ {\n" + upstream + " }\n")
|
b.WriteString(" location ~ ^/assets/[A-Za-z0-9_.-]+\\.(js|css|svg|png|woff2)$ {\n" + upstream + " }\n")
|
||||||
|
// Public issuer endpoints are fixed: unknown /id/* requests still get 444.
|
||||||
|
for _, path := range []string{"/id/.well-known/openid-configuration", "/id/jwks", "/id/authorize", "/id/feishu/callback", "/id/token", "/id/userinfo"} {
|
||||||
|
fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream)
|
||||||
|
}
|
||||||
|
if authPort != 0 {
|
||||||
|
fmt.Fprintf(&b, " location ^~ /oauth2/ {\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", authPort)
|
||||||
|
}
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var paths []string
|
var paths []string
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
|
// The issuer locations above are deliberately enumerated. Gin also
|
||||||
|
// reports them as routes; emitting them twice makes nginx -t fail.
|
||||||
|
if strings.HasPrefix(route.Path, "/id/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !validRoute(route.Path) || seen[route.Path] {
|
if !validRoute(route.Path) || seen[route.Path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,3 +64,25 @@ func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) {
|
|||||||
t.Fatal("injected route accepted")
|
t.Fatal("injected route accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderIdentityAndSingleAuthPort(t *testing.T) {
|
||||||
|
_, site, err := RenderWithAuth("vpn.example.com", gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}, 19090)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, route := range []string{"location = /id/.well-known/openid-configuration", "location = /id/feishu/callback", "location = /id/token", "location ^~ /oauth2/", "proxy_pass http://127.0.0.1:19090;", "location / { return 444; }"} {
|
||||||
|
if !strings.Contains(site, route) {
|
||||||
|
t.Errorf("identity route missing: %s", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(site, "location ^~ /id/") || strings.Contains(site, "location ^~ /.well-known/") {
|
||||||
|
t.Fatal("identity routes must stay allowlisted")
|
||||||
|
}
|
||||||
|
if _, _, err := RenderWithAuth("vpn.example.com", nil, 80); err == nil {
|
||||||
|
t.Fatal("invalid auth port accepted")
|
||||||
|
}
|
||||||
|
_, withoutAuth, err := Render("vpn.example.com", nil)
|
||||||
|
if err != nil || strings.Contains(withoutAuth, "location ^~ /oauth2/") {
|
||||||
|
t.Fatal("auth component exposed without a configured port")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,58 @@ func Stage() string {
|
|||||||
return "http"
|
return "http"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthRouteReady reflects the installed managed site, not actual Feishu reachability.
|
||||||
|
func AuthRouteReady(port int) (bool, bool) {
|
||||||
|
data, err := os.ReadFile(sitePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), "location = /id/.well-known/openid-configuration"),
|
||||||
|
port > 0 && strings.Contains(string(data), fmt.Sprintf("proxy_pass http://127.0.0.1:%d;", port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAuth updates only the managed site after validation; a bad config is
|
||||||
|
// rolled back before returning. It never provisions a certificate or domain.
|
||||||
|
func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error {
|
||||||
|
if Running() {
|
||||||
|
return errors.New("公网入口操作正在进行")
|
||||||
|
}
|
||||||
|
if err := checkSites(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if Stage() != "https" {
|
||||||
|
return errors.New("请先启用 HTTPS 站点")
|
||||||
|
}
|
||||||
|
previous, err := os.ReadFile(sitePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(previous), "server_name "+domain+";") {
|
||||||
|
return errors.New("现有站点域名与配置不匹配")
|
||||||
|
}
|
||||||
|
httpSite, httpsSite, err := nginxconfig.RenderWithAuth(domain, routes, authPort)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeSite(httpSite + httpsSite); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rollback := func() {
|
||||||
|
_ = writeSite(string(previous))
|
||||||
|
_ = command(func(string) {}, 15*time.Second, "nginx", "-t")
|
||||||
|
_ = command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx")
|
||||||
|
}
|
||||||
|
if err := command(func(string) {}, 15*time.Second, "nginx", "-t"); err != nil {
|
||||||
|
rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx"); err != nil {
|
||||||
|
rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func command(emit func(string), timeout time.Duration, name string, args ...string) error {
|
func command(emit func(string), timeout time.Duration, name string, args ...string) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/identityprovider"
|
||||||
"vpn-platform/backend/internal/model"
|
"vpn-platform/backend/internal/model"
|
||||||
"vpn-platform/backend/internal/service"
|
"vpn-platform/backend/internal/service"
|
||||||
"vpn-platform/backend/internal/store"
|
"vpn-platform/backend/internal/store"
|
||||||
@@ -103,6 +104,9 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
|
|||||||
if service.Str(cfg, "oidc_client_secret") == "" {
|
if service.Str(cfg, "oidc_client_secret") == "" {
|
||||||
cfg["oidc_client_secret"] = old["oidc_client_secret"]
|
cfg["oidc_client_secret"] = old["oidc_client_secret"]
|
||||||
}
|
}
|
||||||
|
if secret, ok := identityprovider.ClientForIssuer(service.Str(cfg, "oidc_issuer"), service.Str(cfg, "oidc_client_id")); ok {
|
||||||
|
cfg["oidc_client_secret"] = secret
|
||||||
|
}
|
||||||
if service.Str(cfg, "oidc_client_secret") == "" {
|
if service.Str(cfg, "oidc_client_secret") == "" {
|
||||||
return fmt.Errorf("oidc_client_secret 必填;请从 OIDC 发行者对应的客户端凭据中复制 Client Secret")
|
return fmt.Errorf("oidc_client_secret 必填;请从 OIDC 发行者对应的客户端凭据中复制 Client Secret")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
|
|||||||
}
|
}
|
||||||
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
||||||
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
|
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
DB = db
|
DB = db
|
||||||
|
|||||||
+4
-4
@@ -51,9 +51,9 @@ sudo ss -tlnp | grep ':18080'
|
|||||||
|
|
||||||
## 飞书 / OIDC 登录入口
|
## 飞书 / OIDC 登录入口
|
||||||
|
|
||||||
`http://121.4.111.208:18080` 是当前管理界面示例,**既不是 OIDC issuer,也不是 OAuth 回调地址**。当前项目在 [OpenVPN OIDC 配置](../backend/internal/service/openvpn/openvpn.go)中要求 `oidc_issuer` 和 `oidc_base_url` 均为 HTTPS。issuer 指向提供标准 OIDC discovery 和 ID Token 的身份提供方(飞书若不支持,需使用 Keycloak / Authentik 等身份代理)。OpsX VPN 当前不签发 OIDC 令牌;经代理接入时,飞书 App ID/Secret 配在代理的飞书上游,向导的 Client ID/Secret 则来自代理中为 VPN 创建的 OIDC 客户端。
|
`http://121.4.111.208:18080` 是旧管理入口,**不是 OIDC issuer 或安全的凭据配置入口**。当前内置飞书身份服务启用后以现有可信 HTTPS 域名的 `/id` 为 issuer,提供发现文档、JWKS、授权、令牌和用户信息接口;飞书应用上游回调是 `https://<域名>/id/feishu/callback`,OpenVPN 认证组件的回调是 `https://<域名>/oauth2/callback`。管理端通过 HTTPS 保存飞书 App ID/Secret 与允许的 tenant_key,VPN Client Secret 系统生成、服务器内部传递,不通过管理 API 回显。发行者禁用时不能将管理域名充当 issuer。
|
||||||
|
|
||||||
认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
内置方案复用管理域名与证书;先在「设置 → 飞书身份」启用身份服务并同步公网路由,Nginx 仅允许固定 `/id/` 身份路径。创建并启动唯一一条使用当前域名的 OpenVPN OIDC 测试专线后再次同步,才会把 `/oauth2/` 转发到其本机认证组件 `127.0.0.1:19090+`。未启动专线时不会开放 `/oauth2/` 路由。不要把认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
||||||
|
|
||||||
该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com`,并选择 Go 或 Nginx 入口模式;`VPN_TLS_DOMAIN` 和 `VPN_TLS_MODE` 环境变量优先于页面配置。两种模式只能选一种来占用 TCP 80/443,保存配置并不会安装 Nginx、开放安全组或签发证书。
|
该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com`,并选择 Go 或 Nginx 入口模式;`VPN_TLS_DOMAIN` 和 `VPN_TLS_MODE` 环境变量优先于页面配置。两种模式只能选一种来占用 TCP 80/443,保存配置并不会安装 Nginx、开放安全组或签发证书。
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ Go 模式(默认)使用 `golang.org/x/crypto/acme/autocert` 监听 80/443,
|
|||||||
2026-10-22 生产实例已启用 `test-link.appx365.com`,证书有效至 2027-01-19,`certbot renew --dry-run --run-deploy-hooks --non-interactive` 模拟续签成功。当前管理服务仍在公网 `:18080` 监听,沿用 HTTP 地址的客户端可绕过 Nginx 的路径白名单;完成客户端地址迁移后,需限制 18080 仅回环访问。模拟续签执行钩子时 `nginx -t` 的正常 stderr 被 Certbot 记录为警告,但退出成功。
|
2026-10-22 生产实例已启用 `test-link.appx365.com`,证书有效至 2027-01-19,`certbot renew --dry-run --run-deploy-hooks --non-interactive` 模拟续签成功。当前管理服务仍在公网 `:18080` 监听,沿用 HTTP 地址的客户端可绕过 Nginx 的路径白名单;完成客户端地址迁移后,需限制 18080 仅回环访问。模拟续签执行钩子时 `nginx -t` 的正常 stderr 被 Certbot 记录为警告,但退出成功。
|
||||||
4. 页面保存入口模式为 Nginx,启动或重启 opsx-vpn 并设置 `VPN_LISTEN_ADDR=127.0.0.1:18080`;确认 `https://<域名>/api/health`、登录及各业务页面可用,未知路径断开,80 的挑战仍可读,然后收紧 18080 公网安全组。若现有业务必须从公网直连 18080,先迁移这些客户端。
|
4. 页面保存入口模式为 Nginx,启动或重启 opsx-vpn 并设置 `VPN_LISTEN_ADDR=127.0.0.1:18080`;确认 `https://<域名>/api/health`、登录及各业务页面可用,未知路径断开,80 的挑战仍可读,然后收紧 18080 公网安全组。若现有业务必须从公网直连 18080,先迁移这些客户端。
|
||||||
|
|
||||||
不要只开放 `/oauth2/callback`:独立的 OIDC 认证域名还要转发认证组件的完整登录流程,且应另为该域名申请证书。管理站点的路由预览不覆盖认证域名;真实认证域名和组件端口确认后单独配置。若代理和 VPN 主机分离,预览里的 `127.0.0.1` 指代理主机自身,应改成受限私网地址并保护传输链路。回退时先恢复原 Nginx 配置及 opsx-vpn 入口模式,运行 `nginx -t` 后重载,不删除 `data/` 或已有证书。
|
不要只开放 `/oauth2/callback`:认证组件还需要 `/oauth2/` 下的登录入口。内置身份服务方案通过设置页同步已托管的 Nginx 站点;外部发行者或独立认证域名仍须单独配置,不能直接复用管理站点路径。若代理和 VPN 主机分离,预览里的 `127.0.0.1` 指代理主机自身,应改成受限私网地址并保护传输链路。回退时恢复原 Nginx 配置,运行 `nginx -t` 后重载,不删除 `data/`、签名密钥或已有证书。
|
||||||
|
|
||||||
`/.well-known/openid-configuration` 属于发行者域名,不是认证入口上的 VPN 回调。不要只放行 `/oauth2/callback`:认证组件还需要发起登录等路由。认证入口使用独立域名,只反代给认证组件;如需按路径白名单返回 Nginx `444`,须先核对实际安装版本的完整路由,并保障 HTTP-01 验证路径。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
内置 issuer 的发现文档位于 `https://<域名>/id/.well-known/openid-configuration`,不属于 ACME 路径;Nginx 仅放行固定 `/id/` 身份端点、HTTP-01 验证路径及已配置专线的 `/oauth2/` 前缀,其余保持 444。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
||||||
|
|
||||||
启用飞书认证前,需安装并验证 `openvpn-auth-oauth2`、准备身份提供方凭据、配置 HTTPS 反代,并用支持 WebAuth 的客户端联调。现有 LDAP 拨号用户不会自动绑定飞书用户。
|
启用飞书认证前,需安装并验证 `openvpn-auth-oauth2`、准备身份提供方凭据、配置 HTTPS 反代,并用支持 WebAuth 的客户端联调。现有 LDAP 拨号用户不会自动绑定飞书用户。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# SDD-007:内置飞书 OIDC 发行者
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 级别 | S3(公网身份入口、签名密钥、生产网关变更) |
|
||||||
|
| 状态 | 实施中,尚未验证真实飞书登录 |
|
||||||
|
| 日期 | 2026-10-22 |
|
||||||
|
|
||||||
|
## 1. 现状与目标
|
||||||
|
|
||||||
|
当前 OpenVPN OIDC 模式调用 `openvpn-auth-oauth2`,要求外部标准 issuer;`/api/tunnels/openvpn/oidc-readiness` 仅检测已安装插件与 HTTPS 管理入口。Nginx 只允许管理页面/API,其他路径返回 444;现有 LDAP 与密码专线均应保留。
|
||||||
|
|
||||||
|
增加单租户、单机部署的 Go `/id` 身份模块:飞书网页 OAuth 授权后,向插件提供受限的 OIDC authorization code 流程。仅已启用的管理员配置可提供发现文档、JWKS 与发码;不把登录成功视为自动获得 VPN 权限。沿用现有 HTTPS 域名及数据库,不引入外部身份服务器。
|
||||||
|
|
||||||
|
## 2. 功能与约束
|
||||||
|
|
||||||
|
- FR-01:管理员配置飞书 App ID/Secret(密钥不回显)、允许的 tenant key;系统生成并仅在服务端使用 VPN OIDC Client Secret,管理页面只显示 Client ID;复用现有 TLS 域名。
|
||||||
|
- FR-02:提供 discovery、JWKS、authorize、飞书回调、token、userinfo;只接收登记的一个 HTTPS 回调地址,发放一次性短时 code;签发 RS256 ID Token。飞书返回的 open_id 与 tenant_key 组合成稳定 subject。
|
||||||
|
- FR-03:OpenVPN 插件通过机密客户端交换授权码;客户端凭据、回调、nonce、state 绑定,存在 code_challenge 时校验 S256;不接受外部指定的上游 URL。
|
||||||
|
- FR-04:前端展示 issuer、飞书回调及 VPN 回调、准备状态,允许选择内置 issuer 填写专线配置,但不覆盖既有配置。
|
||||||
|
- FR-05:Nginx 仅允许同域 `/id/` 的固定身份端点转发到管理进程;`/oauth2/` 转发到单条已启动的认证组件(仅就绪后启用);其他未知路径保持 444。
|
||||||
|
- NFR-01:签名密钥 0600、密钥材料不可回显,令牌与随机状态只保存哈希或受限数据;严格 TLS、过期时间、状态原子消费、请求限流及日志脱敏。
|
||||||
|
- NFR-02:未启用或配置不完整时不发布可用 issuer,不破坏现有 OpenVPN/LDAP/管理入口。
|
||||||
|
|
||||||
|
## 3. 方案与契约
|
||||||
|
|
||||||
|
发行者建议 `https://<现有域名>/id`;只按 issuer 文档发布实际 URL,不假设根目录 discovery。飞书上游回调 `https://<现有域名>/id/feishu/callback`;插件回调 `https://<现有域名>/oauth2/callback`。飞书 App ID/Secret 只用于上游;插件使用系统生成的独立 VPN 凭据。数据加密利用现有 `secret.key`;RSA 签名私钥独立文件,备份必须同时包含数据库和密钥。
|
||||||
|
|
||||||
|
独立 `internal/identityprovider` 包承载协议逻辑和存储;`api` 管理路由只对管理员开放;Nginx 模板明确路由优先级。JWT 仅签发 `openid` 授权范围,不在令牌里包含飞书访问令牌。用户授权初期先采用飞书应用可用范围 + 明确租户约束;后续如需逐用户允许名单再增加独立配置。
|
||||||
|
|
||||||
|
接口参考 `/Users/logan/devops/demo/id/Feishu-OIDC-Workers` 的飞书官方端点定义与 JSON 换码格式;授权状态和客户端凭据隔离参考 `/Users/logan/devops/demo/id/dex` 的协议边界。没有复制其实现;Workers 示例把原始飞书 code 转交客户端的做法不用于本项目。
|
||||||
|
|
||||||
|
## 4. 风险与验收
|
||||||
|
|
||||||
|
| 风险 | 防护 / 恢复 |
|
||||||
|
|---|---|
|
||||||
|
| 伪造授权回调 / 重放 code | 随机 state 与 code、短有效期、数据库原子消费、精确 redirect URI 和客户端绑定 |
|
||||||
|
| 泄露私钥或飞书密钥 | 0600 与 AES-GCM、API 不回显、部署时不传入对话中曾公开的旧密钥 |
|
||||||
|
| 多专线共用 `/oauth2/` | 网关只选择唯一运行中的同域 OIDC 专线,冲突时不启用转发 |
|
||||||
|
| 网关修改影响管理入口 | 先预览、备份现有站点、`nginx -t` 后 reload;失败恢复旧站点 |
|
||||||
|
|
||||||
|
验收:禁用与错误配置拒绝授权;模拟飞书服务测试完整码流、重放、错误租户、错误回调/nonce/PKCE、JWT 验签;真实飞书应用授权和 OpenVPN WebAuth 需单独联调。发布顺序:完成本地测试 → 部署但默认禁用 → 管理员轮换并保存飞书凭据 → 预览并启用网关 → 测试专线正/负向登录 → 再决定生产切换。恢复:禁用 issuer、恢复 Nginx 旧站点和旧二进制;不删除现有专线或密钥。
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
|
<el-alert type="warning" :closable="false" :title="$t('oidcGuide.providerTip')" class="guide-alert" />
|
||||||
|
<el-button v-if="readiness?.built_in_enabled" type="primary" plain class="guide-alert" @click="useBuiltIn">{{ $t('oidcGuide.useBuiltIn') }}</el-button>
|
||||||
|
<el-alert v-if="builtInSelected && !readiness?.built_in_gateway_ready" type="warning" :closable="false" :title="$t('oidcGuide.builtInGatewayPending')" class="guide-alert" />
|
||||||
<el-form-item :label="$t('oidcGuide.issuer')" required>
|
<el-form-item :label="$t('oidcGuide.issuer')" required>
|
||||||
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
|
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
|
||||||
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
|
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
|
||||||
@@ -24,10 +26,11 @@
|
|||||||
<el-input v-model.trim="config.oidc_client_id" />
|
<el-input v-model.trim="config.oidc_client_id" />
|
||||||
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
|
<div class="field-tip">{{ $t('oidcGuide.clientIdTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
|
<el-form-item v-if="!builtInSelected" :label="$t('oidcGuide.clientSecret')" :required="!editing">
|
||||||
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
|
<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>
|
<div class="field-tip">{{ $t('oidcGuide.secretTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-alert v-else type="info" :closable="false" :title="$t('oidcGuide.builtInSecret')" class="guide-alert" />
|
||||||
<el-form-item :label="$t('oidcGuide.baseUrl')" required>
|
<el-form-item :label="$t('oidcGuide.baseUrl')" required>
|
||||||
<el-input v-model.trim="config.oidc_base_url" :placeholder="suggestedBaseUrl || 'https://vpn-login.example.com'" />
|
<el-input v-model.trim="config.oidc_base_url" :placeholder="suggestedBaseUrl || 'https://vpn-login.example.com'" />
|
||||||
<el-button v-if="suggestedBaseUrl && config.oidc_base_url !== suggestedBaseUrl" class="reuse-domain" link type="primary" @click="config.oidc_base_url = suggestedBaseUrl">{{ $t('oidcGuide.reuseDomain') }}</el-button>
|
<el-button v-if="suggestedBaseUrl && config.oidc_base_url !== suggestedBaseUrl" class="reuse-domain" link type="primary" @click="config.oidc_base_url = suggestedBaseUrl">{{ $t('oidcGuide.reuseDomain') }}</el-button>
|
||||||
@@ -62,6 +65,13 @@ const props = defineProps({ config: { type: Object, required: true }, editing: {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const portLoading = ref(false)
|
const portLoading = ref(false)
|
||||||
const readiness = ref(null)
|
const readiness = ref(null)
|
||||||
|
const builtInSelected = computed(() => readiness.value?.built_in_enabled && props.config.oidc_issuer === readiness.value.built_in_issuer && props.config.oidc_client_id === readiness.value.built_in_client_id)
|
||||||
|
function useBuiltIn() {
|
||||||
|
props.config.oidc_issuer = readiness.value.built_in_issuer
|
||||||
|
props.config.oidc_client_id = readiness.value.built_in_client_id
|
||||||
|
props.config.oidc_client_secret = ''
|
||||||
|
props.config.oidc_provider = 'builtin'
|
||||||
|
}
|
||||||
async function loadReadiness() {
|
async function loadReadiness() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/tunnels/openvpn/oidc-readiness')
|
const { data } = await api.get('/tunnels/openvpn/oidc-readiness')
|
||||||
@@ -103,7 +113,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
|
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
|
||||||
const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
const guideStep = computed(() => !props.config.oidc_client_id ? 0
|
||||||
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing) ? 1
|
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing && !builtInSelected.value) ? 1
|
||||||
: !props.config.oidc_base_url ? 2 : 3)
|
: !props.config.oidc_base_url ? 2 : 3)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,8 @@
|
|||||||
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
|
"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.",
|
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
|
||||||
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
|
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
|
||||||
"providerTip": "A Feishu app alone is not an OIDC issuer. Verify that your provider publishes standard discovery and ID tokens; otherwise use an identity broker such as Keycloak or Authentik. OpsX VPN does not currently issue OIDC identity tokens.",
|
"providerTip": "Use the built-in Feishu issuer configured in Settings, or provide an external issuer with OIDC discovery and ID tokens. A Feishu app URL alone is not an issuer.",
|
||||||
|
"useBuiltIn": "Use built-in Feishu issuer", "builtInSecret": "The VPN client secret is supplied internally by the server and never returned on this page.", "builtInGatewayPending": "The public Nginx route for the built-in issuer is pending; sync it in Settings before testing.",
|
||||||
"issuer": "OIDC issuer URL",
|
"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.",
|
"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",
|
"clientId": "OIDC Client ID",
|
||||||
@@ -566,6 +567,14 @@
|
|||||||
"loadFailed": "Failed to load logs",
|
"loadFailed": "Failed to load logs",
|
||||||
"noAlerts": "No traffic alerts"
|
"noAlerts": "No traffic alerts"
|
||||||
},
|
},
|
||||||
|
"identity": {
|
||||||
|
"tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.",
|
||||||
|
"appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank to retain the secret; use a rotated secret for new setup", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Verify the enterprise tenant_key from Feishu user info", "enabled": "Enable built-in issuer",
|
||||||
|
"httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.",
|
||||||
|
"addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID",
|
||||||
|
"oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.", "routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.",
|
||||||
|
"syncGateway": "Sync identity gateway", "gatewaySynced": "Gateway routes updated", "gatewayFailed": "Gateway update failed", "loadFailed": "Could not load identity settings", "saved": "Identity settings saved", "saveFailed": "Could not save identity settings"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"pageDescription": "Manage account security, the public gateway, and server setup.",
|
"pageDescription": "Manage account security, the public gateway, and server setup.",
|
||||||
"securityTab": "Account Security", "gatewayTab": "Public Gateway", "hostTab": "Server Setup",
|
"securityTab": "Account Security", "gatewayTab": "Public Gateway", "hostTab": "Server Setup",
|
||||||
|
|||||||
@@ -244,7 +244,8 @@
|
|||||||
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
|
||||||
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
|
||||||
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
|
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
|
||||||
"providerTip": "这张飞书应用页面只证明已创建应用,不能作为 OIDC 发行者。先确认飞书是否提供标准发现文档和 ID Token;否则需要 Keycloak / Authentik 等身份代理。当前 opsx-vpn 不签发 OIDC 身份令牌。",
|
"providerTip": "可使用设置页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。",
|
||||||
|
"useBuiltIn": "使用内置飞书身份服务", "builtInSecret": "内置 VPN 客户端密钥由服务端自动提供,不在页面回显。", "builtInGatewayPending": "内置发行者尚未更新公网 Nginx 路由;到设置页“飞书身份”同步入口后再联调。",
|
||||||
"issuer": "OIDC 发行者地址",
|
"issuer": "OIDC 发行者地址",
|
||||||
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
|
||||||
"clientId": "OIDC Client ID",
|
"clientId": "OIDC Client ID",
|
||||||
@@ -566,6 +567,14 @@
|
|||||||
"loadFailed": "加载日志失败",
|
"loadFailed": "加载日志失败",
|
||||||
"noAlerts": "暂无流量告警"
|
"noAlerts": "暂无流量告警"
|
||||||
},
|
},
|
||||||
|
"identity": {
|
||||||
|
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
|
||||||
|
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "从飞书用户信息确认企业 tenant_key", "enabled": "启用内置发行者",
|
||||||
|
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
|
||||||
|
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",
|
||||||
|
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。",
|
||||||
|
"syncGateway": "同步身份公网路由", "gatewaySynced": "公网路由已更新", "gatewayFailed": "公网路由更新失败", "loadFailed": "读取身份配置失败", "saved": "身份配置已保存", "saveFailed": "保存身份配置失败"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"pageDescription": "管理个人安全、公网认证入口与服务器环境。",
|
"pageDescription": "管理个人安全、公网认证入口与服务器环境。",
|
||||||
"securityTab": "个人安全", "gatewayTab": "公网入口", "hostTab": "服务器环境",
|
"securityTab": "个人安全", "gatewayTab": "公网入口", "hostTab": "服务器环境",
|
||||||
|
|||||||
@@ -37,6 +37,35 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane v-if="auth.isAdmin()" :label="$t('identity.tab')" name="identity">
|
||||||
|
<div class="settings-column settings-column-wide">
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<template #header><b>{{ $t('identity.title') }}</b></template>
|
||||||
|
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
|
||||||
|
<el-form label-position="top" @submit.prevent="saveIdentity">
|
||||||
|
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
|
||||||
|
<el-form-item :label="$t('identity.tenant')"><el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
|
||||||
|
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
|
||||||
|
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
|
||||||
|
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
|
||||||
|
</el-card>
|
||||||
|
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||||
|
<template #header><b>{{ $t('identity.addresses') }}</b></template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
|
||||||
|
<el-button :loading="identityGatewayLoading" :disabled="!identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
|
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
|
||||||
<div class="settings-column settings-column-wide">
|
<div class="settings-column settings-column-wide">
|
||||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||||
@@ -162,12 +191,44 @@ const { t } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref('security')
|
const activeTab = ref('security')
|
||||||
const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security'
|
const visibleTab = (name) => auth.isAdmin() && ['identity', 'gateway', 'host'].includes(name) ? name : 'security'
|
||||||
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
|
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
|
||||||
function changeTab(name) {
|
function changeTab(name) {
|
||||||
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
|
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
|
||||||
}
|
}
|
||||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||||
|
const identityStatus = ref(null)
|
||||||
|
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
|
||||||
|
const identityLoading = ref(false)
|
||||||
|
const identitySaving = ref(false)
|
||||||
|
const identityGatewayLoading = ref(false)
|
||||||
|
async function loadIdentity() {
|
||||||
|
identityLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/system/identity')
|
||||||
|
identityStatus.value = data
|
||||||
|
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||||
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||||
|
finally { identityLoading.value = false }
|
||||||
|
}
|
||||||
|
async function saveIdentity() {
|
||||||
|
identitySaving.value = true
|
||||||
|
try {
|
||||||
|
await api.put('/system/identity', identityForm.value)
|
||||||
|
await loadIdentity()
|
||||||
|
ElMessage.success(t('identity.saved'))
|
||||||
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
|
||||||
|
finally { identitySaving.value = false }
|
||||||
|
}
|
||||||
|
async function syncIdentityGateway() {
|
||||||
|
identityGatewayLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/system/identity/gateway')
|
||||||
|
await loadIdentity()
|
||||||
|
ElMessage.success(t('identity.gatewaySynced'))
|
||||||
|
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
|
||||||
|
finally { identityGatewayLoading.value = false }
|
||||||
|
}
|
||||||
const pwdLoading = ref(false)
|
const pwdLoading = ref(false)
|
||||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||||
const mfaCanvas = ref(null)
|
const mfaCanvas = ref(null)
|
||||||
@@ -365,7 +426,7 @@ async function changePwd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness() }
|
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness(); loadIdentity() }
|
||||||
try {
|
try {
|
||||||
const status = await api.get('/mfa/status')
|
const status = await api.get('/mfa/status')
|
||||||
mfa.value.enabled = status.data.enabled
|
mfa.value.enabled = status.data.enabled
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async function load() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
|
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
|
||||||
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
||||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
|
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' || form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) {
|
||||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||||
}
|
}
|
||||||
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ function selectProtocol(key) {
|
|||||||
async function create() {
|
async function create() {
|
||||||
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
|
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
|
||||||
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
||||||
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_base_url']) {
|
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(form.value.config.oidc_provider === 'builtin' ? [] : ['oidc_client_secret'])]) {
|
||||||
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
||||||
}
|
}
|
||||||
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
||||||
|
|||||||
Reference in new issue
Block a user