teg/backend/active_directory.go
2026-06-25 20:08:45 +02:00

1092 lines
28 KiB
Go

package main
import (
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/jackc/pgx/v5"
)
type activeDirectoryCredentials struct {
ServerURL string
BaseDN string
BindUsername string
BindPassword string
UserGroup string
SkipTLSVerify bool
}
type activeDirectoryUser struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
Email string `json:"email"`
Domain string `json:"domain"`
DistinguishedName string `json:"distinguishedName"`
SID string `json:"sid"`
Source string `json:"source"`
}
type activeDirectorySettingsResponse struct {
ServerURL string `json:"serverUrl"`
BaseDN string `json:"baseDn"`
BindUsername string `json:"bindUsername"`
BindPasswordConfigured bool `json:"bindPasswordConfigured"`
UserGroup string `json:"userGroup"`
SkipTLSVerify bool `json:"skipTlsVerify"`
Configured bool `json:"configured"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
type updateActiveDirectorySettingsRequest struct {
ServerURL string `json:"serverUrl"`
BaseDN string `json:"baseDn"`
BindUsername string `json:"bindUsername"`
BindPassword string `json:"bindPassword"`
UserGroup string `json:"userGroup"`
SkipTLSVerify bool `json:"skipTlsVerify"`
}
type activeDirectoryBaseDNTreeRequest struct {
ServerURL string `json:"serverUrl"`
BindUsername string `json:"bindUsername"`
BindPassword string `json:"bindPassword"`
BaseDN string `json:"baseDn"`
SkipTLSVerify bool `json:"skipTlsVerify"`
}
type activeDirectoryBaseDNTreeNode struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
SecondaryName string `json:"secondaryName,omitempty"`
Children []activeDirectoryBaseDNTreeNode `json:"children,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (s *Server) handleGetActiveDirectorySettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.getActiveDirectorySettings(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"settings": defaultActiveDirectorySettingsResponse(),
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) handleUpdateActiveDirectorySettings(w http.ResponseWriter, r *http.Request) {
var input updateActiveDirectorySettingsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
serverURL := normalizeActiveDirectoryServerURL(input.ServerURL)
baseDN := strings.TrimSpace(input.BaseDN)
bindUsername := strings.TrimSpace(input.BindUsername)
bindPassword := strings.TrimSpace(input.BindPassword)
userGroup := strings.TrimSpace(input.UserGroup)
skipTLSVerify := input.SkipTLSVerify
currentSettings, err := s.getActiveDirectorySettings(r.Context())
passwordAlreadyConfigured := false
if err == nil {
passwordAlreadyConfigured = currentSettings.BindPasswordConfigured
} else if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geprueft werden")
return
}
configured := serverURL != "" || baseDN != "" || bindUsername != "" || bindPassword != ""
if configured {
if serverURL == "" {
writeError(w, http.StatusBadRequest, "AD-Server ist erforderlich")
return
}
if baseDN == "" {
writeError(w, http.StatusBadRequest, "AD-Base-DN ist erforderlich")
return
}
if bindUsername == "" {
writeError(w, http.StatusBadRequest, "AD-Bind-Benutzer ist erforderlich")
return
}
if bindPassword == "" && !passwordAlreadyConfigured {
writeError(w, http.StatusBadRequest, "AD-Bind-Kennwort ist erforderlich")
return
}
}
_, err = s.db.Exec(
r.Context(),
`
INSERT INTO milestone_settings (
id,
active_directory_server_url,
active_directory_base_dn,
active_directory_bind_username,
active_directory_bind_password_encrypted,
active_directory_user_group,
active_directory_skip_tls_verify
)
VALUES (
1,
$1,
$2,
$3,
CASE WHEN $4 <> '' THEN pgp_sym_encrypt($4, $5) ELSE NULL END,
$6,
$7
)
ON CONFLICT (id)
DO UPDATE SET
active_directory_server_url = EXCLUDED.active_directory_server_url,
active_directory_base_dn = EXCLUDED.active_directory_base_dn,
active_directory_bind_username = EXCLUDED.active_directory_bind_username,
active_directory_bind_password_encrypted = CASE
WHEN $1 = '' AND $2 = '' AND $3 = '' AND $4 = '' THEN NULL
WHEN $4 <> '' THEN EXCLUDED.active_directory_bind_password_encrypted
ELSE milestone_settings.active_directory_bind_password_encrypted
END,
active_directory_user_group = EXCLUDED.active_directory_user_group,
active_directory_skip_tls_verify = EXCLUDED.active_directory_skip_tls_verify,
updated_at = now()
`,
serverURL,
baseDN,
bindUsername,
bindPassword,
s.milestoneEncryptionKey(),
userGroup,
skipTLSVerify,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht gespeichert werden")
return
}
settings, err := s.getActiveDirectorySettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) handleListActiveDirectoryBaseDNTree(w http.ResponseWriter, r *http.Request) {
var input activeDirectoryBaseDNTreeRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
credentials, ok := s.activeDirectoryBrowseCredentials(w, r, input)
if !ok {
return
}
nodes, err := listActiveDirectoryBaseDNTree(credentials)
if err != nil {
logError("AD-Base-DN-Struktur konnte nicht geladen werden", err, LogFields{
"serverUrl": credentials.ServerURL,
})
writeError(w, http.StatusBadGateway, "AD-Base-DN-Struktur konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"nodes": nodes,
})
}
func (s *Server) handleListActiveDirectoryGroupTree(w http.ResponseWriter, r *http.Request) {
var input activeDirectoryBaseDNTreeRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
credentials, ok := s.activeDirectoryBrowseCredentials(w, r, input)
if !ok {
return
}
nodes, err := listActiveDirectoryGroupTree(credentials)
if err != nil {
logError("AD-Gruppenstruktur konnte nicht geladen werden", err, LogFields{
"serverUrl": credentials.ServerURL,
"baseDn": credentials.BaseDN,
})
writeError(w, http.StatusBadGateway, "AD-Gruppenstruktur konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"nodes": nodes,
})
}
func (s *Server) activeDirectoryBrowseCredentials(
w http.ResponseWriter,
r *http.Request,
input activeDirectoryBaseDNTreeRequest,
) (activeDirectoryCredentials, bool) {
credentials := activeDirectoryCredentials{
ServerURL: normalizeActiveDirectoryServerURL(input.ServerURL),
BaseDN: strings.TrimSpace(input.BaseDN),
BindUsername: strings.TrimSpace(input.BindUsername),
BindPassword: strings.TrimSpace(input.BindPassword),
SkipTLSVerify: input.SkipTLSVerify,
}
storedCredentials, err := s.getActiveDirectoryCredentials(r.Context())
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden")
return credentials, false
}
if credentials.ServerURL == "" {
credentials.ServerURL = storedCredentials.ServerURL
}
if credentials.BindUsername == "" {
credentials.BindUsername = storedCredentials.BindUsername
}
if credentials.BindPassword == "" {
credentials.BindPassword = storedCredentials.BindPassword
}
if credentials.BaseDN == "" {
credentials.BaseDN = storedCredentials.BaseDN
}
if credentials.ServerURL == "" || credentials.BindUsername == "" || credentials.BindPassword == "" {
writeError(w, http.StatusBadRequest, "AD-Server, Bind-Benutzer und Bind-Kennwort sind erforderlich")
return credentials, false
}
return credentials, true
}
func (s *Server) handleListOperationActiveDirectoryUsers(w http.ResponseWriter, r *http.Request) {
credentials, err := s.getActiveDirectoryCredentials(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"configured": false,
"users": []activeDirectoryUser{},
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden")
return
}
if !credentials.Configured() {
writeJSON(w, http.StatusOK, map[string]any{
"configured": false,
"users": []activeDirectoryUser{},
})
return
}
users, err := listActiveDirectoryUsers(r.Context(), credentials)
if err != nil {
logError("AD-Benutzer konnten nicht geladen werden", err, LogFields{
"baseDn": credentials.BaseDN,
})
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"baseDn": credentials.BaseDN,
"users": users,
})
}
func defaultActiveDirectorySettingsResponse() activeDirectorySettingsResponse {
settings := activeDirectorySettingsResponse{}
settings.Configured = settings.BindPasswordConfigured &&
settings.ServerURL != "" &&
settings.BaseDN != "" &&
settings.BindUsername != ""
return settings
}
func (s *Server) getActiveDirectorySettings(ctx context.Context) (activeDirectorySettingsResponse, error) {
var settings activeDirectorySettingsResponse
var updatedAt time.Time
err := s.db.QueryRow(
ctx,
`
SELECT
COALESCE(active_directory_server_url, ''),
COALESCE(active_directory_base_dn, ''),
COALESCE(active_directory_bind_username, ''),
active_directory_bind_password_encrypted IS NOT NULL,
COALESCE(active_directory_user_group, ''),
COALESCE(active_directory_skip_tls_verify, false),
updated_at
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
).Scan(
&settings.ServerURL,
&settings.BaseDN,
&settings.BindUsername,
&settings.BindPasswordConfigured,
&settings.UserGroup,
&settings.SkipTLSVerify,
&updatedAt,
)
if err != nil {
return settings, err
}
settings.UpdatedAt = &updatedAt
settings.Configured = settings.BindPasswordConfigured &&
settings.ServerURL != "" &&
settings.BaseDN != "" &&
settings.BindUsername != ""
return settings, nil
}
func (c activeDirectoryCredentials) Configured() bool {
return strings.TrimSpace(c.ServerURL) != "" &&
strings.TrimSpace(c.BaseDN) != "" &&
strings.TrimSpace(c.BindUsername) != "" &&
strings.TrimSpace(c.BindPassword) != ""
}
func (s *Server) getActiveDirectoryCredentials(ctx context.Context) (activeDirectoryCredentials, error) {
var credentials activeDirectoryCredentials
err := s.db.QueryRow(
ctx,
`
SELECT
COALESCE(active_directory_server_url, ''),
COALESCE(active_directory_base_dn, ''),
COALESCE(active_directory_bind_username, ''),
COALESCE(pgp_sym_decrypt(active_directory_bind_password_encrypted, $1), ''),
COALESCE(active_directory_user_group, ''),
COALESCE(active_directory_skip_tls_verify, false)
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&credentials.ServerURL,
&credentials.BaseDN,
&credentials.BindUsername,
&credentials.BindPassword,
&credentials.UserGroup,
&credentials.SkipTLSVerify,
)
credentials.ServerURL = normalizeActiveDirectoryServerURL(credentials.ServerURL)
credentials.BaseDN = strings.TrimSpace(credentials.BaseDN)
credentials.BindUsername = strings.TrimSpace(credentials.BindUsername)
credentials.BindPassword = strings.TrimSpace(credentials.BindPassword)
credentials.UserGroup = strings.TrimSpace(credentials.UserGroup)
return credentials, err
}
func listActiveDirectoryUsers(
ctx context.Context,
credentials activeDirectoryCredentials,
) ([]activeDirectoryUser, error) {
conn, err := dialAndBindActiveDirectory(credentials)
if err != nil {
return nil, err
}
defer conn.Close()
searchRequest := ldap.NewSearchRequest(
credentials.BaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
20,
false,
"(&"+
"(objectCategory=person)"+
"(objectClass=user)"+
"(!(userAccountControl:1.2.840.113556.1.4.803:=2))"+
")",
[]string{
"displayName",
"sAMAccountName",
"userPrincipalName",
"mail",
"distinguishedName",
"objectSid",
"cn",
},
nil,
)
searchResult, err := conn.Search(searchRequest)
if err != nil {
return nil, err
}
users := make([]activeDirectoryUser, 0, len(searchResult.Entries))
seen := map[string]bool{}
for _, entry := range searchResult.Entries {
user := activeDirectoryUserFromLDAPEntry(entry)
if user.ID == "" {
continue
}
if seen[user.ID] {
continue
}
seen[user.ID] = true
users = append(users, user)
}
sort.SliceStable(users, func(i, j int) bool {
return strings.ToLower(users[i].DisplayName) < strings.ToLower(users[j].DisplayName)
})
return users, nil
}
func dialAndBindActiveDirectory(credentials activeDirectoryCredentials) (*ldap.Conn, error) {
conn, err := ldap.DialURL(
credentials.ServerURL,
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: credentials.SkipTLSVerify}),
ldap.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}),
)
if err != nil {
return nil, err
}
if err := conn.Bind(credentials.BindUsername, credentials.BindPassword); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
func listActiveDirectoryBaseDNTree(
credentials activeDirectoryCredentials,
) ([]activeDirectoryBaseDNTreeNode, error) {
conn, err := dialAndBindActiveDirectory(credentials)
if err != nil {
return nil, err
}
defer conn.Close()
namingContexts, err := listActiveDirectoryNamingContexts(conn, credentials.BaseDN)
if err != nil {
return nil, err
}
const maxNodes = 500
const maxDepth = 3
remainingNodes := maxNodes
nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(namingContexts))
for _, namingContext := range namingContexts {
node := activeDirectoryBaseDNTreeNodeFromDN(namingContext)
if remainingNodes > 0 {
children, err := listActiveDirectoryBaseDNChildren(
conn,
namingContext,
0,
maxDepth,
&remainingNodes,
)
if err == nil {
node.Children = children
} else {
logError("AD-Base-DN-Unterstruktur konnte nicht geladen werden", err, LogFields{
"baseDn": namingContext,
})
}
}
nodes = append(nodes, node)
}
return nodes, nil
}
func listActiveDirectoryGroupTree(
credentials activeDirectoryCredentials,
) ([]activeDirectoryBaseDNTreeNode, error) {
conn, err := dialAndBindActiveDirectory(credentials)
if err != nil {
return nil, err
}
defer conn.Close()
roots := []string{}
if strings.TrimSpace(credentials.BaseDN) != "" {
roots = append(roots, strings.TrimSpace(credentials.BaseDN))
} else {
roots, err = listActiveDirectoryNamingContexts(conn, "")
if err != nil {
return nil, err
}
}
const maxNodes = 1000
const maxDepth = 5
remainingNodes := maxNodes
nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(roots))
for _, root := range roots {
node := activeDirectoryBaseDNTreeNodeFromDN(root)
node.Disabled = true
if remainingNodes > 0 {
children, err := listActiveDirectoryGroupTreeChildren(
conn,
root,
0,
maxDepth,
&remainingNodes,
)
if err == nil {
node.Children = children
} else {
logError("AD-Gruppen-Unterstruktur konnte nicht geladen werden", err, LogFields{
"baseDn": root,
})
}
}
nodes = append(nodes, node)
}
return nodes, nil
}
func listActiveDirectoryNamingContexts(conn *ldap.Conn, fallbackBaseDN string) ([]string, error) {
searchRequest := ldap.NewSearchRequest(
"",
ldap.ScopeBaseObject,
ldap.NeverDerefAliases,
0,
10,
false,
"(objectClass=*)",
[]string{"defaultNamingContext", "namingContexts"},
nil,
)
searchResult, err := conn.Search(searchRequest)
if err != nil {
if strings.TrimSpace(fallbackBaseDN) != "" {
return []string{strings.TrimSpace(fallbackBaseDN)}, nil
}
return nil, err
}
seen := map[string]bool{}
namingContexts := []string{}
addNamingContext := func(value string) {
value = strings.TrimSpace(value)
if value == "" || seen[strings.ToLower(value)] {
return
}
seen[strings.ToLower(value)] = true
namingContexts = append(namingContexts, value)
}
if len(searchResult.Entries) > 0 {
entry := searchResult.Entries[0]
addNamingContext(entry.GetAttributeValue("defaultNamingContext"))
for _, namingContext := range entry.GetAttributeValues("namingContexts") {
addNamingContext(namingContext)
}
}
addNamingContext(fallbackBaseDN)
if len(namingContexts) == 0 {
return nil, fmt.Errorf("keine AD-Naming-Contexts gefunden")
}
return namingContexts, nil
}
func listActiveDirectoryBaseDNChildren(
conn *ldap.Conn,
parentDN string,
depth int,
maxDepth int,
remainingNodes *int,
) ([]activeDirectoryBaseDNTreeNode, error) {
if depth >= maxDepth || *remainingNodes <= 0 {
return nil, nil
}
searchRequest := ldap.NewSearchRequest(
parentDN,
ldap.ScopeSingleLevel,
ldap.NeverDerefAliases,
0,
10,
false,
"(|(objectClass=domainDNS)(objectClass=organizationalUnit)(objectClass=container))",
[]string{
"cn",
"distinguishedName",
"name",
"objectClass",
"ou",
},
nil,
)
searchResult, err := conn.Search(searchRequest)
if err != nil {
return nil, err
}
nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(searchResult.Entries))
for _, entry := range searchResult.Entries {
if *remainingNodes <= 0 {
break
}
distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName"))
if distinguishedName == "" {
distinguishedName = strings.TrimSpace(entry.DN)
}
if distinguishedName == "" {
continue
}
*remainingNodes = *remainingNodes - 1
node := activeDirectoryBaseDNTreeNodeFromEntry(entry, distinguishedName)
children, err := listActiveDirectoryBaseDNChildren(
conn,
distinguishedName,
depth+1,
maxDepth,
remainingNodes,
)
if err == nil {
node.Children = children
} else {
logError("AD-Base-DN-Unterstruktur konnte nicht geladen werden", err, LogFields{
"baseDn": distinguishedName,
})
}
nodes = append(nodes, node)
}
sort.SliceStable(nodes, func(i, j int) bool {
return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name)
})
return nodes, nil
}
func listActiveDirectoryGroupTreeChildren(
conn *ldap.Conn,
parentDN string,
depth int,
maxDepth int,
remainingNodes *int,
) ([]activeDirectoryBaseDNTreeNode, error) {
if depth >= maxDepth || *remainingNodes <= 0 {
return nil, nil
}
searchRequest := ldap.NewSearchRequest(
parentDN,
ldap.ScopeSingleLevel,
ldap.NeverDerefAliases,
0,
15,
false,
"(|(objectClass=domainDNS)(objectClass=organizationalUnit)(objectClass=container)(objectClass=group))",
[]string{
"cn",
"distinguishedName",
"name",
"objectClass",
"ou",
"sAMAccountName",
},
nil,
)
searchResult, err := conn.Search(searchRequest)
if err != nil {
return nil, err
}
nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(searchResult.Entries))
for _, entry := range searchResult.Entries {
if *remainingNodes <= 0 {
break
}
distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName"))
if distinguishedName == "" {
distinguishedName = strings.TrimSpace(entry.DN)
}
if distinguishedName == "" {
continue
}
*remainingNodes = *remainingNodes - 1
isGroup := activeDirectoryEntryHasObjectClass(entry, "group")
node := activeDirectoryBaseDNTreeNodeFromEntry(entry, distinguishedName)
node.Disabled = !isGroup
if isGroup {
samAccountName := strings.TrimSpace(entry.GetAttributeValue("sAMAccountName"))
if samAccountName != "" {
node.SecondaryName = samAccountName
}
}
if !isGroup {
children, err := listActiveDirectoryGroupTreeChildren(
conn,
distinguishedName,
depth+1,
maxDepth,
remainingNodes,
)
if err == nil {
node.Children = children
} else {
logError("AD-Gruppen-Unterstruktur konnte nicht geladen werden", err, LogFields{
"baseDn": distinguishedName,
})
}
}
if isGroup || len(node.Children) > 0 {
nodes = append(nodes, node)
}
}
sort.SliceStable(nodes, func(i, j int) bool {
if nodes[i].Disabled != nodes[j].Disabled {
return !nodes[i].Disabled
}
return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name)
})
return nodes, nil
}
func activeDirectoryEntryHasObjectClass(entry *ldap.Entry, objectClass string) bool {
for _, value := range entry.GetAttributeValues("objectClass") {
if strings.EqualFold(value, objectClass) {
return true
}
}
return false
}
func activeDirectoryBaseDNTreeNodeFromEntry(
entry *ldap.Entry,
distinguishedName string,
) activeDirectoryBaseDNTreeNode {
name := strings.TrimSpace(entry.GetAttributeValue("ou"))
if name == "" {
name = strings.TrimSpace(entry.GetAttributeValue("name"))
}
if name == "" {
name = strings.TrimSpace(entry.GetAttributeValue("cn"))
}
if name == "" {
name = activeDirectoryDNDisplayName(distinguishedName)
}
return activeDirectoryBaseDNTreeNode{
ID: distinguishedName,
Name: name,
Path: distinguishedName,
SecondaryName: distinguishedName,
}
}
func activeDirectoryBaseDNTreeNodeFromDN(distinguishedName string) activeDirectoryBaseDNTreeNode {
return activeDirectoryBaseDNTreeNode{
ID: distinguishedName,
Name: activeDirectoryDNDisplayName(distinguishedName),
Path: distinguishedName,
SecondaryName: distinguishedName,
}
}
func activeDirectoryDNDisplayName(distinguishedName string) string {
components := splitActiveDirectoryDN(distinguishedName)
if len(components) == 0 {
return distinguishedName
}
domainParts := make([]string, 0, len(components))
allDomainParts := true
for _, component := range components {
attribute, value := activeDirectoryDNRDN(component)
if !strings.EqualFold(attribute, "DC") {
allDomainParts = false
break
}
domainParts = append(domainParts, value)
}
if allDomainParts && len(domainParts) > 0 {
return strings.Join(domainParts, ".")
}
_, value := activeDirectoryDNRDN(components[0])
if value != "" {
return value
}
return distinguishedName
}
func splitActiveDirectoryDN(distinguishedName string) []string {
components := []string{}
var current strings.Builder
escaped := false
for _, char := range distinguishedName {
if escaped {
current.WriteRune(char)
escaped = false
continue
}
if char == '\\' {
escaped = true
current.WriteRune(char)
continue
}
if char == ',' {
component := strings.TrimSpace(current.String())
if component != "" {
components = append(components, component)
}
current.Reset()
continue
}
current.WriteRune(char)
}
component := strings.TrimSpace(current.String())
if component != "" {
components = append(components, component)
}
return components
}
func activeDirectoryDNRDN(component string) (string, string) {
parts := strings.SplitN(component, "=", 2)
if len(parts) != 2 {
return "", strings.TrimSpace(component)
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
func findActiveDirectoryGroupDN(
conn *ldap.Conn,
credentials activeDirectoryCredentials,
) (string, error) {
group := strings.TrimSpace(credentials.UserGroup)
if strings.Contains(group, "=") && strings.Contains(group, ",") {
return group, nil
}
escapedGroup := ldap.EscapeFilter(group)
groupFilter := fmt.Sprintf(
"(&(objectClass=group)(|(cn=%s)(name=%s)(sAMAccountName=%s)))",
escapedGroup,
escapedGroup,
escapedGroup,
)
searchRequest := ldap.NewSearchRequest(
credentials.BaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1,
10,
false,
groupFilter,
[]string{"distinguishedName", "cn", "name", "sAMAccountName"},
nil,
)
searchResult, err := conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(searchResult.Entries) == 0 {
return "", fmt.Errorf("AD-Gruppe %q wurde nicht gefunden", group)
}
return searchResult.Entries[0].DN, nil
}
func activeDirectoryUserFromLDAPEntry(entry *ldap.Entry) activeDirectoryUser {
displayName := strings.TrimSpace(entry.GetAttributeValue("displayName"))
username := strings.TrimSpace(entry.GetAttributeValue("sAMAccountName"))
email := strings.TrimSpace(entry.GetAttributeValue("mail"))
userPrincipalName := strings.TrimSpace(entry.GetAttributeValue("userPrincipalName"))
distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName"))
if distinguishedName == "" {
distinguishedName = strings.TrimSpace(entry.DN)
}
if email == "" {
email = userPrincipalName
}
if displayName == "" {
displayName = strings.TrimSpace(entry.GetAttributeValue("cn"))
}
if displayName == "" {
displayName = username
}
if displayName == "" {
displayName = email
}
sid := activeDirectorySIDString(entry.GetRawAttributeValue("objectSid"))
id := sid
if id == "" {
id = distinguishedName
}
domain := activeDirectoryDomainFromPrincipal(userPrincipalName)
if domain == "" {
domain = activeDirectoryDomainFromDN(distinguishedName)
}
return activeDirectoryUser{
ID: id,
DisplayName: displayName,
Username: username,
Email: email,
Domain: domain,
DistinguishedName: distinguishedName,
SID: sid,
Source: "ad",
}
}
func activeDirectoryDomainFromPrincipal(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
_, domain, ok := strings.Cut(value, "@")
if !ok {
return ""
}
return strings.TrimSpace(domain)
}
func activeDirectoryDomainFromDN(distinguishedName string) string {
components := splitActiveDirectoryDN(distinguishedName)
if len(components) == 0 {
return ""
}
domainParts := make([]string, 0, len(components))
for _, component := range components {
attribute, value := activeDirectoryDNRDN(component)
if strings.EqualFold(attribute, "DC") && value != "" {
domainParts = append(domainParts, value)
}
}
return strings.Join(domainParts, ".")
}
func activeDirectorySIDString(rawSID []byte) string {
if len(rawSID) < 8 {
return ""
}
revision := rawSID[0]
subAuthorityCount := int(rawSID[1])
expectedLength := 8 + subAuthorityCount*4
if len(rawSID) < expectedLength {
return ""
}
var identifierAuthority uint64
for _, value := range rawSID[2:8] {
identifierAuthority = (identifierAuthority << 8) | uint64(value)
}
parts := []string{
"S",
fmt.Sprintf("%d", revision),
fmt.Sprintf("%d", identifierAuthority),
}
for index := 0; index < subAuthorityCount; index++ {
offset := 8 + index*4
parts = append(parts, fmt.Sprintf("%d", binary.LittleEndian.Uint32(rawSID[offset:offset+4])))
}
return strings.Join(parts, "-")
}
func normalizeActiveDirectoryServerURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if !strings.Contains(value, "://") {
value = "ldaps://" + value
}
parsedURL, err := url.Parse(value)
if err != nil || parsedURL.Hostname() == "" {
return value
}
if parsedURL.Port() == "" {
if parsedURL.Scheme == "ldap" {
parsedURL.Host = parsedURL.Hostname() + ":389"
} else if parsedURL.Scheme == "ldaps" {
parsedURL.Host = parsedURL.Hostname() + ":636"
}
}
return parsedURL.String()
}