Fetch Feishu tenant identity from app credentials

This commit is contained in:
opsx committed 2026-10-22 14:31:39 +08:00
1 parent 0528bc4b9a
commit eb3861bbe8
7 files changed
+316 -11

No files matched your search

+1
View File
@@ -312,6 +312,7 @@ 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.POST("/system/identity/tenant", idp.QueryTenant)
admin.PUT("/system/identity", idp.Configure)
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
admin.PUT("/system/tls/domain", s.setTLSDomain)
+158 -7
View File
@@ -1,6 +1,11 @@
package identityprovider
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strings"
@@ -11,6 +16,118 @@ import (
"vpn-platform/backend/internal/store"
)
const feishuTenantToken = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
const feishuTenantQuery = "https://open.feishu.cn/open-apis/tenant/v2/tenant/query"
func secureAdmin(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
return err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https"
}
type tenantCredentials struct {
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
}
func resolveTenantCredentials(input tenantCredentials, cfgAppID, encryptedSecret string) (tenantCredentials, error) {
input.AppID = strings.TrimSpace(input.AppID)
if input.AppID == "" || len(input.AppID) > 128 || strings.ContainsAny(input.AppID, " \r\n\t") || len(input.AppSecret) > 512 {
return input, errors.New("请输入有效的飞书 App ID 和 App Secret")
}
if input.AppSecret == "" && input.AppID == cfgAppID && encryptedSecret != "" {
secret, err := store.DecryptSecret(encryptedSecret)
if err != nil {
return input, errors.New("读取已保存的飞书密钥失败")
}
input.AppSecret = secret
}
if input.AppSecret == "" {
return input, errors.New("请填写当前 App ID 对应的 App Secret")
}
return input, nil
}
// queryFeishuTenant requests app-scoped tenant information without exposing the
// upstream access token or raw upstream errors to the management UI.
func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, string, error) {
payload, _ := json.Marshal(map[string]string{"app_id": creds.AppID, "app_secret": creds.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 "", "", errors.New("飞书企业信息请求失败,请稍后重试")
}
defer resp.Body.Close()
var token struct {
Code *int `json:"code"`
TenantAccessToken string `json:"tenant_access_token"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.Code == nil || *token.Code != 0 || token.TenantAccessToken == "" {
return "", "", errors.New("获取飞书访问凭据失败,请检查 App ID 和 App Secret")
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, feishuTenantQuery, nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+token.TenantAccessToken)
resp, err = upstreamClient.Do(req)
if err != nil {
return "", "", errors.New("飞书企业信息请求失败,请稍后重试")
}
defer resp.Body.Close()
var result struct {
Code *int `json:"code"`
Data struct {
Tenant struct {
TenantKey string `json:"tenant_key"`
Name string `json:"name"`
} `json:"tenant"`
} `json:"data"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || result.Code == nil || *result.Code != 0 {
return "", "", errors.New("查询飞书企业信息失败,请确认应用已开通企业信息读取权限")
}
if !validTenant(result.Data.Tenant.TenantKey) {
return "", "", errors.New("飞书未返回有效的企业标识")
}
return result.Data.Tenant.TenantKey, result.Data.Tenant.Name, nil
}
func (p *Provider) QueryTenant(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口查询飞书企业信息"})
return
}
var input tenantCredentials
if c.ShouldBindJSON(&input) != nil {
c.JSON(400, gin.H{"error": "配置格式错误"})
return
}
cfg, err := current()
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(500, gin.H{"error": "读取身份配置失败"})
return
}
input, err = resolveTenantCredentials(input, cfg.AppID, cfg.AppSecret)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
key, name, err := queryFeishuTenant(c.Request.Context(), input)
if err != nil {
c.JSON(502, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"tenant_key": key, "name": name})
}
func (p *Provider) Status(c *gin.Context) {
cfg, err := current()
if err != nil && err != gorm.ErrRecordNotFound {
@@ -35,12 +152,9 @@ func (p *Provider) Status(c *gin.Context) {
}
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
}
if !secureAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"})
return
}
var req struct {
Enabled bool `json:"enabled"`
@@ -54,7 +168,7 @@ func (p *Provider) Configure(c *gin.Context) {
}
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))) {
if len(req.AppID) > 128 || strings.ContainsAny(req.AppID, " \r\n\t") || (req.Enabled && req.AppID == "") || (req.TenantKey != "" && !validTenant(req.TenantKey)) {
c.JSON(400, gin.H{"error": "请输入有效的飞书 App ID 和租户标识"})
return
}
@@ -69,6 +183,43 @@ func (p *Provider) Configure(c *gin.Context) {
c.JSON(500, gin.H{"error": "读取身份配置失败"})
return
}
if cfg.AppID != "" && cfg.AppID != req.AppID && req.AppSecret == "" {
c.JSON(400, gin.H{"error": "更换 App ID 时必须填写对应的 App Secret"})
return
}
if cfg.AppID != "" && cfg.AppID != req.AppID && !req.Enabled {
// A disabled replacement must not retain the previous application's tenant.
req.TenantKey = ""
}
autoTenant := req.Enabled && req.TenantKey == ""
if req.Enabled && req.TenantKey == "" {
creds, err := resolveTenantCredentials(tenantCredentials{req.AppID, req.AppSecret}, cfg.AppID, cfg.AppSecret)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
req.TenantKey, _, err = queryFeishuTenant(c.Request.Context(), creds)
if err != nil {
c.JSON(502, gin.H{"error": err.Error()})
return
}
}
if cfg.AppID != req.AppID && req.Enabled && !autoTenant {
creds, err := resolveTenantCredentials(tenantCredentials{req.AppID, req.AppSecret}, cfg.AppID, cfg.AppSecret)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
actual, _, err := queryFeishuTenant(c.Request.Context(), creds)
if err != nil {
c.JSON(502, gin.H{"error": err.Error()})
return
}
if actual != req.TenantKey {
c.JSON(400, gin.H{"error": "企业标识与当前飞书应用不一致"})
return
}
}
if req.AppSecret != "" {
if len(req.AppSecret) > 512 {
c.JSON(400, gin.H{"error": "密钥过长"})
@@ -3,6 +3,7 @@ package identityprovider
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -12,6 +13,116 @@ import (
"vpn-platform/backend/internal/store"
)
func TestQueryTenantUsesAppCredentialsWithoutPersistingOrLeaking(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
previous := upstreamClient
defer func() { upstreamClient = previous }()
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
response := `{"code":0,"tenant_access_token":"temporary-token"}`
switch req.URL.String() {
case feishuTenantToken:
if req.Method != http.MethodPost || req.Header.Get("Content-Type") != "application/json" {
t.Fatal("invalid token request")
}
var creds tenantCredentials
if json.NewDecoder(req.Body).Decode(&creds) != nil || creds.AppID != "cli_test" || creds.AppSecret != "private-value" {
t.Fatal("invalid credentials")
}
case feishuTenantQuery:
if req.Method != http.MethodGet || req.Header.Get("Authorization") != "Bearer temporary-token" {
t.Fatal("invalid tenant request")
}
response = `{"code":0,"data":{"tenant":{"tenant_key":"tenant_123","name":"Example"}}}`
default:
t.Fatalf("unexpected endpoint: %s", req.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil
})}
gin.SetMode(gin.TestMode)
server := gin.New()
p := New()
server.POST("/tenant", p.QueryTenant)
server.PUT("/config", p.Configure)
query := []byte(`{"app_id":"cli_test","app_secret":"private-value"}`)
request := httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("X-Forwarded-Proto", "https")
resp := httptest.NewRecorder()
server.ServeHTTP(resp, request)
if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"tenant_key":"tenant_123"`) || strings.Contains(resp.Body.String(), "temporary-token") || strings.Contains(resp.Body.String(), "private-value") {
t.Fatalf("unexpected query response: %d %s", resp.Code, resp.Body.String())
}
if _, err := current(); err == nil {
t.Fatal("query unexpectedly persisted configuration")
}
request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_test","app_secret":"private-value","enabled":false}`))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("X-Forwarded-Proto", "https")
resp = httptest.NewRecorder()
server.ServeHTTP(resp, request)
if resp.Code != 200 {
t.Fatalf("save failed: %d %s", resp.Code, resp.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/tenant", strings.NewReader(`{"app_id":"cli_test"}`))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("X-Forwarded-Proto", "https")
resp = httptest.NewRecorder()
server.ServeHTTP(resp, request)
if resp.Code != 200 {
t.Fatalf("saved credentials not reused: %d %s", resp.Code, resp.Body.String())
}
request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_other","tenant_key":"tenant_123","enabled":false}`))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("X-Forwarded-Proto", "https")
resp = httptest.NewRecorder()
server.ServeHTTP(resp, request)
if resp.Code != 400 {
t.Fatalf("new app reused old secret: %d %s", resp.Code, resp.Body.String())
}
cfg, err := current()
if err != nil || cfg.AppID != "cli_test" {
t.Fatal("rejected app change modified saved configuration")
}
request = httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
resp = httptest.NewRecorder()
server.ServeHTTP(resp, request)
if resp.Code != 403 {
t.Fatalf("plaintext request accepted: %d", resp.Code)
}
}
func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
secret, err := store.EncryptSecret("private-value")
if err != nil {
t.Fatal(err)
}
if _, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_other"}, "cli_test", secret); err == nil {
t.Fatal("old secret reused for another app")
}
creds, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_test"}, "cli_test", secret)
if err != nil || creds.AppSecret != "private-value" {
t.Fatal("saved secret not reused for same app")
}
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(`{"code":999,"msg":"private-value"}`)), Header: make(http.Header)}, nil
})}
_, _, err = queryFeishuTenant(httptest.NewRequest(http.MethodGet, "/", nil).Context(), creds)
if err == nil || strings.Contains(err.Error(), "private-value") {
t.Fatalf("unsafe upstream error: %v", err)
}
}
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)