Files

494 lines
17 KiB
Go

package identityprovider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
const contactAPI = "https://open.feishu.cn/open-apis/contact/v3"
var directoryRunMu sync.Mutex
var directoryRunning bool
type directorySnapshot struct {
departments map[string]model.FeishuDepartment
members map[string]model.FeishuDirectoryMember
visibleDepartments int
visibleUsers int
}
// DirectorySyncCheck probes only the app-visible scope and the first page of
// directory endpoints. It cannot attest to app publication or all-employee scope.
func (p *Provider) DirectorySyncCheck(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口检测飞书权限"})
return
}
cfg, _, err := enabled()
if err != nil {
c.JSON(409, gin.H{"error": "请先启用飞书身份服务"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 22*time.Second)
defer cancel()
secret, err := store.DecryptSecret(cfg.AppSecret)
if err != nil {
c.JSON(503, gin.H{"error": "读取飞书应用凭据失败"})
return
}
result, err := checkDirectoryAccess(ctx, cfg.AppID, secret)
if err != nil {
c.JSON(502, gin.H{"error": err.Error()})
return
}
c.JSON(200, result)
}
func checkDirectoryAccess(ctx context.Context, appID, secret string) (gin.H, error) {
token, err := directoryTenantToken(ctx, appID, secret)
if err != nil {
return nil, err
}
var scope struct {
DepartmentIDs []string `json:"department_ids"`
UserIDs []string `json:"user_ids"`
}
if err := directoryGet(ctx, token, "/scopes", url.Values{"department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}}, &scope); err != nil {
return nil, err
}
result := gin.H{"visible_departments": len(scope.DepartmentIDs), "visible_users": len(scope.UserIDs), "department_access": "unverified", "user_list_access": "unverified"}
if len(scope.DepartmentIDs) == 0 {
return result, nil
}
id := scope.DepartmentIDs[0]
if id == "" || len(id) > 128 {
return nil, errors.New("飞书部门标识无效")
}
var ignored struct{}
if err := directoryGet(ctx, token, "/departments/"+url.PathEscape(id)+"/children", url.Values{"department_id_type": {"open_department_id"}, "page_size": {"1"}}, &ignored); err != nil {
result["department_access"] = "denied"
result["department_error"] = err.Error()
} else {
result["department_access"] = "ok"
}
if err := directoryGet(ctx, token, "/users", url.Values{"department_id": {id}, "department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}, "page_size": {"1"}}, &ignored); err != nil {
result["user_list_access"] = "denied"
result["user_list_error"] = err.Error()
} else {
result["user_list_access"] = "ok"
}
return result, nil
}
func (p *Provider) DirectorySyncStatus(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口查看通讯录"})
return
}
cfg, base, err := enabled()
if err != nil {
c.JSON(409, gin.H{"error": "请先启用飞书身份服务"})
return
}
status := syncStatus(cfg, base)
var members []model.FeishuDirectoryMember
if err := store.DB.Where("issuer = ? AND app_id = ?", base, cfg.AppID).Order("name asc").Limit(500).Find(&members).Error; err != nil {
c.JSON(500, gin.H{"error": "读取成员失败"})
return
}
var departments []model.FeishuDepartment
if err := store.DB.Where("issuer = ? AND app_id = ?", base, cfg.AppID).Order("name asc").Limit(500).Find(&departments).Error; err != nil {
c.JSON(500, gin.H{"error": "读取部门失败"})
return
}
departmentItems := make([]gin.H, 0, len(departments))
for _, d := range departments {
departmentItems = append(departmentItems, gin.H{"id": d.Department, "name": d.Name, "parent_id": d.Parent, "last_seen_at": d.LastSeenAt})
}
directoryRunMu.Lock()
running := directoryRunning
directoryRunMu.Unlock()
c.JSON(200, gin.H{"status": status, "running": running, "members": members, "departments": departmentItems})
}
func syncStatus(cfg model.IdentityProvider, base string) model.FeishuDirectorySync {
var status model.FeishuDirectorySync
if store.DB.Where("id = ? AND issuer = ? AND app_id = ?", 1, base, cfg.AppID).First(&status).Error != nil {
status = model.FeishuDirectorySync{ID: 1, Issuer: base, AppID: cfg.AppID}
}
return status
}
func (p *Provider) DirectorySyncConfigure(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口配置同步"})
return
}
cfg, base, err := enabled()
if err != nil {
c.JSON(409, gin.H{"error": "请先启用飞书身份服务"})
return
}
var input struct {
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&input) != nil || input.Enabled == nil {
c.JSON(400, gin.H{"error": "配置格式错误"})
return
}
directoryRunMu.Lock()
defer directoryRunMu.Unlock()
if directoryRunning {
c.JSON(409, gin.H{"error": "请等待当前同步完成后再修改开关"})
return
}
status := syncStatus(cfg, base)
status.Enabled = *input.Enabled
if err := store.DB.Save(&status).Error; err != nil {
c.JSON(500, gin.H{"error": "保存同步设置失败"})
return
}
store.Audit(c.GetString("username"), "feishu_directory_schedule", cfg.AppID, fmt.Sprint(status.Enabled))
c.JSON(200, gin.H{"status": status})
}
func (p *Provider) DirectorySyncRun(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口同步"})
return
}
directoryRunMu.Lock()
if directoryRunning {
directoryRunMu.Unlock()
c.JSON(409, gin.H{"error": "同步正在进行"})
return
}
directoryRunning = true
directoryRunMu.Unlock()
actor := c.GetString("username")
go func() {
defer func() { directoryRunMu.Lock(); directoryRunning = false; directoryRunMu.Unlock() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
status, err := performDirectorySync(ctx)
if err != nil {
store.Audit(actor, "feishu_directory_sync_failed", status.AppID, "")
} else {
store.Audit(actor, "feishu_directory_sync", status.AppID, fmt.Sprintf("departments=%d members=%d", status.Departments, status.Members))
}
}()
c.JSON(202, gin.H{"running": true})
}
var errDirectoryBusy = errors.New("directory sync busy")
func runDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error) {
directoryRunMu.Lock()
if directoryRunning {
directoryRunMu.Unlock()
return model.FeishuDirectorySync{}, errDirectoryBusy
}
directoryRunning = true
directoryRunMu.Unlock()
defer func() { directoryRunMu.Lock(); directoryRunning = false; directoryRunMu.Unlock() }()
return performDirectorySync(ctx)
}
func performDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error) {
cfg, base, err := enabled()
if err != nil {
return model.FeishuDirectorySync{}, errors.New("飞书身份服务未启用")
}
status := syncStatus(cfg, base)
now := time.Now()
status.LastAttempt = &now
status.LastError = ""
if err := store.DB.Save(&status).Error; err != nil {
return status, errors.New("保存同步状态失败")
}
secret, err := store.DecryptSecret(cfg.AppSecret)
if err != nil {
return failedSync(status, errors.New("读取飞书应用凭据失败"))
}
token, err := directoryTenantToken(ctx, cfg.AppID, secret)
if err != nil {
return failedSync(status, err)
}
snapshot, err := readDirectory(ctx, token)
if err != nil {
return failedSync(status, err)
}
currentCfg, currentBase, err := enabled()
if err != nil || currentBase != base || currentCfg.AppID != cfg.AppID || currentCfg.TenantKey != cfg.TenantKey {
return failedSync(status, errors.New("飞书应用配置已变化,请重新同步"))
}
now = time.Now()
status.LastSuccess, status.Departments, status.Members = &now, len(snapshot.departments), len(snapshot.members)
status.VisibleDepartments, status.VisibleUsers = snapshot.visibleDepartments, snapshot.visibleUsers
status.LastError = ""
err = store.DB.Transaction(func(tx *gorm.DB) error {
for _, dept := range snapshot.departments {
dept.Issuer, dept.AppID, dept.LastSeenAt = base, cfg.AppID, now
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "department"}}, DoUpdates: clause.AssignmentColumns([]string{"parent", "name", "last_seen_at"})}).Create(&dept).Error; err != nil {
return err
}
}
for _, member := range snapshot.members {
member.Issuer, member.AppID, member.LastSeenAt = base, cfg.AppID, now
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "open_id"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "departments", "last_seen_at"})}).Create(&member).Error; err != nil {
return err
}
}
return tx.Model(&model.FeishuDirectorySync{}).Where("id = ? AND issuer = ? AND app_id = ?", 1, base, cfg.AppID).Updates(map[string]any{
"last_success": now, "last_error": "", "departments": status.Departments, "members": status.Members,
"visible_departments": status.VisibleDepartments, "visible_users": status.VisibleUsers,
}).Error
})
if err != nil {
return failedSync(status, errors.New("写入通讯录失败"))
}
return status, nil
}
func failedSync(status model.FeishuDirectorySync, err error) (model.FeishuDirectorySync, error) {
status.LastError = err.Error()
_ = store.DB.Model(&model.FeishuDirectorySync{}).Where("id = ? AND issuer = ? AND app_id = ?", 1, status.Issuer, status.AppID).Update("last_error", status.LastError).Error
return status, err
}
func directoryTenantToken(ctx context.Context, appID, secret string) (string, error) {
payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": secret})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuTenantToken, strings.NewReader(string(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 result struct {
Code *int `json:"code"`
Token string `json:"tenant_access_token"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 || result.Token == "" {
return "", feishuFailure("获取应用凭据", resp.StatusCode, result.Code)
}
return result.Token, nil
}
func directoryGet(ctx context.Context, token, path string, query url.Values, out any) error {
u := contactAPI + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := upstreamClient.Do(req)
if err != nil {
return errors.New("飞书通讯录请求失败")
}
defer resp.Body.Close()
var result struct {
Code *int `json:"code"`
Data json.RawMessage `json:"data"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result) != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 {
return feishuFailure("读取飞书通讯录", resp.StatusCode, result.Code)
}
if json.Unmarshal(result.Data, out) != nil {
return errors.New("飞书通讯录返回格式错误")
}
return nil
}
func readDirectory(ctx context.Context, token string) (directorySnapshot, error) {
snapshot := directorySnapshot{departments: map[string]model.FeishuDepartment{}, members: map[string]model.FeishuDirectoryMember{}}
var scope struct {
DepartmentIDs []string `json:"department_ids"`
UserIDs []string `json:"user_ids"`
}
if err := directoryGet(ctx, token, "/scopes", url.Values{"department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}}, &scope); err != nil {
return snapshot, err
}
if len(scope.DepartmentIDs) == 0 && len(scope.UserIDs) == 0 {
return snapshot, errors.New("飞书应用通讯录可见范围为空;请配置成员和部门可见范围")
}
snapshot.visibleDepartments, snapshot.visibleUsers = len(scope.DepartmentIDs), len(scope.UserIDs)
queue, seen := append([]string{}, scope.DepartmentIDs...), map[string]bool{}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
if id == "" || len(id) > 128 {
return snapshot, errors.New("飞书部门标识无效")
}
if seen[id] {
continue
}
seen[id] = true
if len(seen) > 500 {
return snapshot, errors.New("部门超过同步上限 500")
}
if _, ok := snapshot.departments[id]; !ok {
snapshot.departments[id] = model.FeishuDepartment{Department: id, Name: id}
}
if err := readDirectoryPages(ctx, token, "/departments/"+url.PathEscape(id)+"/children", url.Values{"department_id_type": {"open_department_id"}}, func(raw json.RawMessage) error {
var items []struct {
ID string `json:"open_department_id"`
Name string `json:"name"`
Parent string `json:"parent_department_id"`
}
if err := json.Unmarshal(raw, &items); err != nil {
return err
}
for _, d := range items {
if d.ID == "" || len(d.ID) > 128 || len(d.Name) > 256 {
return errors.New("飞书部门信息无效")
}
snapshot.departments[d.ID] = model.FeishuDepartment{Department: d.ID, Name: d.Name, Parent: d.Parent}
if !seen[d.ID] {
queue = append(queue, d.ID)
}
}
return nil
}); err != nil {
return snapshot, err
}
if err := readDirectoryPages(ctx, token, "/users", url.Values{"department_id": {id}, "department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}}, func(raw json.RawMessage) error {
var items []struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
Departments []string `json:"department_ids"`
}
if err := json.Unmarshal(raw, &items); err != nil {
return err
}
for _, u := range items {
if !strings.HasPrefix(u.OpenID, "ou_") || len(u.OpenID) > 128 || len(u.Name) > 256 {
return errors.New("飞书用户标识或姓名无效")
}
deps := u.Departments
if len(deps) == 0 {
deps = []string{id}
}
if old, ok := snapshot.members[u.OpenID]; ok {
var existing []string
_ = json.Unmarshal([]byte(old.Departments), &existing)
deps = append(deps, existing...)
}
unique := map[string]bool{}
for _, department := range deps {
unique[department] = true
}
deps = deps[:0]
for department := range unique {
deps = append(deps, department)
}
sort.Strings(deps)
encoded, _ := json.Marshal(deps)
snapshot.members[u.OpenID] = model.FeishuDirectoryMember{OpenID: u.OpenID, Name: u.Name, Departments: string(encoded)}
if len(snapshot.members) > 20000 {
return errors.New("成员超过同步上限 20000")
}
}
return nil
}); err != nil {
return snapshot, err
}
}
// Explicitly visible users outside visible departments must also be imported.
for _, id := range scope.UserIDs {
if _, ok := snapshot.members[id]; ok {
continue
}
if !strings.HasPrefix(id, "ou_") || len(id) > 128 {
return snapshot, errors.New("飞书成员标识无效")
}
name, err := fetchBasicName(ctx, token, id)
if err != nil {
return snapshot, errors.New("读取独立可见成员失败;请检查用户基础信息权限")
}
snapshot.members[id] = model.FeishuDirectoryMember{OpenID: id, Name: name, Departments: "[]"}
if len(snapshot.members) > 20000 {
return snapshot, errors.New("成员超过同步上限 20000")
}
}
return snapshot, nil
}
func readDirectoryPages(ctx context.Context, token, path string, query url.Values, consume func(json.RawMessage) error) error {
visited := map[string]bool{}
for page := 0; page < 300; page++ {
query.Set("page_size", "100")
var data struct {
Items json.RawMessage `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
if err := directoryGet(ctx, token, path, query, &data); err != nil {
return err
}
if err := consume(data.Items); err != nil {
return errors.New("飞书通讯录分页数据无效")
}
if !data.HasMore {
return nil
}
if data.PageToken == "" || visited[data.PageToken] {
return errors.New("飞书通讯录分页游标无效")
}
visited[data.PageToken] = true
query.Set("page_token", data.PageToken)
}
return errors.New("飞书通讯录分页超过上限")
}
// StartDirectoryScheduler never imports until an administrator explicitly enables it.
func StartDirectoryScheduler(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
cfg, base, err := enabled()
if err != nil {
continue
}
status := syncStatus(cfg, base)
if !status.Enabled || (status.LastAttempt != nil && time.Since(*status.LastAttempt) < 30*time.Minute) {
continue
}
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
_, _ = runDirectorySync(runCtx)
cancel()
}
}()
}