494 lines
17 KiB
Go
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()
|
|
}
|
|
}()
|
|
}
|