Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user