createConnection($config); } catch (\RuntimeException $e) { return ['ok' => false, 'error' => $e->getMessage()]; } $bindDn = trim((string) ($config['bind_dn'] ?? '')); $bindPassword = (string) ($config['bind_password'] ?? ''); if ($bindDn !== '' && $bindPassword !== '') { if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) { $error = $this->ldap->error($conn); $this->ldap->unbind($conn); return ['ok' => false, 'error' => 'Service account bind failed: ' . $error]; } } $this->ldap->unbind($conn); return ['ok' => true]; } /** * Authenticate a user against the LDAP directory. * * @param array $config Decrypted LDAP config * @param string $username Username (sAMAccountName, uid, or email depending on config) * @param string $password User's plaintext password (never logged) * @return array{ok: bool, attributes?: array, unique_id?: string, dn?: string, error?: string} */ public function authenticate(array $config, string $username, string $password): array { if (trim($username) === '' || $password === '') { return ['ok' => false, 'error' => 'credentials_empty']; } try { $conn = $this->createConnection($config); } catch (\RuntimeException $e) { return ['ok' => false, 'error' => 'connection_failed']; } $bindMethod = $config['bind_method'] ?? 'search_then_bind'; if ($bindMethod === 'direct_bind') { return $this->authenticateDirectBind($conn, $config, $username, $password); } return $this->authenticateSearchThenBind($conn, $config, $username, $password); } private function authenticateSearchThenBind(\LDAP\Connection $conn, array $config, string $username, string $password): array { $bindDn = trim((string) ($config['bind_dn'] ?? '')); $bindPassword = (string) ($config['bind_password'] ?? ''); if ($bindDn !== '') { if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) { $this->ldap->unbind($conn); return ['ok' => false, 'error' => 'service_bind_failed']; } } $userEntry = $this->searchUser($conn, $config, $username); if ($userEntry === null) { $this->ldap->unbind($conn); return ['ok' => false, 'error' => 'user_not_found']; } $userDn = $userEntry['dn']; if (!$this->ldap->bind($conn, $userDn, $password)) { $this->ldap->unbind($conn); return ['ok' => false, 'error' => 'invalid_credentials']; } $attributeMap = $this->parseAttributeMap($config); $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID'; $attributes = $this->readAttributes($userEntry, $attributeMap); $uniqueId = $this->extractUniqueId($userEntry, $uniqueIdAttr); $this->ldap->unbind($conn); return [ 'ok' => true, 'attributes' => $attributes, 'unique_id' => $uniqueId, 'dn' => $userDn, ]; } private function authenticateDirectBind(\LDAP\Connection $conn, array $config, string $username, string $password): array { $format = $config['bind_username_format'] ?? '%s'; $escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_DN); $userDn = str_replace('%s', $escapedUsername, $format); if (!$this->ldap->bind($conn, $userDn, $password)) { $this->ldap->unbind($conn); return ['ok' => false, 'error' => 'invalid_credentials']; } $userEntry = $this->searchUser($conn, $config, $username); $attributeMap = $this->parseAttributeMap($config); $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID'; $attributes = $userEntry !== null ? $this->readAttributes($userEntry, $attributeMap) : []; $uniqueId = $userEntry !== null ? $this->extractUniqueId($userEntry, $uniqueIdAttr) : ''; $this->ldap->unbind($conn); return [ 'ok' => true, 'attributes' => $attributes, 'unique_id' => $uniqueId, 'dn' => $userDn, ]; } private function searchUser(\LDAP\Connection $conn, array $config, string $username): ?array { $baseDn = $config['base_dn'] ?? ''; $filterTemplate = $config['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))'; $searchScope = ($config['user_search_scope'] ?? 'sub') === 'one' ? 1 : 0; $escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_FILTER); $filter = str_replace('%s', $escapedUsername, $filterTemplate); $attributeMap = $this->parseAttributeMap($config); $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID'; $requestAttrs = array_values($attributeMap); $requestAttrs[] = $uniqueIdAttr; $requestAttrs = array_unique(array_filter($requestAttrs)); $result = $this->ldap->search($conn, $baseDn, $filter, $requestAttrs, $searchScope); if ($result === false) { return null; } $entries = $this->ldap->getEntries($conn, $result); if ($entries === false || ($entries['count'] ?? 0) < 1) { return null; } return $entries[0]; } private function readAttributes(array $entry, array $attributeMap): array { $result = []; foreach ($attributeMap as $appField => $ldapAttr) { $ldapAttrLower = strtolower($ldapAttr); $value = null; if (isset($entry[$ldapAttrLower]) && is_array($entry[$ldapAttrLower]) && ($entry[$ldapAttrLower]['count'] ?? 0) > 0) { $value = $entry[$ldapAttrLower][0]; } $result[$appField] = $value; } return $result; } private function extractUniqueId(array $entry, string $attribute): string { $attrLower = strtolower($attribute); if (!isset($entry[$attrLower]) || !is_array($entry[$attrLower]) || ($entry[$attrLower]['count'] ?? 0) < 1) { return ''; } $value = $entry[$attrLower][0]; if ($attrLower === 'objectguid' && strlen($value) === 16) { return self::binaryGuidToUuid($value); } return (string) $value; } /** * Convert a 16-byte binary Active Directory objectGUID to UUID string format. */ public static function binaryGuidToUuid(string $binary): string { if (strlen($binary) !== 16) { return bin2hex($binary); } $parts = unpack('Va/vb/vc/C2d/C6e', $binary); if ($parts === false) { return bin2hex($binary); } return sprintf( '%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x', $parts['a'], $parts['b'], $parts['c'], $parts['d1'], $parts['d2'], $parts['e1'], $parts['e2'], $parts['e3'], $parts['e4'], $parts['e5'], $parts['e6'] ); } private function parseAttributeMap(array $config): array { $raw = $config['attribute_map'] ?? '{}'; if (is_string($raw)) { $map = json_decode($raw, true); if (!is_array($map)) { $map = []; } } elseif (is_array($raw)) { $map = $raw; } else { $map = []; } return $map; } /** * @throws \RuntimeException on connection failure */ private function createConnection(array $config): \LDAP\Connection { $host = trim((string) ($config['host'] ?? '')); $port = (int) ($config['port'] ?? 389); $encryptionMode = $config['encryption_mode'] ?? 'starttls'; $verifyTls = (bool) ($config['verify_tls_certificate'] ?? true); $networkTimeout = (int) ($config['network_timeout'] ?? 5); if ($host === '') { throw new \RuntimeException('LDAP host is not configured'); } if ($encryptionMode === 'ldaps') { $uri = 'ldaps://' . $host . ':' . $port; } else { $uri = 'ldap://' . $host . ':' . $port; } $conn = $this->ldap->connect($uri); if ($conn === false) { throw new \RuntimeException('ldap_connect failed for ' . $host); } $this->ldap->setOption($conn, LDAP_OPT_PROTOCOL_VERSION, 3); $this->ldap->setOption($conn, LDAP_OPT_REFERRALS, 0); $this->ldap->setOption($conn, LDAP_OPT_NETWORK_TIMEOUT, $networkTimeout); if (!$verifyTls) { $this->ldap->setOption($conn, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER); } if ($encryptionMode === 'starttls') { if (!$this->ldap->startTls($conn)) { throw new \RuntimeException('STARTTLS negotiation failed'); } } return $conn; } }