forked from casdoor/casdoor
548 lines
15 KiB
Go
548 lines
15 KiB
Go
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package object
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/casdoor/casdoor/util"
|
|
)
|
|
|
|
// DingtalkSyncerProvider implements SyncerProvider for DingTalk API-based syncers
|
|
type DingtalkSyncerProvider struct {
|
|
Syncer *Syncer
|
|
}
|
|
|
|
// InitAdapter initializes the DingTalk syncer (no database adapter needed)
|
|
func (p *DingtalkSyncerProvider) InitAdapter() error {
|
|
// DingTalk syncer doesn't need database adapter
|
|
return nil
|
|
}
|
|
|
|
// GetOriginalUsers retrieves all users from DingTalk API
|
|
func (p *DingtalkSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
|
|
return p.getDingtalkUsers()
|
|
}
|
|
|
|
// AddUser adds a new user to DingTalk (not supported for read-only API)
|
|
func (p *DingtalkSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
|
|
// DingTalk syncer is typically read-only
|
|
return false, fmt.Errorf("adding users to DingTalk is not supported")
|
|
}
|
|
|
|
// UpdateUser updates an existing user in DingTalk (not supported for read-only API)
|
|
func (p *DingtalkSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
|
|
// DingTalk syncer is typically read-only
|
|
return false, fmt.Errorf("updating users in DingTalk is not supported")
|
|
}
|
|
|
|
// TestConnection tests the DingTalk API connection
|
|
func (p *DingtalkSyncerProvider) TestConnection() error {
|
|
_, err := p.getDingtalkAccessToken()
|
|
return err
|
|
}
|
|
|
|
// Close closes any open connections (no-op for DingTalk API-based syncer)
|
|
func (p *DingtalkSyncerProvider) Close() error {
|
|
// DingTalk syncer doesn't maintain persistent connections
|
|
return nil
|
|
}
|
|
|
|
type DingtalkAccessTokenResp struct {
|
|
Errcode int `json:"errcode"`
|
|
Errmsg string `json:"errmsg"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
type DingtalkUser struct {
|
|
UserId string `json:"userid"`
|
|
UnionId string `json:"unionid"`
|
|
Name string `json:"name"`
|
|
Department []int `json:"dept_id_list"`
|
|
Position string `json:"title"`
|
|
Mobile string `json:"mobile"`
|
|
Email string `json:"email"`
|
|
Avatar string `json:"avatar"`
|
|
JobNumber string `json:"job_number"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
type DingtalkUserListResp struct {
|
|
Errcode int `json:"errcode"`
|
|
Errmsg string `json:"errmsg"`
|
|
Result *DingtalkResult `json:"result"`
|
|
RequestId string `json:"request_id"`
|
|
}
|
|
|
|
type DingtalkResult struct {
|
|
List []*DingtalkUser `json:"list"`
|
|
HasMore bool `json:"has_more"`
|
|
NextCursor int64 `json:"next_cursor"`
|
|
}
|
|
|
|
type DingtalkDeptListResp struct {
|
|
Errcode int `json:"errcode"`
|
|
Errmsg string `json:"errmsg"`
|
|
Result []struct {
|
|
DeptId int64 `json:"dept_id"`
|
|
} `json:"result"`
|
|
RequestId string `json:"request_id"`
|
|
}
|
|
|
|
type DingtalkDepartment struct {
|
|
DeptId int64 `json:"dept_id"`
|
|
Name string `json:"name"`
|
|
ParentId int64 `json:"parent_id"`
|
|
CreateDeptGroup bool `json:"create_dept_group"`
|
|
AutoAddUser bool `json:"auto_add_user"`
|
|
}
|
|
|
|
type DingtalkDeptDetailResp struct {
|
|
Errcode int `json:"errcode"`
|
|
Errmsg string `json:"errmsg"`
|
|
Result *DingtalkDepartment `json:"result"`
|
|
RequestId string `json:"request_id"`
|
|
}
|
|
|
|
// getDingtalkAccessToken gets access token from DingTalk API
|
|
func (p *DingtalkSyncerProvider) getDingtalkAccessToken() (string, error) {
|
|
// syncer.User should be the appKey
|
|
// syncer.Password should be the appSecret
|
|
appKey := p.Syncer.User
|
|
if appKey == "" {
|
|
return "", fmt.Errorf("appKey (user field) is required for DingTalk syncer")
|
|
}
|
|
|
|
appSecret := p.Syncer.Password
|
|
if appSecret == "" {
|
|
return "", fmt.Errorf("appSecret (password field) is required for DingTalk syncer")
|
|
}
|
|
|
|
apiUrl := fmt.Sprintf("https://oapi.dingtalk.com/gettoken?appkey=%s&appsecret=%s",
|
|
url.QueryEscape(appKey), url.QueryEscape(appSecret))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", apiUrl, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var tokenResp DingtalkAccessTokenResp
|
|
err = json.Unmarshal(data, &tokenResp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if tokenResp.Errcode != 0 {
|
|
return "", fmt.Errorf("failed to get access token: errcode=%d, errmsg=%s",
|
|
tokenResp.Errcode, tokenResp.Errmsg)
|
|
}
|
|
|
|
return tokenResp.AccessToken, nil
|
|
}
|
|
|
|
// getDingtalkDepartments gets all department IDs from DingTalk API recursively
|
|
func (p *DingtalkSyncerProvider) getDingtalkDepartments(accessToken string) ([]int64, error) {
|
|
return p.getDingtalkDepartmentsRecursive(accessToken, 1)
|
|
}
|
|
|
|
// getDingtalkDepartmentsRecursive recursively fetches all departments starting from parentDeptId
|
|
func (p *DingtalkSyncerProvider) getDingtalkDepartmentsRecursive(accessToken string, parentDeptId int64) ([]int64, error) {
|
|
apiUrl := fmt.Sprintf("https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=%s",
|
|
url.QueryEscape(accessToken))
|
|
|
|
postData := map[string]interface{}{
|
|
"dept_id": parentDeptId,
|
|
}
|
|
|
|
data, err := p.postJSON(apiUrl, postData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var deptResp DingtalkDeptListResp
|
|
err = json.Unmarshal(data, &deptResp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if deptResp.Errcode != 0 {
|
|
return nil, fmt.Errorf("failed to get departments: errcode=%d, errmsg=%s",
|
|
deptResp.Errcode, deptResp.Errmsg)
|
|
}
|
|
|
|
// Start with the parent department itself
|
|
deptIds := []int64{parentDeptId}
|
|
|
|
// Recursively fetch all child departments
|
|
for _, dept := range deptResp.Result {
|
|
childDeptIds, err := p.getDingtalkDepartmentsRecursive(accessToken, dept.DeptId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deptIds = append(deptIds, childDeptIds...)
|
|
}
|
|
|
|
return deptIds, nil
|
|
}
|
|
|
|
// getDingtalkDepartmentDetails gets detailed department information
|
|
func (p *DingtalkSyncerProvider) getDingtalkDepartmentDetails(accessToken string, deptId int64) (*DingtalkDepartment, error) {
|
|
apiUrl := fmt.Sprintf("https://oapi.dingtalk.com/topapi/v2/department/get?access_token=%s",
|
|
url.QueryEscape(accessToken))
|
|
|
|
postData := map[string]interface{}{
|
|
"dept_id": deptId,
|
|
}
|
|
|
|
data, err := p.postJSON(apiUrl, postData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp DingtalkDeptDetailResp
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.Errcode != 0 {
|
|
return nil, fmt.Errorf("failed to get department details for %d: errcode=%d, errmsg=%s",
|
|
deptId, resp.Errcode, resp.Errmsg)
|
|
}
|
|
|
|
return resp.Result, nil
|
|
}
|
|
|
|
// getDingtalkUsersFromDept gets users from a specific department
|
|
func (p *DingtalkSyncerProvider) getDingtalkUsersFromDept(accessToken string, deptId int64) ([]*DingtalkUser, error) {
|
|
allUsers := []*DingtalkUser{}
|
|
cursor := int64(0)
|
|
|
|
for {
|
|
apiUrl := fmt.Sprintf("https://oapi.dingtalk.com/topapi/user/listsimple?access_token=%s",
|
|
url.QueryEscape(accessToken))
|
|
|
|
postData := map[string]interface{}{
|
|
"dept_id": deptId,
|
|
"cursor": cursor,
|
|
"size": 100,
|
|
}
|
|
|
|
data, err := p.postJSON(apiUrl, postData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var userResp DingtalkUserListResp
|
|
err = json.Unmarshal(data, &userResp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if userResp.Errcode != 0 {
|
|
return nil, fmt.Errorf("failed to get users from dept %d: errcode=%d, errmsg=%s",
|
|
deptId, userResp.Errcode, userResp.Errmsg)
|
|
}
|
|
|
|
if userResp.Result != nil {
|
|
allUsers = append(allUsers, userResp.Result.List...)
|
|
|
|
if !userResp.Result.HasMore {
|
|
break
|
|
}
|
|
cursor = userResp.Result.NextCursor
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return allUsers, nil
|
|
}
|
|
|
|
// getDingtalkUserDetails gets detailed user information
|
|
func (p *DingtalkSyncerProvider) getDingtalkUserDetails(accessToken string, userId string) (*DingtalkUser, error) {
|
|
apiUrl := fmt.Sprintf("https://oapi.dingtalk.com/topapi/v2/user/get?access_token=%s",
|
|
url.QueryEscape(accessToken))
|
|
|
|
postData := map[string]interface{}{
|
|
"userid": userId,
|
|
}
|
|
|
|
data, err := p.postJSON(apiUrl, postData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp struct {
|
|
Errcode int `json:"errcode"`
|
|
Errmsg string `json:"errmsg"`
|
|
Result *DingtalkUser `json:"result"`
|
|
}
|
|
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.Errcode != 0 {
|
|
return nil, fmt.Errorf("failed to get user details for %s: errcode=%d, errmsg=%s",
|
|
userId, resp.Errcode, resp.Errmsg)
|
|
}
|
|
|
|
return resp.Result, nil
|
|
}
|
|
|
|
// postJSON sends a POST request with JSON body
|
|
func (p *DingtalkSyncerProvider) postJSON(url string, data map[string]interface{}) ([]byte, error) {
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respData, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return respData, nil
|
|
}
|
|
|
|
// getDingtalkUsers gets all users from DingTalk API
|
|
func (p *DingtalkSyncerProvider) getDingtalkUsers() ([]*OriginalUser, error) {
|
|
// Get access token
|
|
accessToken, err := p.getDingtalkAccessToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get all departments
|
|
deptIds, err := p.getDingtalkDepartments(accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get users from all departments (deduplicate by userid)
|
|
userMap := make(map[string]*DingtalkUser)
|
|
for _, deptId := range deptIds {
|
|
users, err := p.getDingtalkUsersFromDept(accessToken, deptId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, user := range users {
|
|
// Deduplicate users by userid
|
|
if _, exists := userMap[user.UserId]; !exists {
|
|
// Get detailed user information
|
|
detailedUser, err := p.getDingtalkUserDetails(accessToken, user.UserId)
|
|
if err != nil {
|
|
// Use basic user info if details fail
|
|
userMap[user.UserId] = user
|
|
} else {
|
|
userMap[user.UserId] = detailedUser
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert DingTalk users to Casdoor OriginalUser
|
|
originalUsers := []*OriginalUser{}
|
|
for _, dingtalkUser := range userMap {
|
|
originalUser := p.dingtalkUserToOriginalUser(dingtalkUser)
|
|
originalUsers = append(originalUsers, originalUser)
|
|
}
|
|
|
|
return originalUsers, nil
|
|
}
|
|
|
|
// getDingtalkUserFieldValue extracts a field value from DingtalkUser by field name
|
|
func (p *DingtalkSyncerProvider) getDingtalkUserFieldValue(dingtalkUser *DingtalkUser, fieldName string) string {
|
|
switch fieldName {
|
|
case "userid":
|
|
return dingtalkUser.UserId
|
|
case "unionid":
|
|
return dingtalkUser.UnionId
|
|
case "name":
|
|
return dingtalkUser.Name
|
|
case "email":
|
|
return dingtalkUser.Email
|
|
case "mobile":
|
|
return dingtalkUser.Mobile
|
|
case "avatar":
|
|
return dingtalkUser.Avatar
|
|
case "title":
|
|
return dingtalkUser.Position
|
|
case "job_number":
|
|
return dingtalkUser.JobNumber
|
|
case "active":
|
|
// Invert the boolean because active=true means NOT forbidden
|
|
return util.BoolToString(!dingtalkUser.Active)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// dingtalkUserToOriginalUser converts DingTalk user to Casdoor OriginalUser
|
|
func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *DingtalkUser) *OriginalUser {
|
|
user := &OriginalUser{
|
|
Address: []string{},
|
|
Properties: map[string]string{},
|
|
Groups: []string{},
|
|
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
|
|
}
|
|
|
|
// Apply TableColumns mapping if configured
|
|
if len(p.Syncer.TableColumns) > 0 {
|
|
for _, tableColumn := range p.Syncer.TableColumns {
|
|
value := p.getDingtalkUserFieldValue(dingtalkUser, tableColumn.Name)
|
|
p.Syncer.setUserByKeyValue(user, tableColumn.CasdoorName, value)
|
|
}
|
|
} else {
|
|
// Fallback to default mapping for backward compatibility
|
|
user.Id = dingtalkUser.UserId
|
|
user.Name = dingtalkUser.UserId
|
|
if dingtalkUser.UnionId != "" {
|
|
user.Name = dingtalkUser.UnionId
|
|
}
|
|
user.DisplayName = dingtalkUser.Name
|
|
user.Email = dingtalkUser.Email
|
|
user.Phone = dingtalkUser.Mobile
|
|
user.Avatar = dingtalkUser.Avatar
|
|
user.Title = dingtalkUser.Position
|
|
user.IsForbidden = !dingtalkUser.Active
|
|
}
|
|
|
|
// Add department IDs to Groups field
|
|
for _, deptId := range dingtalkUser.Department {
|
|
user.Groups = append(user.Groups, fmt.Sprintf("%d", deptId))
|
|
}
|
|
|
|
// Set CreatedTime to current time if not set
|
|
if user.CreatedTime == "" {
|
|
user.CreatedTime = util.GetCurrentTime()
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
// GetOriginalGroups retrieves all groups (departments) from DingTalk
|
|
func (p *DingtalkSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
|
|
// Get access token
|
|
accessToken, err := p.getDingtalkAccessToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get all department IDs
|
|
deptIds, err := p.getDingtalkDepartments(accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get detailed information for each department
|
|
originalGroups := []*OriginalGroup{}
|
|
for _, deptId := range deptIds {
|
|
dept, err := p.getDingtalkDepartmentDetails(accessToken, deptId)
|
|
if err != nil {
|
|
// Log error but continue with other departments
|
|
fmt.Printf("Warning: failed to get details for department %d: %v\n", deptId, err)
|
|
continue
|
|
}
|
|
|
|
originalGroup := p.dingtalkDepartmentToOriginalGroup(dept)
|
|
originalGroups = append(originalGroups, originalGroup)
|
|
}
|
|
|
|
return originalGroups, nil
|
|
}
|
|
|
|
// dingtalkDepartmentToOriginalGroup converts DingTalk department to Casdoor OriginalGroup
|
|
func (p *DingtalkSyncerProvider) dingtalkDepartmentToOriginalGroup(dept *DingtalkDepartment) *OriginalGroup {
|
|
// Convert department ID to string for group ID
|
|
deptIdStr := fmt.Sprintf("%d", dept.DeptId)
|
|
|
|
return &OriginalGroup{
|
|
Id: deptIdStr,
|
|
Name: deptIdStr, // Use ID as name for uniqueness
|
|
DisplayName: dept.Name, // Use actual name as display name
|
|
Description: "", // DingTalk doesn't provide description
|
|
Type: "department", // Mark as department type
|
|
Manager: "", // DingTalk doesn't provide manager in dept details
|
|
Email: "", // DingTalk doesn't provide email for departments
|
|
}
|
|
}
|
|
|
|
// GetOriginalUserGroups retrieves the group (department) IDs that a user belongs to
|
|
func (p *DingtalkSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
|
|
// Get access token
|
|
accessToken, err := p.getDingtalkAccessToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get detailed user information which includes department list
|
|
user, err := p.getDingtalkUserDetails(accessToken, userId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert department IDs to strings
|
|
groupIds := []string{}
|
|
for _, deptId := range user.Department {
|
|
groupIds = append(groupIds, fmt.Sprintf("%d", deptId))
|
|
}
|
|
|
|
return groupIds, nil
|
|
}
|