Files
opsx-vpn/backend/internal/identityprovider/admin.go
T

292 lines
9.6 KiB
Go

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})
}