package identityprovider import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "vpn-platform/backend/internal/nginxgateway" "vpn-platform/backend/internal/store" "vpn-platform/backend/internal/tlsservice" ) 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" // Do not include upstream msg: it is untrusted and may contain request data. func feishuFailure(stage string, status int, code *int) error { if code != nil { return fmt.Errorf("%s失败(飞书错误码 %d,HTTP %d);请按错误码检查应用权限、发布状态及接口配置", stage, *code, status) } return fmt.Errorf("%s失败(HTTP %d,未获得飞书错误码);请检查飞书接口配置", stage, status) } func secureAdmin(c *gin.Context) bool { if c.Request.TLS != nil { return true } peer, _, err := net.SplitHostPort(c.Request.RemoteAddr) if err != nil || net.ParseIP(peer) == nil || !net.ParseIP(peer).IsLoopback() { return false } scheme := tlsservice.Status().Scheme if scheme == "" { scheme = "https" } return c.GetHeader("X-Forwarded-Proto") == scheme } 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"` } decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) if resp.StatusCode != http.StatusOK || decodeErr != nil || token.Code == nil || *token.Code != 0 { return "", "", feishuFailure("获取飞书访问凭据", resp.StatusCode, token.Code) } if token.TenantAccessToken == "" { return "", "", errors.New("飞书未返回 tenant_access_token") } 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"` } decodeErr = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) if resp.StatusCode != http.StatusOK || decodeErr != nil || result.Code == nil || *result.Code != 0 { return "", "", feishuFailure("查询飞书企业信息", resp.StatusCode, result.Code) } 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": "请通过可信公网管理入口查询飞书企业信息"}) 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 { 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, "user_scope_verified": cfg.UserScopeVerifiedAt != nil, }) } func (p *Provider) Configure(c *gin.Context) { if !secureAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信公网管理入口保存飞书凭据"}) 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 == "") || (req.TenantKey != "" && !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 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.AppSecret != "") && 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": "密钥过长"}) return } cfg.AppSecret, err = store.EncryptSecret(req.AppSecret) if err != nil { c.JSON(500, gin.H{"error": "密钥保存失败"}) return } } if cfg.AppID != req.AppID || req.AppSecret != "" || cfg.TenantKey != req.TenantKey { cfg.UserScopeVerifiedAt = nil } 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}) }