Fetch Feishu tenant identity from app credentials
This commit is contained in:
1 parent
0528bc4b9a
commit
eb3861bbe8
7 files changed
+316
-11
No files matched your search
@@ -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": "密钥过长"})
|
||||
|
||||
Reference in new issue
Block a user