Resolve Feishu names by verified open ID and scope VPN member access
This commit is contained in:
1 parent
578a58a129
commit
d4eddf7365
19 files changed
+837
-35
No files matched your search
@@ -313,6 +313,11 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
admin.POST("/system/fix", s.systemFix)
|
||||
admin.GET("/system/tls", s.tlsStatus)
|
||||
admin.GET("/system/identity", idp.Status)
|
||||
admin.GET("/system/identity/profiles", idp.Profiles)
|
||||
admin.POST("/system/identity/directory-check", idp.DirectoryCheck)
|
||||
admin.GET("/tunnels/:id/feishu-grants", idp.Grants)
|
||||
admin.POST("/tunnels/:id/feishu-grants", idp.AddGrant)
|
||||
admin.DELETE("/tunnels/:id/feishu-grants/:grant_id", idp.DeleteGrant)
|
||||
admin.POST("/system/identity/tenant", idp.QueryTenant)
|
||||
admin.PUT("/system/identity", idp.Configure)
|
||||
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// builtinTunnel finds the one tunnel whose OAuth callback owns this redirect.
|
||||
// Ambiguous or missing routes fail closed; a browser redirect alone grants nothing.
|
||||
func builtinTunnel(redirect, base string, cfg model.IdentityProvider) (model.Tunnel, map[string]interface{}, error) {
|
||||
u, err := url.Parse(redirect)
|
||||
if err != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/oauth2/callback" {
|
||||
return model.Tunnel{}, nil, errors.New("unknown callback")
|
||||
}
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Where("protocol = ?", "openvpn").Find(&tunnels).Error; err != nil {
|
||||
return model.Tunnel{}, nil, err
|
||||
}
|
||||
var match model.Tunnel
|
||||
var config map[string]interface{}
|
||||
for _, tunnel := range tunnels {
|
||||
data, err := store.TunnelConfig(&tunnel)
|
||||
if err != nil {
|
||||
return model.Tunnel{}, nil, err
|
||||
}
|
||||
if service.Str(data, "auth_mode") != "oidc" || service.Str(data, "oidc_provider") != "builtin" || service.Str(data, "oidc_issuer") != base || service.Str(data, "oidc_client_id") != cfg.ClientID || strings.TrimRight(service.Str(data, "oidc_base_url"), "/")+"/oauth2/callback" != redirect {
|
||||
continue
|
||||
}
|
||||
if match.ID != 0 {
|
||||
return model.Tunnel{}, nil, errors.New("ambiguous callback")
|
||||
}
|
||||
match, config = tunnel, data
|
||||
}
|
||||
if match.ID == 0 {
|
||||
return model.Tunnel{}, nil, errors.New("unknown callback")
|
||||
}
|
||||
return match, config, nil
|
||||
}
|
||||
|
||||
func admitted(redirect, base string, cfg model.IdentityProvider, subject string) (bool, error) {
|
||||
tunnel, config, err := builtinTunnel(redirect, base, cfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if raw, present := config["feishu_access_mode"]; present {
|
||||
if _, ok := raw.(string); !ok {
|
||||
return false, errors.New("invalid admission mode")
|
||||
}
|
||||
}
|
||||
switch service.Str(config, "feishu_access_mode") {
|
||||
case "", "all":
|
||||
return true, nil
|
||||
case "allowlist":
|
||||
var count int64
|
||||
err := store.DB.Model(&model.FeishuTunnelGrant{}).Where("tunnel_id = ? AND issuer = ? AND app_id = ? AND subject = ?", tunnel.ID, base, cfg.AppID, subject).Count(&count).Error
|
||||
return err == nil && count == 1, err
|
||||
default:
|
||||
return false, errors.New("invalid admission mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestDirectoryOnlyAcceptsMatchingOpenIDAndName(t *testing.T) {
|
||||
previous := upstreamClient
|
||||
t.Cleanup(func() { upstreamClient = previous })
|
||||
response := `{"code":0,"data":{"users":[{"user_id":"ou_123","name":"Li Ge"}]}}`
|
||||
fallback := `{"code":99991672}`
|
||||
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := `{"code":0,"tenant_access_token":"temporary"}`
|
||||
switch req.URL.String() {
|
||||
case feishuTenantToken:
|
||||
case feishuBasicBatch:
|
||||
if req.Method != http.MethodPost || req.Header.Get("Authorization") != "Bearer temporary" || req.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatal("invalid basic batch request")
|
||||
}
|
||||
var payload struct {
|
||||
UserIDs []string `json:"user_ids"`
|
||||
}
|
||||
if json.NewDecoder(req.Body).Decode(&payload) != nil || len(payload.UserIDs) != 1 || payload.UserIDs[0] != "ou_123" {
|
||||
t.Fatal("wrong open_id in basic batch")
|
||||
}
|
||||
body = response
|
||||
case feishuContactUser + "ou_123?user_id_type=open_id":
|
||||
body = fallback
|
||||
default:
|
||||
t.Fatalf("unexpected directory request: %s", req.URL)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
if name, source, err := fetchDirectoryNameWithSource(context.Background(), "cli_app", "secret", "ou_123"); err != nil || name != "Li Ge" || source != "basic_batch" {
|
||||
t.Fatalf("directory: %q %q %v", name, source, err)
|
||||
}
|
||||
for _, reply := range []string{`{"code":0,"data":{"users":[{"user_id":"ou_other","name":"Wrong"}]}}`, `{"code":0,"data":{"users":[{"name":"No ID"}]}}`, `{"code":99991672,"msg":"secret"}`} {
|
||||
response = reply
|
||||
if _, err := fetchDirectoryName(context.Background(), "cli_app", "secret", "ou_123"); err == nil {
|
||||
t.Fatal("accepted unverified directory identity")
|
||||
}
|
||||
}
|
||||
response = `{"code":99991672}`
|
||||
fallback = `{"code":0,"data":{"user":{"open_id":"ou_123","name":"Directory name"}}}`
|
||||
if name, source, err := fetchDirectoryNameWithSource(context.Background(), "cli_app", "secret", "ou_123"); err != nil || name != "Directory name" || source != "contact_user" {
|
||||
t.Fatalf("fallback: %q %q %v", name, source, err)
|
||||
}
|
||||
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == feishuTenantToken {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"tenant_access_token":"temporary"}`)), Header: make(http.Header)}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader(`{"code":99991672,"msg":"private upstream detail"}`)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := fetchDirectoryName(context.Background(), "cli_app", "secret", "ou_123"); err == nil || strings.Contains(err.Error(), "private upstream detail") {
|
||||
t.Fatal("directory permission failure leaked upstream details")
|
||||
}
|
||||
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { return nil, req.Context().Err() })}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := fetchDirectoryName(ctx, "cli_app", "secret", "ou_123"); err == nil {
|
||||
t.Fatal("cancelled directory request succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionDefaultsToAllAndScopesGrants(t *testing.T) {
|
||||
previous := store.DB
|
||||
t.Cleanup(func() {
|
||||
if store.DB != nil {
|
||||
if db, err := store.DB.DB(); err == nil {
|
||||
_ = db.Close()
|
||||
}
|
||||
}
|
||||
store.DB = previous
|
||||
})
|
||||
if err := store.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "https://vpn.example.com/id"
|
||||
redirect := "https://vpn.example.com/oauth2/callback"
|
||||
idp := model.IdentityProvider{AppID: "cli_current", ClientID: "vpn_client", TenantKey: "tenant"}
|
||||
tunnel := model.Tunnel{Name: "feishu", Protocol: "openvpn", Config: `{"auth_mode":"oidc","oidc_provider":"builtin","oidc_issuer":"https://vpn.example.com/id","oidc_client_id":"vpn_client","oidc_base_url":"https://vpn.example.com"}`}
|
||||
if err := store.DB.Create(&tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := admitted(redirect, base, idp, "tenant:ou_1"); err != nil || !ok {
|
||||
t.Fatalf("default: %v %v", ok, err)
|
||||
}
|
||||
if err := store.DB.Model(&tunnel).Update("config", `{"auth_mode":"oidc","oidc_provider":"builtin","oidc_issuer":"https://vpn.example.com/id","oidc_client_id":"vpn_client","oidc_base_url":"https://vpn.example.com","feishu_access_mode":true}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := admitted(redirect, base, idp, "tenant:ou_1"); err == nil || ok {
|
||||
t.Fatal("invalid policy admitted")
|
||||
}
|
||||
if err := store.DB.Model(&tunnel).Update("config", tunnel.Config).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := admitted("https://wrong.example.com/oauth2/callback", base, idp, "tenant:ou_1"); ok {
|
||||
t.Fatal("unknown callback admitted")
|
||||
}
|
||||
tunnel.Config = `{"auth_mode":"oidc","oidc_provider":"builtin","oidc_issuer":"https://vpn.example.com/id","oidc_client_id":"vpn_client","oidc_base_url":"https://vpn.example.com","feishu_access_mode":"allowlist"}`
|
||||
if err := store.DB.Save(&tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := admitted(redirect, base, idp, "tenant:ou_1"); err != nil || ok {
|
||||
t.Fatalf("unauthorized: %v %v", ok, err)
|
||||
}
|
||||
grant := model.FeishuTunnelGrant{TunnelID: tunnel.ID, Issuer: base, AppID: idp.AppID, Subject: "tenant:ou_1"}
|
||||
if err := store.DB.Create(&grant).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := admitted(redirect, base, idp, grant.Subject); err != nil || !ok {
|
||||
t.Fatalf("grant: %v %v", ok, err)
|
||||
}
|
||||
idp.AppID = "cli_other"
|
||||
if ok, _ := admitted(redirect, base, idp, grant.Subject); ok {
|
||||
t.Fatal("cross-app grant admitted")
|
||||
}
|
||||
idp.AppID = "cli_current"
|
||||
if err := store.DB.Delete(&grant).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := admitted(redirect, base, idp, grant.Subject); ok {
|
||||
t.Fatal("revoked grant admitted")
|
||||
}
|
||||
if err := store.DB.Migrator().DropTable(&model.FeishuTunnelGrant{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := admitted(redirect, base, idp, grant.Subject); ok || err == nil {
|
||||
t.Fatal("database failure admitted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const feishuContactUser = "https://open.feishu.cn/open-apis/contact/v3/users/"
|
||||
const feishuBasicBatch = "https://open.feishu.cn/open-apis/contact/v3/users/basic_batch?user_id_type=open_id"
|
||||
|
||||
func fetchDirectoryName(ctx context.Context, appID, appSecret, openID string) (string, error) {
|
||||
name, _, err := fetchDirectoryNameWithSource(ctx, appID, appSecret, openID)
|
||||
return name, err
|
||||
}
|
||||
|
||||
func fetchDirectoryNameWithSource(ctx context.Context, appID, appSecret, openID string) (string, string, error) {
|
||||
if !strings.HasPrefix(openID, "ou_") || len(openID) > 128 {
|
||||
return "", "", errors.New("invalid open_id")
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuTenantToken, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := upstreamClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var token struct {
|
||||
Code *int `json:"code"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token)
|
||||
resp.Body.Close()
|
||||
if err != nil || resp.StatusCode != http.StatusOK || token.Code == nil || *token.Code != 0 || token.TenantAccessToken == "" {
|
||||
return "", "", errors.New("tenant token unavailable")
|
||||
}
|
||||
if name, err := fetchBasicName(ctx, token.TenantAccessToken, openID); err == nil {
|
||||
return name, "basic_batch", nil
|
||||
}
|
||||
name, err := fetchContactName(ctx, token.TenantAccessToken, openID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return name, "contact_user", nil
|
||||
}
|
||||
|
||||
// basic_batch returns only core identity fields and does not require the user
|
||||
// to be in the application's directory visibility range. Never accept an
|
||||
// uncorrelated name from a batch response.
|
||||
func fetchBasicName(ctx context.Context, token, openID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string][]string{"user_ids": {openID}})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuBasicBatch, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := upstreamClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
Users []struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"users"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 {
|
||||
return "", errors.New("basic identity unavailable")
|
||||
}
|
||||
for _, user := range result.Data.Users {
|
||||
if user.UserID == openID && strings.TrimSpace(user.Name) != "" && len(user.Name) <= 256 {
|
||||
return strings.TrimSpace(user.Name), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("basic identity mismatch")
|
||||
}
|
||||
|
||||
func fetchContactName(ctx context.Context, token, openID string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feishuContactUser+url.PathEscape(openID)+"?user_id_type=open_id", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := upstreamClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
User struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result)
|
||||
if err != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 || result.Data.User.OpenID != openID || strings.TrimSpace(result.Data.User.Name) == "" || len(result.Data.User.Name) > 256 {
|
||||
return "", errors.New("directory identity unavailable")
|
||||
}
|
||||
return strings.TrimSpace(result.Data.User.Name), nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func managedTunnel(c *gin.Context) (model.Tunnel, model.IdentityProvider, string, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(400, gin.H{"error": "无效专线"})
|
||||
return model.Tunnel{}, model.IdentityProvider{}, "", false
|
||||
}
|
||||
var tunnel model.Tunnel
|
||||
if store.DB.First(&tunnel, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "专线不存在"})
|
||||
return tunnel, model.IdentityProvider{}, "", false
|
||||
}
|
||||
cfg, err := store.TunnelConfig(&tunnel)
|
||||
if err != nil || tunnel.Protocol != "openvpn" || service.Str(cfg, "auth_mode") != "oidc" || service.Str(cfg, "oidc_provider") != "builtin" {
|
||||
c.JSON(400, gin.H{"error": "仅支持内置飞书 OpenVPN 专线"})
|
||||
return tunnel, model.IdentityProvider{}, "", false
|
||||
}
|
||||
idp, base, err := enabled()
|
||||
if err != nil || service.Str(cfg, "oidc_issuer") != base || service.Str(cfg, "oidc_client_id") != idp.ClientID {
|
||||
c.JSON(409, gin.H{"error": "专线与当前飞书身份服务不匹配"})
|
||||
return tunnel, idp, base, false
|
||||
}
|
||||
return tunnel, idp, base, true
|
||||
}
|
||||
|
||||
func (p *Provider) Profiles(c *gin.Context) {
|
||||
noStore(c)
|
||||
idp, base, err := enabled()
|
||||
if err != nil {
|
||||
c.JSON(409, gin.H{"error": "飞书身份服务未启用"})
|
||||
return
|
||||
}
|
||||
var profiles []model.IdentityProfile
|
||||
if err := store.DB.Where("issuer = ? AND app_id = ?", base, idp.AppID).Order("updated_at desc").Limit(500).Find(&profiles).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "查询用户失败"})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
items = append(items, gin.H{"subject": profile.Subject, "name": profile.Name, "updated_at": profile.UpdatedAt})
|
||||
}
|
||||
c.JSON(200, gin.H{"profiles": items})
|
||||
}
|
||||
|
||||
func (p *Provider) Grants(c *gin.Context) {
|
||||
noStore(c)
|
||||
tunnel, idp, base, ok := managedTunnel(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var grants []model.FeishuTunnelGrant
|
||||
if err := store.DB.Where("tunnel_id = ? AND issuer = ? AND app_id = ?", tunnel.ID, base, idp.AppID).Order("created_at desc").Find(&grants).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "查询授权失败"})
|
||||
return
|
||||
}
|
||||
if grants == nil {
|
||||
grants = []model.FeishuTunnelGrant{}
|
||||
}
|
||||
c.JSON(200, gin.H{"grants": grants})
|
||||
}
|
||||
|
||||
func (p *Provider) AddGrant(c *gin.Context) {
|
||||
noStore(c)
|
||||
tunnel, idp, base, ok := managedTunnel(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
if c.ShouldBindJSON(&input) != nil || len(input.Subject) > 256 || !strings.HasPrefix(input.Subject, idp.TenantKey+":ou_") {
|
||||
c.JSON(400, gin.H{"error": "请选择已验证的飞书成员"})
|
||||
return
|
||||
}
|
||||
var profile model.IdentityProfile
|
||||
if store.DB.Where("issuer = ? AND app_id = ? AND subject = ?", base, idp.AppID, input.Subject).First(&profile).Error != nil {
|
||||
c.JSON(400, gin.H{"error": "用户尚未完成飞书验证"})
|
||||
return
|
||||
}
|
||||
grant := model.FeishuTunnelGrant{TunnelID: tunnel.ID, Issuer: base, AppID: idp.AppID, Subject: input.Subject}
|
||||
if err := store.DB.Where(&model.FeishuTunnelGrant{TunnelID: tunnel.ID, Issuer: base, AppID: idp.AppID, Subject: input.Subject}).FirstOrCreate(&grant).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "授权失败"})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "feishu_grant_added", tunnel.Name, input.Subject)
|
||||
c.JSON(http.StatusOK, gin.H{"grant": grant})
|
||||
}
|
||||
|
||||
func (p *Provider) DeleteGrant(c *gin.Context) {
|
||||
noStore(c)
|
||||
tunnel, idp, base, ok := managedTunnel(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
grantID, err := strconv.ParseUint(c.Param("grant_id"), 10, 32)
|
||||
if err != nil || grantID == 0 {
|
||||
c.JSON(400, gin.H{"error": "无效授权"})
|
||||
return
|
||||
}
|
||||
var grant model.FeishuTunnelGrant
|
||||
if store.DB.Where("id = ? AND tunnel_id = ? AND issuer = ? AND app_id = ?", grantID, tunnel.ID, base, idp.AppID).First(&grant).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "授权不存在"})
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&grant).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销失败"})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "feishu_grant_removed", tunnel.Name, grant.Subject)
|
||||
c.Status(204)
|
||||
}
|
||||
|
||||
func (p *Provider) DirectoryCheck(c *gin.Context) {
|
||||
noStore(c)
|
||||
if !secureAdmin(c) {
|
||||
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口检查权限"})
|
||||
return
|
||||
}
|
||||
idp, base, err := enabled()
|
||||
if err != nil {
|
||||
c.JSON(409, gin.H{"error": "飞书身份服务未启用"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
if c.ShouldBindJSON(&input) != nil || len(input.Subject) > 256 || !strings.HasPrefix(input.Subject, idp.TenantKey+":ou_") {
|
||||
c.JSON(400, gin.H{"error": "请选择已验证的飞书成员"})
|
||||
return
|
||||
}
|
||||
var profile model.IdentityProfile
|
||||
if store.DB.Where("issuer = ? AND app_id = ? AND subject = ?", base, idp.AppID, input.Subject).First(&profile).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "用户尚未完成飞书验证"})
|
||||
return
|
||||
}
|
||||
secret, err := store.DecryptSecret(idp.AppSecret)
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"error": "读取飞书配置失败"})
|
||||
return
|
||||
}
|
||||
name, source, err := fetchDirectoryNameWithSource(c.Request.Context(), idp.AppID, secret, strings.TrimPrefix(input.Subject, idp.TenantKey+":"))
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"available": false, "hint": "请在飞书开放平台检查 contact:user.base:readonly 权限、应用发布状态和通讯录可见范围"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"available": true, "name": name, "source": source})
|
||||
}
|
||||
@@ -212,6 +212,25 @@ func (p *Provider) FeishuCallback(c *gin.Context) {
|
||||
oidcError(c, 403)
|
||||
return
|
||||
}
|
||||
allowed, err := admitted(state.Redirect, base, cfg, user.Subject)
|
||||
if err != nil {
|
||||
oidcError(c, 503)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
oidcError(c, 403)
|
||||
return
|
||||
}
|
||||
directoryContext, cancelDirectory := context.WithTimeout(c.Request.Context(), 4*time.Second)
|
||||
defer cancelDirectory()
|
||||
openID := strings.TrimPrefix(user.Subject, tenant+":")
|
||||
// The browser grant can have user-level contact permission even when the
|
||||
// application's tenant token lacks basic_batch permission.
|
||||
if name, err := fetchBasicName(directoryContext, token, openID); err == nil {
|
||||
user.Name = name
|
||||
} else if name, err := fetchDirectoryName(directoryContext, cfg.AppID, secret, openID); err == nil {
|
||||
user.Name = name
|
||||
}
|
||||
if err := saveProfile(base, cfg, user); err != nil {
|
||||
oidcError(c, 503)
|
||||
return
|
||||
|
||||
@@ -31,14 +31,24 @@ type IdentityGrant struct {
|
||||
// IdentityProfile is display metadata from a verified Feishu user-info response.
|
||||
// It grants no VPN permissions and contains no upstream access token.
|
||||
type IdentityProfile struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Issuer string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
|
||||
AppID string `gorm:"uniqueIndex:idx_identity_profile;size:128"`
|
||||
Subject string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
|
||||
Name string `gorm:"size:256"`
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Issuer string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
|
||||
AppID string `gorm:"uniqueIndex:idx_identity_profile;size:128"`
|
||||
Subject string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
|
||||
Name string `gorm:"size:256"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// FeishuTunnelGrant records explicit admission for a verified Feishu subject.
|
||||
type FeishuTunnelGrant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"uniqueIndex:idx_feishu_tunnel_grant" json:"tunnel_id"`
|
||||
Issuer string `gorm:"uniqueIndex:idx_feishu_tunnel_grant;size:256" json:"-"`
|
||||
AppID string `gorm:"uniqueIndex:idx_feishu_tunnel_grant;size:128" json:"-"`
|
||||
Subject string `gorm:"uniqueIndex:idx_feishu_tunnel_grant;size:256" json:"subject"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
|
||||
type TrafficSample struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -204,13 +214,13 @@ type VPNSession struct {
|
||||
// ConnectionLog 连接审计日志(连接/断开/认证失败/配额超额/异常流量) // Logan.Li
|
||||
type ConnectionLog struct {
|
||||
DisplayName string `gorm:"size:256" json:"display_name,omitempty"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128" json:"username"`
|
||||
Event string `gorm:"size:32" json:"event"` // connect / disconnect / auth_fail / quota_exceed / rate_alert
|
||||
IPAddress string `gorm:"size:64" json:"ip_address"`
|
||||
Detail string `gorm:"size:1024" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128" json:"username"`
|
||||
Event string `gorm:"size:32" json:"event"` // connect / disconnect / auth_fail / quota_exceed / rate_alert
|
||||
IPAddress string `gorm:"size:64" json:"ip_address"`
|
||||
Detail string `gorm:"size:1024" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ClientDevice 终端用户设备(桌面/移动客户端注册)。
|
||||
|
||||
@@ -115,6 +115,18 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
return nil, fmt.Errorf("auth_mode 必须为 certificate、password、certificate+password 或 oidc")
|
||||
}
|
||||
if service.Str(out, "auth_mode") == "oidc" {
|
||||
if raw, present := out["feishu_access_mode"]; present {
|
||||
if _, ok := raw.(string); !ok {
|
||||
return nil, fmt.Errorf("feishu_access_mode 必须为字符串")
|
||||
}
|
||||
}
|
||||
mode := service.Str(out, "feishu_access_mode")
|
||||
if mode != "" && mode != "all" && mode != "allowlist" {
|
||||
return nil, fmt.Errorf("feishu_access_mode 必须为 all 或 allowlist")
|
||||
}
|
||||
if mode == "allowlist" && service.Str(out, "oidc_provider") != "builtin" {
|
||||
return nil, fmt.Errorf("指定飞书成员仅适用于内置飞书身份服务")
|
||||
}
|
||||
switch service.Str(out, "oidc_provider") {
|
||||
case "", "external", "builtin":
|
||||
default:
|
||||
@@ -138,6 +150,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
}
|
||||
out["mfa_mode"] = "off" // 飞书 MFA 由身份提供方负责
|
||||
} else {
|
||||
delete(out, "feishu_access_mode")
|
||||
for _, key := range []string{"oidc_provider", "oidc_issuer", "oidc_base_url", "oidc_client_id", "oidc_client_secret", "oidc_http_port", "oidc_http_secret", "oidc_management_password"} {
|
||||
delete(out, key)
|
||||
}
|
||||
|
||||
@@ -235,6 +235,19 @@ func TestOIDCTunnelsNeedDistinctCallbackAndPort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
feishu := map[string]interface{}{"auth_mode": "oidc", "oidc_provider": "builtin", "oidc_issuer": "https://vpn.example.com/id", "oidc_base_url": "https://vpn.example.com", "oidc_client_id": "opsx-vpn", "feishu_access_mode": "allowlist"}
|
||||
if _, err := New().Validate(feishu); err != nil {
|
||||
t.Fatalf("valid member policy: %v", err)
|
||||
}
|
||||
feishu["feishu_access_mode"] = true
|
||||
if _, err := New().Validate(feishu); err == nil {
|
||||
t.Fatal("non-string member policy accepted")
|
||||
}
|
||||
feishu["feishu_access_mode"] = "allowlist"
|
||||
feishu["oidc_provider"] = "external"
|
||||
if _, err := New().Validate(feishu); err == nil {
|
||||
t.Fatal("external OIDC accepted Feishu member policy")
|
||||
}
|
||||
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -456,9 +469,10 @@ func TestWebAuthSessionIdentityAndTrafficTransition(t *testing.T) {
|
||||
if rx, tx := svc.collectTraffic(tunnel); rx != 150 || tx != 230 {
|
||||
t.Fatalf("initial traffic = %d/%d", rx, tx)
|
||||
}
|
||||
var unknown model.VPNSession
|
||||
if err := store.DB.Where("tunnel_id = ? AND status = ?", tunnel.ID, "active").First(&unknown).Error; err != nil || unknown.Username != "" || unknown.VPNUserID != 0 {
|
||||
t.Fatalf("unknown peer must not be attributed: %+v, %v", unknown, err)
|
||||
var unknownCount int64
|
||||
store.DB.Model(&model.VPNSession{}).Where("tunnel_id = ?", tunnel.ID).Count(&unknownCount)
|
||||
if unknownCount != 0 {
|
||||
t.Fatalf("pre-auth socket created %d audit sessions", unknownCount)
|
||||
}
|
||||
var placeholder model.VPNUser
|
||||
store.DB.Where("tunnel_id = ? AND username = ?", tunnel.ID, "UNDEF").First(&placeholder)
|
||||
|
||||
@@ -31,6 +31,9 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
|
||||
now := time.Now()
|
||||
subjects := make([]string, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if protocol == "openvpn" && client.Username == "" {
|
||||
continue // WebAuth reports UNDEF until the browser flow supplies a subject.
|
||||
}
|
||||
subjects = append(subjects, client.Username)
|
||||
}
|
||||
var tunnel model.Tunnel
|
||||
@@ -40,6 +43,9 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
|
||||
// 1. 收集当前在线的标识 key(username|realAddress)
|
||||
currentKeys := map[string]bool{}
|
||||
for _, c := range clients {
|
||||
if protocol == "openvpn" && c.Username == "" {
|
||||
continue // Never audit a pre-auth socket as a connected VPN identity.
|
||||
}
|
||||
key := c.Username + "|" + c.RealAddress
|
||||
currentKeys[key] = true
|
||||
|
||||
@@ -89,8 +95,10 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
|
||||
"disconnected_at": now,
|
||||
})
|
||||
duration := now.Sub(s.ConnectedAt).Round(time.Second)
|
||||
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress,
|
||||
fmt.Sprintf("时长 %s, ↓%s ↑%s", duration, fmtBytes(s.RxBytes), fmtBytes(s.TxBytes)))
|
||||
if s.Username != "" {
|
||||
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress,
|
||||
fmt.Sprintf("时长 %s, ↓%s ↑%s", duration, fmtBytes(s.RxBytes), fmtBytes(s.TxBytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,3 +36,37 @@ func TestDisconnectLoggedOnceWhenTunnelHasNoClients(t *testing.T) {
|
||||
t.Fatalf("expected one connect and one disconnect with stable subject, got %+v", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenVPNPreAuthSocketDoesNotCreateAnonymousAudit(t *testing.T) {
|
||||
previous := store.DB
|
||||
t.Cleanup(func() {
|
||||
if store.DB != nil {
|
||||
if db, err := store.DB.DB(); err == nil {
|
||||
_ = db.Close()
|
||||
}
|
||||
}
|
||||
store.DB = previous
|
||||
})
|
||||
if err := store.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnel := model.Tunnel{Name: "web-auth", Protocol: "openvpn"}
|
||||
if err := store.DB.Create(&tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preAuth := ClientInfo{RealAddress: "203.0.113.1:4321", RxBytes: 100, TxBytes: 200}
|
||||
SyncSessions(tunnel.ID, "openvpn", []ClientInfo{preAuth})
|
||||
var count int64
|
||||
store.DB.Model(&model.ConnectionLog{}).Where("tunnel_id = ?", tunnel.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("pre-auth status created an audit log")
|
||||
}
|
||||
preAuth.Username = "tenant:ou_verified"
|
||||
SyncSessions(tunnel.ID, "openvpn", []ClientInfo{preAuth})
|
||||
SyncSessions(tunnel.ID, "openvpn", nil)
|
||||
var logs []model.ConnectionLog
|
||||
store.DB.Where("tunnel_id = ?", tunnel.ID).Order("id").Find(&logs)
|
||||
if len(logs) != 2 || logs[0].Username != preAuth.Username || logs[1].Username != preAuth.Username {
|
||||
t.Fatalf("unexpected authenticated audit sequence: %+v", logs)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
|
||||
}
|
||||
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
||||
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{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}); 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{}, &model.IdentityProfile{}, &model.FeishuTunnelGrant{}); err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# SDD-011:飞书姓名补全与 OpenVPN 成员准入
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 级别 | S3:认证准入、外部目录查询、数据库新增表 |
|
||||
| 设计状态 | 已形成 |
|
||||
| 实现状态 | 本地实现;待真实飞书环境验证与发布 |
|
||||
| 日期 | 2026-10-23 |
|
||||
| 授权 | 用户要求继续开发姓名反查及两类成员准入 |
|
||||
|
||||
## 1. 背景与范围
|
||||
|
||||
当前 `identityprovider/handlers.go` 使用飞书 `authen/v1/user_info` 的 `name` 保存到 `IdentityProfile`。测试环境已有会话稳定 subject,但保存姓名为飞书返回的“用户812604”。`FeishuCallback` 只检查 `tenant_key`,OIDC 专线尚无成员白名单。`sessionmgr` 仅用 subject 精确查姓名;不按 IP 或展示名猜测用户。
|
||||
|
||||
本轮在用户每次授权时尝试使用应用 tenant token 按同应用 `open_id` 查询基础姓名,再回退通讯录单用户接口;均失败时保留 `user_info` 结果,登录不中断。提供仅管理员可调用的姓名查询诊断,结果不含上游 token。增加专线级准入模式:现有与缺省专线保持同租户全员;指定成员模式只允许已明确授予的 subject,撤销后拒绝下一次登录。身份资料与授权名单分开,不自动让已登录用户获准。OpenVPN WebAuth 尚未完成认证时出现的 UNDEF 不创建匿名审计,待稳定 subject 出现后记录连接。
|
||||
|
||||
## 2. 需求
|
||||
|
||||
- FR-01:反查同一应用 `open_id` 时,仅在返回有效姓名时覆盖展示名;权限、范围、超时等错误降级而不跳过租户检查;无管理员授权的用户不能调用诊断。
|
||||
- FR-02:管理员能查看/添加/撤销某条内置飞书 OpenVPN 专线的已验证用户授权;不能跨租户、跨 issuer 或凭可变姓名授权。
|
||||
- FR-03:`all` / `allowlist` 两模式:`all` 仍要求相同 `tenant_key`;`allowlist` 需精确匹配当前 tunnel ID + issuer + app ID + subject 的授权记录,缺数据和数据库错误均拒绝发放授权码。切换模式需在专线编辑页明示影响。
|
||||
- NFR-01:短期上游访问令牌不存数据库/日志;接口查询只由服务端执行;更换飞书应用后旧名单不能生效;历史会话不回填猜测姓名。
|
||||
|
||||
## 3. 架构
|
||||
|
||||
`FeishuCallback` 已有经过飞书签发并核实租户的 `open_id`。优先用本次授权的用户令牌调用飞书 `POST /contact/v3/users/basic_batch?user_id_type=open_id`;失败后用应用 tenant token 重试,最后尝试单用户通讯录接口。请求体为 `user_ids`;SDK 文档说明 basic_batch 不受通讯录可见范围限制,但接口仍可能返回权限错误。只接受 `code=0`、有姓名且响应用户 ID 与原 `open_id` 一致的结果;均失败即保留原姓名。认证判断在保存 profile 和生成内部授权码之前,根据回调 URL 定位唯一的内置专线并检查准入。管理员选择已验证 profile 加入准入表。UI 为专线编辑页模式选择与成员列表;身份设置页提供姓名权限检测和操作提示。
|
||||
|
||||
## 4. 接口和数据
|
||||
|
||||
在 `Tunnel.Config` 增加 `feishu_access_mode`,值为 `all` 或 `allowlist`;未配置按 `all` 处理以保持兼容。新增 `FeishuTunnelGrant`(tunnel_id、issuer、app_id、subject、created_at),唯一索引约束完整身份;撤销时删除此记录。管理员接口:查询已验证身份、列出授权、添加、撤销;诊断接口输入已验证 subject,服务端使用加密保存的 App Secret 请求飞书并返回有限的状态和展示名。其余认证模式拒绝使用此功能。
|
||||
|
||||
## 5. 风险和验收
|
||||
|
||||
| 风险 | 防护/恢复 | 观察 |
|
||||
|---|---|---|
|
||||
| 飞书权限不足或通讯录不可见 | 不阻断 `all` 登录;诊断说明需在飞书开放平台授权,平台不能代授 | 降级显示原姓名 |
|
||||
| 白名单配置错误造成拒绝连接 | 切换前先加入用户;设置恢复 `all` 即回退 | 回调无新授权码 |
|
||||
| 换 App 后旧授权误用 | 联合 issuer/app ID/subject 精确匹配 | 跨应用负向测试 |
|
||||
| 在线已连用户撤销 | 下一次登录阻断;本轮不强制踢已有连接 | 会话状态与下一次授权 |
|
||||
|
||||
| AC | 需求 | 验证 | 状态 |
|
||||
|---|---|---|---|
|
||||
| AC-01 | FR-01/NFR-01 | 模拟飞书成功/403/不一致/请求取消,核验姓名与不泄露 token | 单元测试通过;真实姓名待验证 |
|
||||
| AC-02 | FR-02 | 管理员添加/撤销、普通账号禁止及未知身份拒绝 | 授权接口已实现,管理角色与未知身份尚未单独测试 |
|
||||
| AC-03 | FR-03 | 默认 all;同租户白名单准入,跨应用、未授权、DB 失败均拒绝 | 单元测试通过,真实回调待验证 |
|
||||
| AC-04 | 全部 | Go 测试、前端构建、现有登录路由回归 | 完整 Go 测试及前端构建通过;真实飞书登录待验证 |
|
||||
|
||||
## 6. 发布与恢复
|
||||
|
||||
先备份数据库和加密密钥,运行迁移与测试,部署后检查当前 `all` 专线仍能登录。先导入可验证身份并授予,再手动切换 `allowlist`;遇故障切回 `all`,新增表保留供重试。不删旧身份资料或证书。未实际完成测试前不标通过。
|
||||
|
||||
## 7. 未决
|
||||
|
||||
2026-10-23 测试环境以已保存的应用凭据查询已验证 `open_id`:tenant token 成功,`basic_batch` 返回飞书错误码 `99991672`;单用户接口成功且确认 open_id 匹配,但 `name` 仍为“用户812604”,其他姓名字段为空。浏览器用户令牌的 basic_batch 路径尚待下一次真人授权验证。基础姓名接口的应用权限和发布状态需在飞书开放平台核对;不能由平台代授权或仅靠用户数字反查。全量通讯录同步、部门实时同步和强制踢离在线用户留到后续迭代,本轮仅支持按已验证身份授权。
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<section class="feishu-admission">
|
||||
<el-divider content-position="left">{{ $t('feishuAdmission.title') }}</el-divider>
|
||||
<el-form-item :label="$t('feishuAdmission.mode')">
|
||||
<el-radio-group v-model="config.feishu_access_mode">
|
||||
<el-radio value="all">{{ $t('feishuAdmission.all') }}</el-radio>
|
||||
<el-radio value="allowlist">{{ $t('feishuAdmission.allowlist') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="hint">{{ $t('feishuAdmission.modeHint') }}</p>
|
||||
</el-form-item>
|
||||
<template v-if="editing && isAdmin">
|
||||
<el-form-item :label="$t('feishuAdmission.members')">
|
||||
<div class="members">
|
||||
<div class="member-add">
|
||||
<el-select v-model="selected" filterable :placeholder="$t('feishuAdmission.choose')" style="flex:1" :loading="loading">
|
||||
<el-option v-for="profile in candidates" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
|
||||
</el-select>
|
||||
<el-button :disabled="!selected" :loading="working" @click="add">{{ $t('feishuAdmission.add') }}</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!grants.length" :description="$t('feishuAdmission.empty')" :image-size="64" />
|
||||
<div v-for="grant in grants" :key="grant.id" class="member-row">
|
||||
<span>{{ profiles.find(p => p.subject === grant.subject)?.name || grant.subject }} <small>{{ grant.subject }}</small></span>
|
||||
<el-button type="danger" link :loading="working" @click="remove(grant)">{{ $t('feishuAdmission.remove') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-alert v-else :title="isAdmin ? $t('feishuAdmission.saveFirst') : $t('feishuAdmission.adminOnly')" type="info" :closable="false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
|
||||
const props = defineProps({ config: { type: Object, required: true }, tunnelId: { type: [String, Number], required: true }, editing: Boolean, isAdmin: Boolean })
|
||||
const { t } = useI18n()
|
||||
const profiles = ref([])
|
||||
const grants = ref([])
|
||||
const selected = ref('')
|
||||
const loading = ref(false)
|
||||
const working = ref(false)
|
||||
const candidates = computed(() => profiles.value.filter(p => !grants.value.some(g => g.subject === p.subject)))
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [users, permissions] = await Promise.all([api.get('/system/identity/profiles'), api.get(`/tunnels/${props.tunnelId}/feishu-grants`)])
|
||||
profiles.value = users.data.profiles || []
|
||||
grants.value = permissions.data.grants || []
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('feishuAdmission.loadFailed')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function add() {
|
||||
working.value = true
|
||||
try { await api.post(`/tunnels/${props.tunnelId}/feishu-grants`, { subject: selected.value }); selected.value = ''; await refresh() }
|
||||
catch (e) { ElMessage.error(e.response?.data?.error || t('common.saveFailed')) }
|
||||
finally { working.value = false }
|
||||
}
|
||||
async function remove(grant) {
|
||||
working.value = true
|
||||
try { await api.delete(`/tunnels/${props.tunnelId}/feishu-grants/${grant.id}`); await refresh() }
|
||||
catch (e) { ElMessage.error(e.response?.data?.error || t('common.saveFailed')) }
|
||||
finally { working.value = false }
|
||||
}
|
||||
onMounted(() => { if (props.editing && props.isAdmin) refresh() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { width: 100%; margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
|
||||
.members { width: 100%; }
|
||||
.member-add { display: flex; gap: 8px; }
|
||||
.member-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.member-row small { display: block; color: var(--text-secondary); line-height: 1.2; }
|
||||
</style>
|
||||
@@ -793,6 +793,30 @@
|
||||
"setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server",
|
||||
"commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled"
|
||||
},
|
||||
"feishuDirectory": {
|
||||
"title": "Feishu directory lookup",
|
||||
"hint": "After a sign-in, check the directory name. The app needs published user basic info permission and visibility.",
|
||||
"choose": "Select a signed-in member",
|
||||
"check": "Check name",
|
||||
"found": "Directory name: {name}",
|
||||
"basicUnavailable": "The basic name API did not return a result. Check app permissions and publication in Feishu.",
|
||||
"failed": "Lookup failed"
|
||||
},
|
||||
"feishuAdmission": {
|
||||
"title": "Feishu member access",
|
||||
"mode": "Who can connect",
|
||||
"all": "All coworkers in this tenant",
|
||||
"allowlist": "Selected members only",
|
||||
"modeHint": "Members appear here after their first Feishu sign-in. Add them before enabling the allowlist; revocation applies to their next login.",
|
||||
"members": "Authorized members",
|
||||
"choose": "Select a member who has signed in",
|
||||
"add": "Add",
|
||||
"remove": "Revoke",
|
||||
"empty": "No authorized members",
|
||||
"saveFirst": "Save the built-in Feishu tunnel before managing members.",
|
||||
"adminOnly": "Only administrators can manage member access.",
|
||||
"loadFailed": "Could not load Feishu members"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
|
||||
@@ -793,6 +793,30 @@
|
||||
"setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续",
|
||||
"commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启"
|
||||
},
|
||||
"feishuDirectory": {
|
||||
"title": "飞书姓名查询",
|
||||
"hint": "完成登录后可检查通讯录姓名。需要飞书应用已发布、获取用户基本信息权限和可见范围。",
|
||||
"choose": "选择已登录成员",
|
||||
"check": "检查姓名",
|
||||
"found": "通讯录姓名:{name}",
|
||||
"basicUnavailable": "基础姓名接口未返回结果;请在飞书开放平台核对接口权限和应用发布状态。",
|
||||
"failed": "检查失败"
|
||||
},
|
||||
"feishuAdmission": {
|
||||
"title": "飞书成员准入",
|
||||
"mode": "允许连接的成员",
|
||||
"all": "同企业所有同事",
|
||||
"allowlist": "仅指定成员",
|
||||
"modeHint": "成员首次通过飞书登录后出现在下方。先添加成员,再切换为仅指定成员;撤销后下次登录生效。",
|
||||
"members": "已授权成员",
|
||||
"choose": "选择已完成飞书登录的成员",
|
||||
"add": "添加",
|
||||
"remove": "撤销",
|
||||
"empty": "暂无授权成员",
|
||||
"saveFirst": "先保存内置飞书专线,再管理授权成员。",
|
||||
"adminOnly": "仅管理员可管理授权成员。",
|
||||
"loadFailed": "无法加载飞书成员"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
"name": "WireGuard",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<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'" />
|
||||
<FeishuAdmission v-if="isOpenVPN && form.config.auth_mode === 'oidc' && form.config.oidc_provider === 'builtin'" :config="form.config" :tunnel-id="route.params.id" :editing="originalAuthMode === 'oidc' && originalOIDCProvider === 'builtin'" :is-admin="auth.role === 'admin'" />
|
||||
<template v-if="isOpenVPN">
|
||||
<el-form-item :label="$t('tunnelEditor.trafficMode')">
|
||||
<el-radio-group v-model="trafficMode">
|
||||
@@ -76,9 +77,10 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../api'
|
||||
import api, { auth } from '../api'
|
||||
import RouteListInput from '../components/RouteListInput.vue'
|
||||
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
||||
import FeishuAdmission from '../components/FeishuAdmission.vue'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -140,6 +142,7 @@ async function load() {
|
||||
const config = parseConfig(tunnel.config)
|
||||
originalAuthMode.value = config.auth_mode || ''
|
||||
originalOIDCProvider.value = config.oidc_provider || 'external'
|
||||
if (config.feishu_access_mode === undefined) config.feishu_access_mode = 'all'
|
||||
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
||||
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
||||
}
|
||||
|
||||
@@ -81,22 +81,24 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||||
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div class="row-actions">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||||
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)">
|
||||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -279,6 +281,8 @@ onUnmounted(() => clearInterval(timer))
|
||||
|
||||
<style scoped>
|
||||
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
|
||||
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
.tunnel-search { width: 200px; }
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
|
||||
<el-button :loading="identityGatewayLoading" :disabled="!identitySecure || !identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
|
||||
</el-card>
|
||||
<el-card v-if="identityStatus?.enabled && identitySecure" class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>{{ $t('feishuDirectory.title') }}</b></template>
|
||||
<p class="tls-hint">{{ $t('feishuDirectory.hint') }}</p>
|
||||
<div class="directory-check">
|
||||
<el-select v-model="selectedProfile" filterable :placeholder="$t('feishuDirectory.choose')" :loading="profilesLoading" style="flex:1">
|
||||
<el-option v-for="profile in identityProfiles" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
|
||||
</el-select>
|
||||
<el-button :disabled="!selectedProfile" :loading="directoryLoading" @click="checkDirectory">{{ $t('feishuDirectory.check') }}</el-button>
|
||||
</div>
|
||||
<el-alert v-if="directoryResult" :title="directoryResult" :type="directoryAvailable ? 'success' : 'warning'" :closable="false" style="margin-top:12px" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +76,12 @@ const tenantLoading = ref(false)
|
||||
const tenantName = ref('')
|
||||
const tenantError = ref('')
|
||||
const tenantChecked = ref(false)
|
||||
const identityProfiles = ref([])
|
||||
const selectedProfile = ref('')
|
||||
const profilesLoading = ref(false)
|
||||
const directoryLoading = ref(false)
|
||||
const directoryResult = ref('')
|
||||
const directoryAvailable = ref(false)
|
||||
const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key)
|
||||
const tenantSeconds = ref(0)
|
||||
let tenantTimer
|
||||
@@ -117,6 +134,7 @@ async function loadIdentity() {
|
||||
try {
|
||||
const { data } = await api.get('/system/identity')
|
||||
identityStatus.value = data
|
||||
if (data.enabled && identitySecure) loadProfiles()
|
||||
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
|
||||
tenantName.value = ''
|
||||
tenantError.value = ''
|
||||
@@ -124,6 +142,22 @@ async function loadIdentity() {
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
|
||||
finally { identityLoading.value = false }
|
||||
}
|
||||
async function loadProfiles() {
|
||||
profilesLoading.value = true
|
||||
try { identityProfiles.value = (await api.get('/system/identity/profiles')).data.profiles || [] }
|
||||
catch { identityProfiles.value = [] }
|
||||
finally { profilesLoading.value = false }
|
||||
}
|
||||
async function checkDirectory() {
|
||||
directoryLoading.value = true
|
||||
directoryResult.value = ''
|
||||
try {
|
||||
const { data } = await api.post('/system/identity/directory-check', { subject: selectedProfile.value })
|
||||
directoryAvailable.value = data.available
|
||||
directoryResult.value = data.available ? t('feishuDirectory.found', { name: data.name }) + (data.source === 'contact_user' ? ` ${t('feishuDirectory.basicUnavailable')}` : '') : data.hint
|
||||
} catch (e) { directoryAvailable.value = false; directoryResult.value = e.response?.data?.error || t('feishuDirectory.failed') }
|
||||
finally { directoryLoading.value = false }
|
||||
}
|
||||
async function saveIdentity() {
|
||||
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
|
||||
identitySaving.value = true
|
||||
@@ -158,4 +192,5 @@ onMounted(() => { if (!auth.mustChange) loadIdentity() })
|
||||
.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; }
|
||||
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
||||
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
|
||||
.directory-check { display: flex; gap: 8px; }
|
||||
</style>
|
||||
Reference in new issue
Block a user