forked from casdoor/casdoor
385 lines
12 KiB
Go
385 lines
12 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 (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/casdoor/casdoor/util"
|
|
goldap "github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
// convertGUIDToString converts a binary GUID byte array to a standard UUID string format
|
|
// Active Directory GUIDs are 16 bytes in a specific byte order
|
|
func convertGUIDToString(guidBytes []byte) string {
|
|
if len(guidBytes) != 16 {
|
|
return ""
|
|
}
|
|
|
|
// Active Directory GUID format is:
|
|
// Data1 (4 bytes, little-endian) - Data2 (2 bytes, little-endian) - Data3 (2 bytes, little-endian) - Data4 (2 bytes, big-endian) - Data5 (6 bytes, big-endian)
|
|
// Convert to standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
|
guidBytes[3], guidBytes[2], guidBytes[1], guidBytes[0], // Data1 (reverse byte order)
|
|
guidBytes[5], guidBytes[4], // Data2 (reverse byte order)
|
|
guidBytes[7], guidBytes[6], // Data3 (reverse byte order)
|
|
guidBytes[8], guidBytes[9], // Data4 (big-endian)
|
|
guidBytes[10], guidBytes[11], guidBytes[12], guidBytes[13], guidBytes[14], guidBytes[15]) // Data5 (big-endian)
|
|
}
|
|
|
|
// sanitizeUTF8 ensures the string contains only valid UTF-8 characters
|
|
// Invalid UTF-8 sequences are replaced with the Unicode replacement character
|
|
func sanitizeUTF8(s string) string {
|
|
if utf8.ValidString(s) {
|
|
return s
|
|
}
|
|
|
|
// Build a new string with only valid UTF-8
|
|
var builder strings.Builder
|
|
builder.Grow(len(s))
|
|
|
|
for _, r := range s {
|
|
if r == utf8.RuneError {
|
|
// Skip invalid runes
|
|
continue
|
|
}
|
|
builder.WriteRune(r)
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
// getAttributeValueSafe safely retrieves an LDAP attribute value and ensures it's valid UTF-8
|
|
func getAttributeValueSafe(entry *goldap.Entry, attributeName string) string {
|
|
value := entry.GetAttributeValue(attributeName)
|
|
return sanitizeUTF8(value)
|
|
}
|
|
|
|
// ActiveDirectorySyncerProvider implements SyncerProvider for Active Directory LDAP-based syncers
|
|
type ActiveDirectorySyncerProvider struct {
|
|
Syncer *Syncer
|
|
}
|
|
|
|
// InitAdapter initializes the Active Directory syncer (no database adapter needed)
|
|
func (p *ActiveDirectorySyncerProvider) InitAdapter() error {
|
|
// Active Directory syncer doesn't need database adapter
|
|
return nil
|
|
}
|
|
|
|
// GetOriginalUsers retrieves all users from Active Directory via LDAP
|
|
func (p *ActiveDirectorySyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
|
|
return p.getActiveDirectoryUsers()
|
|
}
|
|
|
|
// AddUser adds a new user to Active Directory (not supported for read-only LDAP)
|
|
func (p *ActiveDirectorySyncerProvider) AddUser(user *OriginalUser) (bool, error) {
|
|
// Active Directory syncer is typically read-only
|
|
return false, fmt.Errorf("adding users to Active Directory is not supported")
|
|
}
|
|
|
|
// UpdateUser updates an existing user in Active Directory (not supported for read-only LDAP)
|
|
func (p *ActiveDirectorySyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
|
|
// Active Directory syncer is typically read-only
|
|
return false, fmt.Errorf("updating users in Active Directory is not supported")
|
|
}
|
|
|
|
// TestConnection tests the Active Directory LDAP connection
|
|
func (p *ActiveDirectorySyncerProvider) TestConnection() error {
|
|
conn, err := p.getLdapConn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
return nil
|
|
}
|
|
|
|
// Close closes any open connections (no-op for Active Directory LDAP-based syncer)
|
|
func (p *ActiveDirectorySyncerProvider) Close() error {
|
|
// Active Directory syncer doesn't maintain persistent connections
|
|
// LDAP connections are opened and closed per operation
|
|
return nil
|
|
}
|
|
|
|
// getLdapConn establishes an LDAP connection to Active Directory
|
|
func (p *ActiveDirectorySyncerProvider) getLdapConn() (*goldap.Conn, error) {
|
|
// syncer.Host should be the AD server hostname/IP
|
|
// syncer.Port should be the LDAP port (usually 389 or 636 for LDAPS)
|
|
// syncer.User should be the bind DN or username
|
|
// syncer.Password should be the bind password
|
|
|
|
host := p.Syncer.Host
|
|
if host == "" {
|
|
return nil, fmt.Errorf("host is required for Active Directory syncer")
|
|
}
|
|
|
|
port := p.Syncer.Port
|
|
if port == 0 {
|
|
port = 389 // Default LDAP port
|
|
}
|
|
|
|
user := p.Syncer.User
|
|
if user == "" {
|
|
return nil, fmt.Errorf("user (bind DN) is required for Active Directory syncer")
|
|
}
|
|
|
|
password := p.Syncer.Password
|
|
if password == "" {
|
|
return nil, fmt.Errorf("password is required for Active Directory syncer")
|
|
}
|
|
|
|
var conn *goldap.Conn
|
|
var err error
|
|
|
|
// Check if SSL is enabled (port 636 typically indicates LDAPS)
|
|
if port == 636 {
|
|
tlsConfig := &tls.Config{
|
|
InsecureSkipVerify: true, // TODO: Make this configurable
|
|
}
|
|
conn, err = goldap.DialTLS("tcp", fmt.Sprintf("%s:%d", host, port), tlsConfig)
|
|
} else {
|
|
conn, err = goldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to Active Directory: %w", err)
|
|
}
|
|
|
|
// Bind with the provided credentials
|
|
err = conn.Bind(user, password)
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("failed to bind to Active Directory: %w", err)
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
// getActiveDirectoryUsers retrieves all users from Active Directory
|
|
func (p *ActiveDirectorySyncerProvider) getActiveDirectoryUsers() ([]*OriginalUser, error) {
|
|
conn, err := p.getLdapConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Use the Database field to store the base DN for searching
|
|
baseDN := p.Syncer.Database
|
|
if baseDN == "" {
|
|
return nil, fmt.Errorf("database field (base DN) is required for Active Directory syncer")
|
|
}
|
|
|
|
// Search filter for user objects in Active Directory
|
|
// Filter for users: objectClass=user, objectCategory=person, and not disabled accounts
|
|
searchFilter := "(&(objectClass=user)(objectCategory=person))"
|
|
|
|
// Attributes to retrieve from Active Directory
|
|
attributes := []string{
|
|
"sAMAccountName", // Username
|
|
"userPrincipalName", // UPN (email-like format)
|
|
"displayName", // Display name
|
|
"givenName", // First name
|
|
"sn", // Last name (surname)
|
|
"mail", // Email address
|
|
"telephoneNumber", // Phone number
|
|
"mobile", // Mobile phone
|
|
"title", // Job title
|
|
"department", // Department
|
|
"company", // Company
|
|
"streetAddress", // Street address
|
|
"l", // City/Locality
|
|
"st", // State/Province
|
|
"postalCode", // Postal code
|
|
"co", // Country
|
|
"objectGUID", // Unique identifier
|
|
"whenCreated", // Creation time
|
|
"userAccountControl", // Account status
|
|
}
|
|
|
|
searchRequest := goldap.NewSearchRequest(
|
|
baseDN,
|
|
goldap.ScopeWholeSubtree,
|
|
goldap.NeverDerefAliases,
|
|
0, // No size limit
|
|
0, // No time limit
|
|
false, // Types only = false
|
|
searchFilter,
|
|
attributes,
|
|
nil,
|
|
)
|
|
|
|
sr, err := conn.Search(searchRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to search Active Directory: %w", err)
|
|
}
|
|
|
|
originalUsers := []*OriginalUser{}
|
|
for _, entry := range sr.Entries {
|
|
originalUser := p.adEntryToOriginalUser(entry)
|
|
originalUsers = append(originalUsers, originalUser)
|
|
}
|
|
|
|
return originalUsers, nil
|
|
}
|
|
|
|
// adEntryToOriginalUser converts an Active Directory LDAP entry to Casdoor OriginalUser
|
|
func (p *ActiveDirectorySyncerProvider) adEntryToOriginalUser(entry *goldap.Entry) *OriginalUser {
|
|
user := &OriginalUser{
|
|
Address: []string{},
|
|
Properties: map[string]string{},
|
|
Groups: []string{},
|
|
}
|
|
|
|
// Get basic attributes with UTF-8 sanitization
|
|
sAMAccountName := getAttributeValueSafe(entry, "sAMAccountName")
|
|
userPrincipalName := getAttributeValueSafe(entry, "userPrincipalName")
|
|
displayName := getAttributeValueSafe(entry, "displayName")
|
|
givenName := getAttributeValueSafe(entry, "givenName")
|
|
sn := getAttributeValueSafe(entry, "sn")
|
|
mail := getAttributeValueSafe(entry, "mail")
|
|
telephoneNumber := getAttributeValueSafe(entry, "telephoneNumber")
|
|
mobile := getAttributeValueSafe(entry, "mobile")
|
|
title := getAttributeValueSafe(entry, "title")
|
|
department := getAttributeValueSafe(entry, "department")
|
|
company := getAttributeValueSafe(entry, "company")
|
|
streetAddress := getAttributeValueSafe(entry, "streetAddress")
|
|
city := getAttributeValueSafe(entry, "l")
|
|
state := getAttributeValueSafe(entry, "st")
|
|
postalCode := getAttributeValueSafe(entry, "postalCode")
|
|
country := getAttributeValueSafe(entry, "co")
|
|
whenCreated := getAttributeValueSafe(entry, "whenCreated")
|
|
userAccountControlStr := getAttributeValueSafe(entry, "userAccountControl")
|
|
|
|
// Handle objectGUID specially - it's a binary attribute
|
|
var objectGUID string
|
|
objectGUIDBytes := entry.GetRawAttributeValue("objectGUID")
|
|
if len(objectGUIDBytes) == 16 {
|
|
objectGUID = convertGUIDToString(objectGUIDBytes)
|
|
}
|
|
|
|
// Set user fields
|
|
// Use sAMAccountName as the primary username
|
|
user.Name = sAMAccountName
|
|
|
|
// Use objectGUID as the unique ID if available, otherwise use sAMAccountName
|
|
if objectGUID != "" {
|
|
user.Id = objectGUID
|
|
} else {
|
|
user.Id = sAMAccountName
|
|
}
|
|
|
|
user.DisplayName = displayName
|
|
user.FirstName = givenName
|
|
user.LastName = sn
|
|
|
|
// If display name is 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))
|
|
}
|
|
|
|
// Set email - prefer mail attribute, fallback to userPrincipalName
|
|
if mail != "" {
|
|
user.Email = mail
|
|
} else if userPrincipalName != "" {
|
|
user.Email = userPrincipalName
|
|
}
|
|
|
|
// Set phone - prefer mobile, fallback to telephoneNumber
|
|
if mobile != "" {
|
|
user.Phone = mobile
|
|
} else if telephoneNumber != "" {
|
|
user.Phone = telephoneNumber
|
|
}
|
|
|
|
user.Title = title
|
|
|
|
// Set affiliation/department
|
|
if department != "" {
|
|
user.Affiliation = department
|
|
}
|
|
|
|
// Construct location from city, state, country
|
|
locationParts := []string{}
|
|
if city != "" {
|
|
locationParts = append(locationParts, city)
|
|
}
|
|
if state != "" {
|
|
locationParts = append(locationParts, state)
|
|
}
|
|
if country != "" {
|
|
locationParts = append(locationParts, country)
|
|
}
|
|
if len(locationParts) > 0 {
|
|
user.Location = strings.Join(locationParts, ", ")
|
|
}
|
|
|
|
// Construct address
|
|
if streetAddress != "" {
|
|
addressParts := []string{streetAddress}
|
|
if city != "" {
|
|
addressParts = append(addressParts, city)
|
|
}
|
|
if state != "" {
|
|
addressParts = append(addressParts, state)
|
|
}
|
|
if postalCode != "" {
|
|
addressParts = append(addressParts, postalCode)
|
|
}
|
|
if country != "" {
|
|
addressParts = append(addressParts, country)
|
|
}
|
|
user.Address = []string{strings.Join(addressParts, ", ")}
|
|
}
|
|
|
|
// Store additional properties
|
|
if company != "" {
|
|
user.Properties["company"] = company
|
|
}
|
|
if userPrincipalName != "" {
|
|
user.Properties["userPrincipalName"] = userPrincipalName
|
|
}
|
|
|
|
// Set creation time
|
|
if whenCreated != "" {
|
|
user.CreatedTime = whenCreated
|
|
} else {
|
|
user.CreatedTime = util.GetCurrentTime()
|
|
}
|
|
|
|
// Parse userAccountControl to determine if account is disabled
|
|
// Bit 2 (value 2) indicates the account is disabled
|
|
if userAccountControlStr != "" {
|
|
userAccountControl := util.ParseInt(userAccountControlStr)
|
|
// Check if bit 2 is set (account disabled)
|
|
user.IsForbidden = (userAccountControl & 0x02) != 0
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
// GetOriginalGroups retrieves all groups from Active Directory (not implemented yet)
|
|
func (p *ActiveDirectorySyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
|
|
// TODO: Implement Active Directory group sync
|
|
return []*OriginalGroup{}, nil
|
|
}
|
|
|
|
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
|
|
func (p *ActiveDirectorySyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
|
|
// TODO: Implement Active Directory user group membership sync
|
|
return []string{}, nil
|
|
}
|