forked from casdoor/casdoor
350 lines
10 KiB
Go
350 lines
10 KiB
Go
// Copyright 2025 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/casdoor/casdoor/util"
|
|
)
|
|
|
|
// SCIMSyncerProvider implements SyncerProvider for SCIM 2.0 API-based syncers
|
|
type SCIMSyncerProvider struct {
|
|
Syncer *Syncer
|
|
}
|
|
|
|
// InitAdapter initializes the SCIM syncer (no database adapter needed)
|
|
func (p *SCIMSyncerProvider) InitAdapter() error {
|
|
// SCIM syncer doesn't need database adapter
|
|
return nil
|
|
}
|
|
|
|
// GetOriginalUsers retrieves all users from SCIM API
|
|
func (p *SCIMSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
|
|
return p.getSCIMUsers()
|
|
}
|
|
|
|
// AddUser adds a new user to SCIM (not supported for read-only API)
|
|
func (p *SCIMSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
|
|
// SCIM syncer is typically read-only
|
|
return false, fmt.Errorf("adding users to SCIM is not supported")
|
|
}
|
|
|
|
// UpdateUser updates an existing user in SCIM (not supported for read-only API)
|
|
func (p *SCIMSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
|
|
// SCIM syncer is typically read-only
|
|
return false, fmt.Errorf("updating users in SCIM is not supported")
|
|
}
|
|
|
|
// TestConnection tests the SCIM API connection
|
|
func (p *SCIMSyncerProvider) TestConnection() error {
|
|
// Test by trying to fetch users with a limit of 1
|
|
endpoint := p.buildSCIMEndpoint()
|
|
endpoint = fmt.Sprintf("%s?startIndex=1&count=1", endpoint)
|
|
|
|
req, err := p.createSCIMRequest("GET", endpoint, 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()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("SCIM connection test failed: status=%d, body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes any open connections (no-op for SCIM API-based syncer)
|
|
func (p *SCIMSyncerProvider) Close() error {
|
|
// SCIM syncer doesn't maintain persistent connections
|
|
return nil
|
|
}
|
|
|
|
// SCIMName represents a SCIM user name structure
|
|
type SCIMName struct {
|
|
FamilyName string `json:"familyName"`
|
|
GivenName string `json:"givenName"`
|
|
Formatted string `json:"formatted"`
|
|
}
|
|
|
|
// SCIMEmail represents a SCIM user email structure
|
|
type SCIMEmail struct {
|
|
Value string `json:"value"`
|
|
Type string `json:"type"`
|
|
Primary bool `json:"primary"`
|
|
}
|
|
|
|
// SCIMPhoneNumber represents a SCIM user phone number structure
|
|
type SCIMPhoneNumber struct {
|
|
Value string `json:"value"`
|
|
Type string `json:"type"`
|
|
Primary bool `json:"primary"`
|
|
}
|
|
|
|
// SCIMAddress represents a SCIM user address structure
|
|
type SCIMAddress struct {
|
|
StreetAddress string `json:"streetAddress"`
|
|
Locality string `json:"locality"`
|
|
Region string `json:"region"`
|
|
PostalCode string `json:"postalCode"`
|
|
Country string `json:"country"`
|
|
Formatted string `json:"formatted"`
|
|
Type string `json:"type"`
|
|
Primary bool `json:"primary"`
|
|
}
|
|
|
|
// SCIMUser represents a SCIM 2.0 user resource
|
|
type SCIMUser struct {
|
|
ID string `json:"id"`
|
|
ExternalID string `json:"externalId"`
|
|
UserName string `json:"userName"`
|
|
Name SCIMName `json:"name"`
|
|
DisplayName string `json:"displayName"`
|
|
NickName string `json:"nickName"`
|
|
ProfileURL string `json:"profileUrl"`
|
|
Title string `json:"title"`
|
|
UserType string `json:"userType"`
|
|
PreferredLan string `json:"preferredLanguage"`
|
|
Locale string `json:"locale"`
|
|
Timezone string `json:"timezone"`
|
|
Active bool `json:"active"`
|
|
Emails []SCIMEmail `json:"emails"`
|
|
PhoneNumbers []SCIMPhoneNumber `json:"phoneNumbers"`
|
|
Addresses []SCIMAddress `json:"addresses"`
|
|
}
|
|
|
|
// SCIMListResponse represents a SCIM list response
|
|
type SCIMListResponse struct {
|
|
TotalResults int `json:"totalResults"`
|
|
ItemsPerPage int `json:"itemsPerPage"`
|
|
StartIndex int `json:"startIndex"`
|
|
Resources []*SCIMUser `json:"Resources"`
|
|
}
|
|
|
|
// buildSCIMEndpoint builds the SCIM API endpoint URL
|
|
func (p *SCIMSyncerProvider) buildSCIMEndpoint() string {
|
|
// syncer.Host should be the SCIM server URL (e.g., https://example.com/scim/v2)
|
|
host := strings.TrimSuffix(p.Syncer.Host, "/")
|
|
return fmt.Sprintf("%s/Users", host)
|
|
}
|
|
|
|
// createSCIMRequest creates an HTTP request with proper authentication
|
|
func (p *SCIMSyncerProvider) createSCIMRequest(method, url string, body io.Reader) (*http.Request, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set SCIM headers
|
|
req.Header.Set("Content-Type", "application/scim+json")
|
|
req.Header.Set("Accept", "application/scim+json")
|
|
|
|
// Add authentication
|
|
// syncer.User should be the authentication token or username
|
|
// syncer.Password should be the password or API key
|
|
if p.Syncer.User != "" && p.Syncer.Password != "" {
|
|
// Try Basic Auth
|
|
req.SetBasicAuth(p.Syncer.User, p.Syncer.Password)
|
|
} else if p.Syncer.Password != "" {
|
|
// Try Bearer token (assuming password field contains the token)
|
|
req.Header.Set("Authorization", "Bearer "+p.Syncer.Password)
|
|
} else if p.Syncer.User != "" {
|
|
// Try Bearer token (assuming user field contains the token)
|
|
req.Header.Set("Authorization", "Bearer "+p.Syncer.User)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// getSCIMUsers retrieves all users from SCIM API with pagination
|
|
func (p *SCIMSyncerProvider) getSCIMUsers() ([]*OriginalUser, error) {
|
|
allUsers := []*SCIMUser{}
|
|
startIndex := 1
|
|
count := 100 // Fetch 100 users per page
|
|
|
|
for {
|
|
endpoint := p.buildSCIMEndpoint()
|
|
endpoint = fmt.Sprintf("%s?startIndex=%d&count=%d", endpoint, startIndex, count)
|
|
|
|
req, err := p.createSCIMRequest("GET", endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("failed to get users: status=%d, body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var listResp SCIMListResponse
|
|
err = json.Unmarshal(body, &listResp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allUsers = append(allUsers, listResp.Resources...)
|
|
|
|
// Check if we've fetched all users
|
|
if len(allUsers) >= listResp.TotalResults {
|
|
break
|
|
}
|
|
|
|
// Move to the next page
|
|
startIndex += count
|
|
}
|
|
|
|
// Convert SCIM users to Casdoor OriginalUser
|
|
originalUsers := []*OriginalUser{}
|
|
for _, scimUser := range allUsers {
|
|
originalUser := p.scimUserToOriginalUser(scimUser)
|
|
originalUsers = append(originalUsers, originalUser)
|
|
}
|
|
|
|
return originalUsers, nil
|
|
}
|
|
|
|
// scimUserToOriginalUser converts SCIM user to Casdoor OriginalUser
|
|
func (p *SCIMSyncerProvider) scimUserToOriginalUser(scimUser *SCIMUser) *OriginalUser {
|
|
user := &OriginalUser{
|
|
Id: scimUser.ID,
|
|
ExternalId: scimUser.ExternalID,
|
|
Name: scimUser.UserName,
|
|
DisplayName: scimUser.DisplayName,
|
|
FirstName: scimUser.Name.GivenName,
|
|
LastName: scimUser.Name.FamilyName,
|
|
Title: scimUser.Title,
|
|
Language: scimUser.PreferredLan,
|
|
Address: []string{},
|
|
Properties: map[string]string{},
|
|
Groups: []string{},
|
|
}
|
|
|
|
// If display name is from name structure
|
|
if user.DisplayName == "" && scimUser.Name.Formatted != "" {
|
|
user.DisplayName = scimUser.Name.Formatted
|
|
}
|
|
|
|
// If display name is still empty, construct from first and last name
|
|
if user.DisplayName == "" && (user.FirstName != "" || user.LastName != "") {
|
|
user.DisplayName = strings.TrimSpace(fmt.Sprintf("%s %s", user.FirstName, user.LastName))
|
|
}
|
|
|
|
// Extract primary email or first email
|
|
if len(scimUser.Emails) > 0 {
|
|
for _, email := range scimUser.Emails {
|
|
if email.Primary {
|
|
user.Email = email.Value
|
|
break
|
|
}
|
|
}
|
|
// If no primary email, use the first one
|
|
if user.Email == "" && len(scimUser.Emails) > 0 {
|
|
user.Email = scimUser.Emails[0].Value
|
|
}
|
|
}
|
|
|
|
// Extract primary phone or first phone
|
|
if len(scimUser.PhoneNumbers) > 0 {
|
|
for _, phone := range scimUser.PhoneNumbers {
|
|
if phone.Primary {
|
|
user.Phone = phone.Value
|
|
break
|
|
}
|
|
}
|
|
// If no primary phone, use the first one
|
|
if user.Phone == "" && len(scimUser.PhoneNumbers) > 0 {
|
|
user.Phone = scimUser.PhoneNumbers[0].Value
|
|
}
|
|
}
|
|
|
|
// Extract primary address or first address
|
|
if len(scimUser.Addresses) > 0 {
|
|
for _, addr := range scimUser.Addresses {
|
|
if addr.Primary {
|
|
if addr.Formatted != "" {
|
|
user.Address = []string{addr.Formatted}
|
|
} else {
|
|
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
|
|
}
|
|
user.Location = addr.Locality
|
|
user.Region = addr.Region
|
|
break
|
|
}
|
|
}
|
|
// If no primary address, use the first one
|
|
if len(user.Address) == 0 && len(scimUser.Addresses) > 0 {
|
|
addr := scimUser.Addresses[0]
|
|
if addr.Formatted != "" {
|
|
user.Address = []string{addr.Formatted}
|
|
} else {
|
|
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
|
|
}
|
|
user.Location = addr.Locality
|
|
user.Region = addr.Region
|
|
}
|
|
}
|
|
|
|
// Set IsForbidden based on Active status
|
|
user.IsForbidden = !scimUser.Active
|
|
|
|
// Set CreatedTime to current time if not set
|
|
if user.CreatedTime == "" {
|
|
user.CreatedTime = util.GetCurrentTime()
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
// GetOriginalGroups retrieves all groups from SCIM (not implemented yet)
|
|
func (p *SCIMSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
|
|
// TODO: Implement SCIM group sync
|
|
return []*OriginalGroup{}, nil
|
|
}
|
|
|
|
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
|
|
func (p *SCIMSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
|
|
// TODO: Implement SCIM user group membership sync
|
|
return []string{}, nil
|
|
}
|