1
0
Files
breadcrumb-the-shire/lib/Service/Auth/LdapAuthService.php

295 lines
9.8 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\Auth;
/**
* Handles LDAP authentication: connect, bind, search, and attribute retrieval.
*
* Supports two bind methods:
* - search_then_bind: service account binds first, searches for user DN, then re-binds as user
* - direct_bind: constructs user DN from a format string and binds directly
*
* All user-supplied values are escaped via ldap_escape() to prevent LDAP injection.
*/
class LdapAuthService
{
public function __construct(
private readonly LdapConnectionGateway $ldap
) {
}
/**
* Test connectivity and service-account bind.
*
* @param array $config Decrypted LDAP config (host, port, encryption_mode, etc.)
* @return array{ok: bool, error?: string}
*/
public function testConnection(array $config): array
{
try {
$conn = $this->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;
}
}