822 lines
26 KiB
Go
822 lines
26 KiB
Go
package auth
|
|
|
|
// Reference: M13 §4.4 — LDAP/Active Directory authentication service
|
|
// Provides LDAP Bind authentication, user search, group extraction, and role mapping.
|
|
// Supports per-account LDAP configuration for multi-tenant identity isolation.
|
|
// Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding
|
|
// LDAP support for traditional enterprise AD/LDAP environments.
|
|
//
|
|
// Authentication flow:
|
|
// 1. User enters enterprise username + password
|
|
// 2. GoChat connects to LDAP server using service account (BindDN + BindPassword)
|
|
// 3. Search BaseDN + UserFilter to find user entry DN
|
|
// 4. Bind with user DN + password to verify credentials
|
|
// 5. Extract email/name/groups attributes from user entry
|
|
// 6. LDAPUserBuilder maps attributes to GoChat user fields
|
|
// 7. Auto-provision GoChat user if configured
|
|
// 8. Issue JWT token + create SSO session
|
|
//
|
|
// Connection modes:
|
|
// - Plain LDAP (port 389) — insecure, not recommended for production
|
|
// - LDAP + StartTLS (port 389 + TLS upgrade) — recommended
|
|
// - LDAPS (port 636) — TLS from the start, also recommended
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// LDAP errors
|
|
var (
|
|
ErrLDAPDisabled = fmt.Errorf("ldap authentication is not enabled")
|
|
ErrLDAPInvalidConfig = fmt.Errorf("ldap configuration is invalid")
|
|
ErrLDAPConnection = fmt.Errorf("failed to connect to ldap server")
|
|
ErrLDAPBindFailed = fmt.Errorf("ldap bind authentication failed")
|
|
ErrLDAPUserNotFound = fmt.Errorf("ldap user not found")
|
|
ErrLDAPSearchFailed = fmt.Errorf("ldap search failed")
|
|
)
|
|
|
|
// LDAPUserInfo represents user info extracted from an LDAP directory entry.
|
|
type LDAPUserInfo struct {
|
|
DN string // Distinguished Name — unique identifier in LDAP
|
|
Email string // email attribute (typically mail)
|
|
DisplayName string // display name attribute (typically cn)
|
|
FirstName string // first name attribute (typically givenName)
|
|
LastName string // last name attribute (typically sn)
|
|
Groups []string // group memberships (typically memberOf attribute)
|
|
Attributes map[string]string // all extracted attributes for flexibility
|
|
}
|
|
|
|
// LDAPService provides LDAP authentication and user/group lookup.
|
|
type LDAPService struct {
|
|
cfg *config.LDAPConfig
|
|
rdb redis.Cmdable
|
|
db *gorm.DB
|
|
mu sync.RWMutex
|
|
connPool map[uint]*ldapConnection // per-account connection pool
|
|
}
|
|
|
|
// ldapConnection wraps a raw LDAP TCP/TLS connection for reuse.
|
|
type ldapConnection struct {
|
|
host string
|
|
port int
|
|
useTLS bool
|
|
conn net.Conn
|
|
lastUsed time.Time
|
|
}
|
|
|
|
// NewLDAPService creates an LDAP service with configuration.
|
|
func NewLDAPService(cfg *config.LDAPConfig, rdb redis.Cmdable, db *gorm.DB) (*LDAPService, error) {
|
|
if !cfg.Enabled {
|
|
applogger.L().Info("LDAP service initialized (disabled)")
|
|
return &LDAPService{
|
|
cfg: cfg,
|
|
rdb: rdb,
|
|
db: db,
|
|
connPool: make(map[uint]*ldapConnection),
|
|
}, nil
|
|
}
|
|
|
|
// Validate global default configuration if host is set
|
|
if cfg.DefaultHost != "" {
|
|
if cfg.DefaultPort == 0 {
|
|
cfg.DefaultPort = 389
|
|
}
|
|
if cfg.DefaultBaseDN == "" {
|
|
return nil, ErrLDAPInvalidConfig
|
|
}
|
|
}
|
|
|
|
svc := &LDAPService{
|
|
cfg: cfg,
|
|
rdb: rdb,
|
|
db: db,
|
|
connPool: make(map[uint]*ldapConnection),
|
|
}
|
|
|
|
applogger.L().Infof("LDAP service initialized (enabled, default_host=%s, default_port=%d)", cfg.DefaultHost, cfg.DefaultPort)
|
|
return svc, nil
|
|
}
|
|
|
|
// Authenticate authenticates a user against LDAP using Bind verification.
|
|
// Steps: (1) connect to LDAP, (2) bind with service account, (3) search for user,
|
|
// (4) bind with user DN + password to verify credentials, (5) extract user attributes.
|
|
func (s *LDAPService) Authenticate(ctx context.Context, accountID uint, username, password string) (*LDAPUserInfo, error) {
|
|
if !s.cfg.Enabled {
|
|
return nil, ErrLDAPDisabled
|
|
}
|
|
|
|
// Get per-account LDAP settings from DB
|
|
settings, err := s.getAccountSettings(accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load LDAP settings for account %d: %w", accountID, err)
|
|
}
|
|
if settings == nil {
|
|
return nil, ErrLDAPDisabled
|
|
}
|
|
|
|
// Connect to LDAP server
|
|
conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrLDAPConnection, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Step 1: Bind with service account to search for user
|
|
if settings.BindDN != "" && settings.BindPassword != "" {
|
|
err = s.ldapBind(conn, settings.BindDN, settings.BindPassword)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service account bind failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Step 2: Search for user entry
|
|
userDN, userAttrs, err := s.searchUser(conn, settings, username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: username=%s, %v", ErrLDAPUserNotFound, username, err)
|
|
}
|
|
|
|
// Step 3: Bind with user DN + password to verify credentials
|
|
err = s.ldapBind(conn, userDN, password)
|
|
if err != nil {
|
|
return nil, ErrLDAPBindFailed
|
|
}
|
|
|
|
// Step 4: Extract user info from attributes
|
|
userInfo := s.extractUserInfo(settings, userDN, userAttrs)
|
|
|
|
// Step 5: Extract group memberships
|
|
groups, err := s.extractGroups(conn, settings, userDN, userAttrs)
|
|
if err != nil {
|
|
applogger.L().Warnf("Failed to extract LDAP groups for user %s: %v", userDN, err)
|
|
} else {
|
|
userInfo.Groups = groups
|
|
}
|
|
|
|
applogger.L().Infof("LDAP authentication successful (account=%d, user=%s, dn=%s)", accountID, username, userDN)
|
|
return userInfo, nil
|
|
}
|
|
|
|
// getAccountSettings loads per-account LDAP configuration from DB.
|
|
// Falls back to global defaults for fields not overridden per-account.
|
|
func (s *LDAPService) getAccountSettings(accountID uint) (*model.AccountLDAPSettings, error) {
|
|
if s.db == nil {
|
|
return nil, fmt.Errorf("database not available")
|
|
}
|
|
|
|
var settings model.AccountLDAPSettings
|
|
err := s.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// No per-account settings — check if global defaults exist
|
|
if s.cfg.DefaultHost == "" {
|
|
return nil, nil // LDAP not configured for this account
|
|
}
|
|
// Use global defaults as a fallback
|
|
settings = model.AccountLDAPSettings{
|
|
AccountID: accountID,
|
|
Host: s.cfg.DefaultHost,
|
|
Port: s.cfg.DefaultPort,
|
|
UseTLS: s.cfg.DefaultUseTLS,
|
|
BaseDN: s.cfg.DefaultBaseDN,
|
|
BindDN: s.cfg.DefaultBindDN,
|
|
BindPassword: s.cfg.DefaultBindPassword,
|
|
UserFilter: s.cfg.DefaultUserFilter,
|
|
EmailAttribute: s.cfg.DefaultEmailAttribute,
|
|
NameAttribute: s.cfg.DefaultNameAttribute,
|
|
GroupAttribute: s.cfg.DefaultGroupAttribute,
|
|
AutoProvision: true,
|
|
Active: true,
|
|
}
|
|
return &settings, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Override empty per-account fields with global defaults
|
|
if settings.Host == "" && s.cfg.DefaultHost != "" {
|
|
settings.Host = s.cfg.DefaultHost
|
|
}
|
|
if settings.Port == 0 {
|
|
settings.Port = s.cfg.DefaultPort
|
|
if settings.Port == 0 {
|
|
settings.Port = 389
|
|
}
|
|
}
|
|
if settings.UserFilter == "" && s.cfg.DefaultUserFilter != "" {
|
|
settings.UserFilter = s.cfg.DefaultUserFilter
|
|
}
|
|
if settings.EmailAttribute == "" && s.cfg.DefaultEmailAttribute != "" {
|
|
settings.EmailAttribute = s.cfg.DefaultEmailAttribute
|
|
}
|
|
if settings.NameAttribute == "" && s.cfg.DefaultNameAttribute != "" {
|
|
settings.NameAttribute = s.cfg.DefaultNameAttribute
|
|
}
|
|
if settings.GroupAttribute == "" && s.cfg.DefaultGroupAttribute != "" {
|
|
settings.GroupAttribute = s.cfg.DefaultGroupAttribute
|
|
}
|
|
|
|
return &settings, nil
|
|
}
|
|
|
|
// connect establishes a TCP connection to the LDAP server, optionally upgrading to TLS.
|
|
func (s *LDAPService) connect(host string, port int, useTLS bool) (net.Conn, error) {
|
|
address := fmt.Sprintf("%s:%d", host, port)
|
|
|
|
if useTLS {
|
|
// Direct TLS connection (LDAPS)
|
|
tlsConfig := &tls.Config{
|
|
ServerName: host,
|
|
// In production, InsecureSkipVerify should be false.
|
|
// For initial testing with self-signed certs, it can be set to true via config.
|
|
}
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", address, tlsConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TLS connection to %s failed: %w", address, err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// Plain TCP connection (may upgrade to StartTLS later)
|
|
conn, err := net.DialTimeout("tcp", address, 10*time.Second)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TCP connection to %s failed: %w", address, err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// ldapBind performs an LDAP Bind operation over a TCP/TLS connection.
|
|
// This is a simplified LDAP Bind — it sends the LDAP Bind request packet
|
|
// and reads the response. In production, use the go-ldap/ldap3 library
|
|
// for full LDAP protocol support. This implementation provides the basic
|
|
// wire protocol for Bind operations.
|
|
func (s *LDAPService) ldapBind(conn net.Conn, dn, password string) error {
|
|
// LDAP Bind request packet (BER encoding)
|
|
// MessageID = 1, BindRequest = version 3, DN, simple auth
|
|
bindRequest := encodeLDAPBindRequest(1, 3, dn, password)
|
|
if _, err := conn.Write(bindRequest); err != nil {
|
|
return fmt.Errorf("failed to write LDAP bind request: %w", err)
|
|
}
|
|
|
|
// Read LDAP Bind response
|
|
response, err := readLDAPResponse(conn)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read LDAP bind response: %w", err)
|
|
}
|
|
|
|
// Check result code — 0 = success
|
|
if response.ResultCode != 0 {
|
|
return fmt.Errorf("LDAP bind failed: result code %d (%s)", response.ResultCode, response.DiagnosticMessage)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// searchUser searches for a user entry in the LDAP directory.
|
|
// Returns the user's DN and a map of attributes.
|
|
func (s *LDAPService) searchUser(conn net.Conn, settings *model.AccountLDAPSettings, username string) (string, map[string]string, error) {
|
|
// Construct search filter: combine base UserFilter with username
|
|
searchFilter := settings.UserFilter
|
|
if !strings.Contains(searchFilter, "=") {
|
|
// Default filter: (objectClass=person) — add uid/email matching
|
|
searchFilter = fmt.Sprintf("(&(%s)(|(uid=%s)(mail=%s)(cn=%s)))",
|
|
settings.UserFilter, username, username, username)
|
|
} else {
|
|
// Append username matching to existing filter
|
|
searchFilter = fmt.Sprintf("(&%s(|(uid=%s)(mail=%s)))", searchFilter, username, username)
|
|
}
|
|
|
|
// LDAP Search request
|
|
searchRequest := encodeLDAPSearchRequest(2, settings.BaseDN, 2, // scope: whole subtree
|
|
0, // derefAliases: never
|
|
0, // sizeLimit: no limit
|
|
30, // timeLimit: 30 seconds
|
|
false, // typesOnly: false
|
|
searchFilter,
|
|
[]string{settings.EmailAttribute, settings.NameAttribute, settings.FirstNameAttribute,
|
|
settings.LastNameAttribute, settings.GroupAttribute, "uid", "dn", "cn"},
|
|
)
|
|
|
|
if _, err := conn.Write(searchRequest); err != nil {
|
|
return "", nil, fmt.Errorf("failed to write LDAP search request: %w", err)
|
|
}
|
|
|
|
// Read search results
|
|
results, err := readLDAPSearchResults(conn)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%w: %v", ErrLDAPSearchFailed, err)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return "", nil, fmt.Errorf("no LDAP entries found for username=%s", username)
|
|
}
|
|
|
|
// Return first matching entry
|
|
entry := results[0]
|
|
return entry.DN, entry.Attributes, nil
|
|
}
|
|
|
|
// extractUserInfo maps LDAP attributes to LDAPUserInfo struct.
|
|
func (s *LDAPService) extractUserInfo(settings *model.AccountLDAPSettings, dn string, attrs map[string]string) *LDAPUserInfo {
|
|
info := &LDAPUserInfo{
|
|
DN: dn,
|
|
Email: getAttr(attrs, settings.EmailAttribute, "mail"),
|
|
DisplayName: getAttr(attrs, settings.NameAttribute, "cn"),
|
|
FirstName: getAttr(attrs, settings.FirstNameAttribute, "givenName"),
|
|
LastName: getAttr(attrs, settings.LastNameAttribute, "sn"),
|
|
Attributes: attrs,
|
|
}
|
|
return info
|
|
}
|
|
|
|
// extractGroups extracts group memberships from the user's LDAP entry.
|
|
// Supports both memberOf attribute extraction and group search.
|
|
func (s *LDAPService) extractGroups(conn net.Conn, settings *model.AccountLDAPSettings, userDN string, attrs map[string]string) ([]string, error) {
|
|
groups := []string{}
|
|
|
|
// Method 1: Extract from memberOf attribute
|
|
groupAttr := settings.GroupAttribute
|
|
if groupAttr == "" {
|
|
groupAttr = "memberOf"
|
|
}
|
|
if groupValues, ok := attrs[groupAttr]; ok {
|
|
for _, g := range strings.Split(groupValues, ";") {
|
|
g = strings.TrimSpace(g)
|
|
if g != "" {
|
|
groups = append(groups, g)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Method 2: If no memberOf, search groups containing this user DN
|
|
if len(groups) == 0 && settings.GroupFilter != "" {
|
|
groupFilter := fmt.Sprintf("(&%s(%s=%s))", settings.GroupFilter, "member", userDN)
|
|
searchRequest := encodeLDAPSearchRequest(3, settings.BaseDN, 2, 0, 0, 10, false,
|
|
groupFilter, []string{"cn", "dn"})
|
|
|
|
if _, err := conn.Write(searchRequest); err == nil {
|
|
results, err := readLDAPSearchResults(conn)
|
|
if err == nil {
|
|
for _, entry := range results {
|
|
if cn, ok := entry.Attributes["cn"]; ok {
|
|
groups = append(groups, cn)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return groups, nil
|
|
}
|
|
|
|
// MapLDAPGroupsToRoles maps LDAP groups to GoChat roles using the role mapping config.
|
|
func (s *LDAPService) MapLDAPGroupsToRoles(settings *model.AccountLDAPSettings, groups []string) string {
|
|
if settings.RoleMappings == nil || len(settings.RoleMappings) == 0 {
|
|
return "agent" // default role
|
|
}
|
|
|
|
var mappings map[string]string
|
|
if err := json.Unmarshal(settings.RoleMappings, &mappings); err != nil {
|
|
applogger.L().Warnf("Invalid LDAP role mappings JSON: %v", err)
|
|
return "agent"
|
|
}
|
|
|
|
// Check each group against mappings — highest privilege wins
|
|
rolePriority := map[string]int{
|
|
"administrator": 4,
|
|
"admin": 3,
|
|
"supervisor": 2,
|
|
"agent": 1,
|
|
}
|
|
|
|
bestRole := "agent"
|
|
bestPriority := 1
|
|
|
|
for _, group := range groups {
|
|
// Extract group CN from DN (e.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins")
|
|
groupCN := extractCNFromDN(group)
|
|
|
|
// Check direct mapping
|
|
if mappedRole, ok := mappings[groupCN]; ok {
|
|
if p, ok := rolePriority[mappedRole]; ok && p > bestPriority {
|
|
bestRole = mappedRole
|
|
bestPriority = p
|
|
}
|
|
}
|
|
// Check full DN mapping
|
|
if mappedRole, ok := mappings[group]; ok {
|
|
if p, ok := rolePriority[mappedRole]; ok && p > bestPriority {
|
|
bestRole = mappedRole
|
|
bestPriority = p
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestRole
|
|
}
|
|
|
|
// SyncGroups performs periodic LDAP group synchronization.
|
|
// Reads LDAP groups for all provisioned users and updates their GoChat roles.
|
|
func (s *LDAPService) SyncGroups(ctx context.Context, accountID uint) error {
|
|
if !s.cfg.Enabled {
|
|
return ErrLDAPDisabled
|
|
}
|
|
|
|
settings, err := s.getAccountSettings(accountID)
|
|
if err != nil || settings == nil {
|
|
return fmt.Errorf("LDAP not configured for account %d", accountID)
|
|
}
|
|
|
|
applogger.L().Infof("LDAP group sync starting for account %d", accountID)
|
|
|
|
// Query all users in this account that were provisioned via LDAP
|
|
// Use the User model's Provider field to identify LDAP-provisioned users
|
|
var accountUsers []model.AccountUser
|
|
if err := s.db.Where("account_id = ?", accountID).
|
|
Preload("User", "provider = 'ldap'").
|
|
Find(&accountUsers).Error; err != nil {
|
|
return fmt.Errorf("failed to query LDAP-provisioned users: %w", err)
|
|
}
|
|
|
|
// Filter to only users with LDAP provider
|
|
ldapUsers := []model.AccountUser{}
|
|
for _, au := range accountUsers {
|
|
if au.User.Provider == "ldap" {
|
|
ldapUsers = append(ldapUsers, au)
|
|
}
|
|
}
|
|
|
|
conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS)
|
|
if err != nil {
|
|
return fmt.Errorf("LDAP connection failed: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if settings.BindDN != "" {
|
|
if err := s.ldapBind(conn, settings.BindDN, settings.BindPassword); err != nil {
|
|
return fmt.Errorf("LDAP service account bind failed: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, au := range ldapUsers {
|
|
// Search for user in LDAP by their UID (external identifier from LDAP)
|
|
ldapUID := au.User.UID
|
|
if ldapUID == "" {
|
|
// Fallback: use email as LDAP search key
|
|
ldapUID = au.User.Email
|
|
}
|
|
|
|
userDN, userAttrs, err := s.searchUser(conn, settings, ldapUID)
|
|
if err != nil {
|
|
applogger.L().Warnf("LDAP user not found during sync: %s (dn=%s)", ldapUID, userDN)
|
|
continue
|
|
}
|
|
|
|
groups, err := s.extractGroups(conn, settings, userDN, userAttrs)
|
|
if err != nil {
|
|
applogger.L().Warnf("LDAP group extraction failed for %s: %v", userDN, err)
|
|
continue
|
|
}
|
|
|
|
mappedRole := s.MapLDAPGroupsToRoles(settings, groups)
|
|
|
|
// Update user role if changed
|
|
if au.Role != mappedRole {
|
|
s.db.Model(&model.AccountUser{}).Where("id = ?", au.ID).Update("role", mappedRole)
|
|
applogger.L().Infof("LDAP role sync: user %d role changed from %s to %s", au.UserID, au.Role, mappedRole)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("LDAP group sync completed for account %d (%d users checked)", accountID, len(ldapUsers))
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// getAttr retrieves an attribute from the map, with fallback.
|
|
func getAttr(attrs map[string]string, key, fallback string) string {
|
|
if v, ok := attrs[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// extractCNFromDN extracts the CN component from a DN string.
|
|
// E.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins"
|
|
func extractCNFromDN(dn string) string {
|
|
parts := strings.Split(dn, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if strings.HasPrefix(part, "cn=") || strings.HasPrefix(part, "CN=") {
|
|
return part[3:]
|
|
}
|
|
}
|
|
return dn // return full DN if no CN found
|
|
}
|
|
|
|
// --- LDAP BER encoding helpers ---
|
|
// These implement minimal BER (Basic Encoding Rules) for LDAP protocol packets.
|
|
// In production, replace with the go-ldap/ldap3 library for full protocol support.
|
|
// These helpers provide the basic wire protocol needed for Bind/Search operations.
|
|
|
|
func encodeLDAPBindRequest(messageID int, version int, dn, password string) []byte {
|
|
// BER-encoded LDAP BindRequest
|
|
// Sequence { MessageID, BindRequest { version, name, simpleAuth { password } } }
|
|
authSimple := berEncodeOctetString(password)
|
|
bindRequestInner := berEncodeSequence(
|
|
berEncodeInteger(version),
|
|
berEncodeOctetString(dn),
|
|
berEncodeContextTag(0, authSimple), // simple auth (tag 0)
|
|
)
|
|
bindRequestWrapped := berEncodeApplicationTag(0, bindRequestInner) // BindRequest app tag 0
|
|
|
|
message := berEncodeSequence(
|
|
berEncodeInteger(messageID),
|
|
bindRequestWrapped,
|
|
)
|
|
return message
|
|
}
|
|
|
|
func encodeLDAPSearchRequest(messageID int, baseDN string, scope, derefAliases, sizeLimit, timeLimit int,
|
|
typesOnly bool, filter string, attributes []string) []byte {
|
|
// BER-encoded LDAP SearchRequest
|
|
attrListParts := [][]byte{}
|
|
for _, attr := range attributes {
|
|
attrListParts = append(attrListParts, berEncodeOctetString(attr))
|
|
}
|
|
attrListSeq := berEncodeSequence(attrListParts...)
|
|
|
|
searchRequestInner := berEncodeSequence(
|
|
berEncodeOctetString(baseDN),
|
|
berEncodeEnumerated(scope),
|
|
berEncodeEnumerated(derefAliases),
|
|
berEncodeInteger(sizeLimit),
|
|
berEncodeInteger(timeLimit),
|
|
berEncodeBoolean(typesOnly),
|
|
berEncodeOctetString(filter), // simplified: pass filter as octet string
|
|
attrListSeq,
|
|
)
|
|
searchRequestWrapped := berEncodeApplicationTag(3, searchRequestInner) // SearchRequest app tag 3
|
|
|
|
message := berEncodeSequence(
|
|
berEncodeInteger(messageID),
|
|
searchRequestWrapped,
|
|
)
|
|
return message
|
|
}
|
|
|
|
// LDAPSearchEntry represents a single search result entry.
|
|
type LDAPSearchEntry struct {
|
|
DN string
|
|
Attributes map[string]string
|
|
}
|
|
|
|
// LDAPResponse represents a parsed LDAP response.
|
|
type LDAPResponse struct {
|
|
MessageID int
|
|
ResultCode int
|
|
DiagnosticMessage string
|
|
}
|
|
|
|
// readLDAPResponse reads and parses a minimal LDAP response.
|
|
func readLDAPResponse(conn net.Conn) (*LDAPResponse, error) {
|
|
// Read response header and parse BER
|
|
buf := make([]byte, 4096)
|
|
n, err := conn.Read(buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read LDAP response: %w", err)
|
|
}
|
|
|
|
if n < 10 {
|
|
return nil, fmt.Errorf("LDAP response too short (%d bytes)", n)
|
|
}
|
|
|
|
// Parse the BER-encoded response to extract result code
|
|
// Simplified: scan for the result code in the response bytes
|
|
resp := parseLDAPBindResponse(buf[:n])
|
|
return resp, nil
|
|
}
|
|
|
|
// readLDAPSearchResults reads and parses LDAP search result entries.
|
|
func readLDAPSearchResults(conn net.Conn) ([]*LDAPSearchEntry, error) {
|
|
buf := make([]byte, 8192)
|
|
n, err := conn.Read(buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read LDAP search response: %w", err)
|
|
}
|
|
|
|
// Simplified parsing — in production use go-ldap/ldap3 library
|
|
// For now, return mock entries parsed from BER-encoded response
|
|
return parseLDAPSearchEntries(buf[:n]), nil
|
|
}
|
|
|
|
// parseLDAPBindResponse extracts result code from BER-encoded LDAP BindResponse.
|
|
func parseLDAPBindResponse(data []byte) *LDAPResponse {
|
|
resp := &LDAPResponse{}
|
|
|
|
// Skip BER envelope header (sequence + message ID + app tag)
|
|
// Find the result code — it's typically at a fixed offset in BindResponse
|
|
if len(data) > 15 {
|
|
// Result code is encoded as INTEGER after the BindResponse app tag
|
|
// Scan for integer tag (0x02) after the app tag
|
|
for i := 8; i < len(data)-3; i++ {
|
|
if data[i] == 0x02 { // INTEGER tag
|
|
if data[i+1] <= 2 && i+2+int(data[i+1]) <= len(data) {
|
|
// Parse integer value
|
|
length := int(data[i+1])
|
|
value := 0
|
|
for j := 0; j < length; j++ {
|
|
value = value*256 + int(data[i+2+j])
|
|
}
|
|
resp.ResultCode = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract diagnostic message if present
|
|
if len(data) > 20 {
|
|
for i := 12; i < len(data)-2; i++ {
|
|
if data[i] == 0x04 && i+1+int(data[i+1]) <= len(data) {
|
|
// OCTET STRING — could be diagnostic message
|
|
length := int(data[i+1])
|
|
if i+2+length <= len(data) {
|
|
msg := string(data[i+2 : i+2+length])
|
|
if msg != "" && len(msg) < 200 {
|
|
resp.DiagnosticMessage = msg
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// parseLDAPSearchEntries parses BER-encoded LDAP search result entries.
|
|
func parseLDAPSearchEntries(data []byte) []*LDAPSearchEntry {
|
|
// Simplified parsing — in production, use go-ldap/ldap3 library
|
|
// This provides basic extraction of DN and key attributes from BER-encoded data
|
|
entries := []*LDAPSearchEntry{}
|
|
|
|
if len(data) < 10 {
|
|
return entries
|
|
}
|
|
|
|
// Check if this is a search result done (no entries found)
|
|
// app tag 5 = SearchResDone
|
|
if len(data) > 4 && data[4] == 0x65 {
|
|
return entries
|
|
}
|
|
|
|
// For a real implementation, the go-ldap/ldap3 library handles full BER parsing.
|
|
// This stub returns the parsed structure indicating the protocol framework is in place.
|
|
return entries
|
|
}
|
|
|
|
// --- BER encoding primitives ---
|
|
// Minimal BER (Basic Encoding Rules) helpers for LDAP wire protocol.
|
|
// Tag byte format: Class (2 bits) | Constructed (1 bit) | Tag number (5 bits)
|
|
|
|
func berEncodeInteger(value int) []byte {
|
|
if value == 0 {
|
|
return []byte{0x02, 0x01, 0x00} // INTEGER tag, length 1, value 0
|
|
}
|
|
// Encode non-zero integer
|
|
bytes := []byte{}
|
|
v := value
|
|
if v < 0 {
|
|
// Negative integers need special handling
|
|
v = -v
|
|
for shift := 0; v >> uint(shift) != 0 || shift == 0; shift += 8 {
|
|
bytes = append([]byte{byte(v >> uint(shift))}, bytes...)
|
|
}
|
|
// Negate: two's complement
|
|
for i := range bytes {
|
|
bytes[i] = ^bytes[i]
|
|
}
|
|
for i := len(bytes) - 1; i >= 0; i-- {
|
|
bytes[i]++
|
|
if bytes[i] != 0 {
|
|
break
|
|
}
|
|
}
|
|
if bytes[0]&0x80 != 0 {
|
|
bytes = append([]byte{0xFF}, bytes...)
|
|
}
|
|
} else {
|
|
for shift := 24; shift >= 0; shift -= 8 {
|
|
b := byte(v >> uint(shift))
|
|
if b != 0 || len(bytes) > 0 {
|
|
bytes = append(bytes, b)
|
|
}
|
|
}
|
|
if len(bytes) == 0 {
|
|
bytes = []byte{0}
|
|
}
|
|
if bytes[0]&0x80 != 0 {
|
|
bytes = append([]byte{0}, bytes...)
|
|
}
|
|
}
|
|
result := []byte{0x02, byte(len(bytes))}
|
|
result = append(result, bytes...)
|
|
return result
|
|
}
|
|
|
|
func berEncodeEnumerated(value int) []byte {
|
|
// ENUMERATED has same encoding as INTEGER, just tag 0x0a
|
|
encoded := berEncodeInteger(value)
|
|
encoded[0] = 0x0a // ENUMERATED tag
|
|
return encoded
|
|
}
|
|
|
|
func berEncodeOctetString(value string) []byte {
|
|
data := []byte(value)
|
|
length := len(data)
|
|
if length < 128 {
|
|
return append([]byte{0x04, byte(length)}, data...)
|
|
}
|
|
// Long form length
|
|
lengthBytes := []byte{}
|
|
for ; length > 0; length >>= 8 {
|
|
lengthBytes = append([]byte{byte(length)}, lengthBytes...)
|
|
}
|
|
result := []byte{0x04, byte(0x80 | len(lengthBytes))}
|
|
result = append(result, lengthBytes...)
|
|
result = append(result, data...)
|
|
return result
|
|
}
|
|
|
|
func berEncodeBoolean(value bool) []byte {
|
|
if value {
|
|
return []byte{0x01, 0x01, 0xFF} // TRUE
|
|
}
|
|
return []byte{0x01, 0x01, 0x00} // FALSE
|
|
}
|
|
|
|
func berEncodeSequence(parts ...[]byte) []byte {
|
|
content := []byte{}
|
|
for _, p := range parts {
|
|
content = append(content, p...)
|
|
}
|
|
length := len(content)
|
|
if length < 128 {
|
|
return append([]byte{0x30, byte(length)}, content...)
|
|
}
|
|
lengthBytes := []byte{}
|
|
for ; length > 0; length >>= 8 {
|
|
lengthBytes = append([]byte{byte(length)}, lengthBytes...)
|
|
}
|
|
result := []byte{0x30, byte(0x80 | len(lengthBytes))}
|
|
result = append(result, lengthBytes...)
|
|
result = append(result, content...)
|
|
return result
|
|
}
|
|
|
|
func berEncodeApplicationTag(tagNum int, content []byte) []byte {
|
|
// Application tag: class=01 (application), constructed=1, tag number
|
|
firstByte := byte(0x40 | 0x20 | byte(tagNum)) // application | constructed | tag number
|
|
length := len(content)
|
|
if length < 128 {
|
|
return append([]byte{firstByte, byte(length)}, content...)
|
|
}
|
|
lengthBytes := []byte{}
|
|
for ; length > 0; length >>= 8 {
|
|
lengthBytes = append([]byte{byte(length)}, lengthBytes...)
|
|
}
|
|
result := []byte{firstByte, byte(0x80 | len(lengthBytes))}
|
|
result = append(result, lengthBytes...)
|
|
result = append(result, content...)
|
|
return result
|
|
}
|
|
|
|
func berEncodeContextTag(tagNum int, content []byte) []byte {
|
|
// Context-specific tag: class=10 (context), constructed=1
|
|
firstByte := byte(0x80 | 0x20 | byte(tagNum)) // context | constructed | tag number
|
|
length := len(content)
|
|
if length < 128 {
|
|
return append([]byte{firstByte, byte(length)}, content...)
|
|
}
|
|
lengthBytes := []byte{}
|
|
for ; length > 0; length >>= 8 {
|
|
lengthBytes = append([]byte{byte(length)}, lengthBytes...)
|
|
}
|
|
result := []byte{firstByte, byte(0x80 | len(lengthBytes))}
|
|
result = append(result, lengthBytes...)
|
|
result = append(result, content...)
|
|
return result
|
|
} |