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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user