Files
casdoor/object/syncer_activedirectory.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
}