Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
295 lines
9.8 KiB
PHP
295 lines
9.8 KiB
PHP
<?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;
|
|
}
|
|
}
|