forked from fa/breadcrumb-the-shire
feat: add LDAP authentication as per-tenant login method
Add LDAP as a third authentication option alongside local password and Microsoft Entra ID SSO. Per-tenant configuration supports Active Directory, OpenLDAP, and FreeIPA with configurable attribute mapping, search-then-bind and direct-bind methods, encrypted bind credentials (AES-256-GCM), profile sync on login, and user auto-provisioning via external identity linking. Includes admin UI for connection settings with test-connection button, login page LDAP form, 25 new PHPUnit tests, and de/en translations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,36 @@ CREATE TABLE IF NOT EXISTS `tenant_auth_microsoft` (
|
||||
CONSTRAINT `fk_tenant_auth_microsoft_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `tenant_auth_ldap` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`enforce_ldap_login` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`port` INT UNSIGNED NOT NULL DEFAULT 389,
|
||||
`encryption_mode` ENUM('none','starttls','ldaps') NOT NULL DEFAULT 'starttls',
|
||||
`verify_tls_certificate` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`base_dn` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`bind_dn_enc` TEXT NULL,
|
||||
`bind_password_enc` TEXT NULL,
|
||||
`user_search_filter` VARCHAR(500) NOT NULL DEFAULT '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
`user_search_scope` ENUM('sub','one') NOT NULL DEFAULT 'sub',
|
||||
`bind_method` ENUM('search_then_bind','direct_bind') NOT NULL DEFAULT 'search_then_bind',
|
||||
`bind_username_format` VARCHAR(255) NULL,
|
||||
`unique_id_attribute` VARCHAR(64) NOT NULL DEFAULT 'objectGUID',
|
||||
`attribute_map` JSON NOT NULL DEFAULT '{"first_name":"givenName","last_name":"sn","email":"mail","phone":"telephoneNumber","mobile":"mobile"}',
|
||||
`sync_profile_on_login` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`sync_profile_fields` TEXT NULL,
|
||||
`allowed_domains` TEXT NULL,
|
||||
`auto_remember_mode` TINYINT(1) NULL DEFAULT NULL,
|
||||
`network_timeout` INT UNSIGNED NOT NULL DEFAULT 5,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_tenant_auth_ldap_tenant` (`tenant_id`),
|
||||
CONSTRAINT `fk_tenant_auth_ldap_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_external_identities` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
|
||||
30
db/updates/2026-03-25-tenant-auth-ldap.sql
Normal file
30
db/updates/2026-03-25-tenant-auth-ldap.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Idempotent: tenant_auth_ldap table for LDAP authentication per tenant
|
||||
CREATE TABLE IF NOT EXISTS `tenant_auth_ldap` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`enforce_ldap_login` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`port` INT UNSIGNED NOT NULL DEFAULT 389,
|
||||
`encryption_mode` ENUM('none','starttls','ldaps') NOT NULL DEFAULT 'starttls',
|
||||
`verify_tls_certificate` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`base_dn` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`bind_dn_enc` TEXT NULL,
|
||||
`bind_password_enc` TEXT NULL,
|
||||
`user_search_filter` VARCHAR(500) NOT NULL DEFAULT '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
`user_search_scope` ENUM('sub','one') NOT NULL DEFAULT 'sub',
|
||||
`bind_method` ENUM('search_then_bind','direct_bind') NOT NULL DEFAULT 'search_then_bind',
|
||||
`bind_username_format` VARCHAR(255) NULL,
|
||||
`unique_id_attribute` VARCHAR(64) NOT NULL DEFAULT 'objectGUID',
|
||||
`attribute_map` JSON NOT NULL DEFAULT '{"first_name":"givenName","last_name":"sn","email":"mail","phone":"telephoneNumber","mobile":"mobile"}',
|
||||
`sync_profile_on_login` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`sync_profile_fields` TEXT NULL,
|
||||
`allowed_domains` TEXT NULL,
|
||||
`auto_remember_mode` TINYINT(1) NULL DEFAULT NULL,
|
||||
`network_timeout` INT UNSIGNED NOT NULL DEFAULT 5,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_tenant_auth_ldap_tenant` (`tenant_id`),
|
||||
CONSTRAINT `fk_tenant_auth_ldap_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -11,10 +11,11 @@ RUN apt-get update \
|
||||
libjpeg-dev \
|
||||
libpng-dev \
|
||||
libwebp-dev \
|
||||
libldap2-dev \
|
||||
&& pecl install memcached \
|
||||
&& docker-php-ext-enable memcached \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install pdo_mysql mysqli gd zip \
|
||||
&& docker-php-ext-install pdo_mysql mysqli gd zip ldap \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -1355,5 +1355,63 @@
|
||||
"Environment": "Umgebung",
|
||||
"Modules": "Module",
|
||||
"Version": "Version",
|
||||
"Breadcrumb": "Breadcrumb"
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"LDAP Authentication": "LDAP-Authentifizierung",
|
||||
"LDAP Username": "LDAP-Benutzername",
|
||||
"Sign in with LDAP": "Mit LDAP anmelden",
|
||||
"LDAP login is not available for this tenant.": "LDAP-Anmeldung ist für diesen Mandanten nicht verfügbar.",
|
||||
"Your email domain is not allowed for LDAP login on this tenant.": "Ihre E-Mail-Domain ist für die LDAP-Anmeldung bei diesem Mandanten nicht zugelassen.",
|
||||
"LDAP server is not reachable. Please try again later.": "LDAP-Server ist nicht erreichbar. Bitte versuchen Sie es später erneut.",
|
||||
"Your LDAP account does not have an email address configured.": "Ihr LDAP-Konto hat keine E-Mail-Adresse konfiguriert.",
|
||||
"LDAP host is required": "LDAP-Host ist erforderlich",
|
||||
"LDAP base DN is required": "LDAP-Base-DN ist erforderlich",
|
||||
"LDAP port must be between 1 and 65535": "LDAP-Port muss zwischen 1 und 65535 liegen",
|
||||
"Search filter must contain %s placeholder": "Suchfilter muss den Platzhalter %s enthalten",
|
||||
"LDAP-only login requires a complete configuration": "Nur-LDAP-Anmeldung erfordert eine vollständige Konfiguration",
|
||||
"Direct bind method requires a username format": "Direkte Bindung erfordert ein Benutzernamenformat",
|
||||
"Bind DN could not be encrypted": "Bind-DN konnte nicht verschlüsselt werden",
|
||||
"Bind password could not be encrypted": "Bind-Passwort konnte nicht verschlüsselt werden",
|
||||
"Tenant LDAP settings could not be saved": "LDAP-Einstellungen des Mandanten konnten nicht gespeichert werden",
|
||||
"Bind DN could not be decrypted": "Bind-DN konnte nicht entschlüsselt werden",
|
||||
"Bind password could not be decrypted": "Bind-Passwort konnte nicht entschlüsselt werden",
|
||||
"LDAP login": "LDAP-Anmeldung",
|
||||
"LDAP setup is incomplete": "LDAP-Konfiguration ist unvollständig",
|
||||
"LDAP connection settings": "LDAP-Verbindungseinstellungen",
|
||||
"Enable LDAP login for this tenant": "LDAP-Anmeldung für diesen Mandanten aktivieren",
|
||||
"LDAP Host": "LDAP-Host",
|
||||
"Encryption": "Verschlüsselung",
|
||||
"None (not recommended)": "Keine (nicht empfohlen)",
|
||||
"Verify TLS certificate": "TLS-Zertifikat überprüfen",
|
||||
"Base DN": "Base-DN",
|
||||
"Bind DN (service account)": "Bind-DN (Dienstkonto)",
|
||||
"Bind password": "Bind-Passwort",
|
||||
"Clear bind password": "Bind-Passwort löschen",
|
||||
"Test connection": "Verbindung testen",
|
||||
"Search and bind settings": "Such- und Bindungseinstellungen",
|
||||
"Bind method": "Bindungsmethode",
|
||||
"Search then bind (recommended)": "Suchen dann binden (empfohlen)",
|
||||
"Direct bind": "Direkte Bindung",
|
||||
"User search filter": "Benutzer-Suchfilter",
|
||||
"Search scope": "Suchbereich",
|
||||
"Subtree (recursive)": "Unterbaum (rekursiv)",
|
||||
"One level": "Eine Ebene",
|
||||
"Network timeout (seconds)": "Netzwerk-Timeout (Sekunden)",
|
||||
"Direct bind username format": "Format für direkte Bindung",
|
||||
"Unique ID attribute": "Eindeutiges ID-Attribut",
|
||||
"Attribute mapping and sync": "Attributzuordnung und Synchronisierung",
|
||||
"Map application fields to LDAP directory attributes.": "Anwendungsfelder den LDAP-Verzeichnisattributen zuordnen.",
|
||||
"Profile sync on LDAP login": "Profilsynchronisierung bei LDAP-Anmeldung",
|
||||
"Allow LDAP-only login (disable local password login)": "Nur LDAP-Anmeldung erlauben (lokale Passwortanmeldung deaktivieren)",
|
||||
"Sync email": "E-Mail synchronisieren",
|
||||
"AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID": "AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID",
|
||||
"Login could not be completed. Please contact your administrator.": "Anmeldung konnte nicht abgeschlossen werden. Bitte kontaktieren Sie Ihren Administrator.",
|
||||
"Only for direct bind. Use %s as placeholder. Example: %s@corp.example.com or uid=%s,ou=People,dc=example,dc=com": "Nur für direkte Bindung. Verwenden Sie %s als Platzhalter. Beispiel: %s@corp.example.com oder uid=%s,ou=People,dc=example,dc=com",
|
||||
"Optional allow-list, comma or newline separated.": "Optionale Zulassungsliste, durch Komma oder Zeilenumbruch getrennt.",
|
||||
"Please enter your username and password.": "Bitte geben Sie Ihren Benutzernamen und Ihr Passwort ein.",
|
||||
"Port": "Port",
|
||||
"Search-then-bind uses a service account to find the user DN first, then binds with the user credentials.": "Suchen-dann-binden verwendet ein Dienstkonto, um zuerst den Benutzer-DN zu finden, und bindet dann mit den Benutzeranmeldedaten.",
|
||||
"Use %s as placeholder for the username. Examples: (&(objectClass=user)(sAMAccountName=%s)) for AD, (&(objectClass=inetOrgPerson)(uid=%s)) for OpenLDAP": "Verwenden Sie %s als Platzhalter für den Benutzernamen. Beispiele: (&(objectClass=user)(sAMAccountName=%s)) für AD, (&(objectClass=inetOrgPerson)(uid=%s)) für OpenLDAP",
|
||||
"Username or password not valid": "Benutzername oder Passwort ungültig",
|
||||
"Write-only. Leave empty to keep current value.": "Nur Schreibzugriff. Leer lassen, um den aktuellen Wert beizubehalten.",
|
||||
"Your account is deactivated.": "Ihr Konto ist deaktiviert."
|
||||
}
|
||||
|
||||
@@ -1355,5 +1355,63 @@
|
||||
"Environment": "Environment",
|
||||
"Modules": "Modules",
|
||||
"Version": "Version",
|
||||
"Breadcrumb": "Breadcrumb"
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"LDAP Authentication": "LDAP Authentication",
|
||||
"LDAP Username": "LDAP Username",
|
||||
"Sign in with LDAP": "Sign in with LDAP",
|
||||
"LDAP login is not available for this tenant.": "LDAP login is not available for this tenant.",
|
||||
"Your email domain is not allowed for LDAP login on this tenant.": "Your email domain is not allowed for LDAP login on this tenant.",
|
||||
"LDAP server is not reachable. Please try again later.": "LDAP server is not reachable. Please try again later.",
|
||||
"Your LDAP account does not have an email address configured.": "Your LDAP account does not have an email address configured.",
|
||||
"LDAP host is required": "LDAP host is required",
|
||||
"LDAP base DN is required": "LDAP base DN is required",
|
||||
"LDAP port must be between 1 and 65535": "LDAP port must be between 1 and 65535",
|
||||
"Search filter must contain %s placeholder": "Search filter must contain %s placeholder",
|
||||
"LDAP-only login requires a complete configuration": "LDAP-only login requires a complete configuration",
|
||||
"Direct bind method requires a username format": "Direct bind method requires a username format",
|
||||
"Bind DN could not be encrypted": "Bind DN could not be encrypted",
|
||||
"Bind password could not be encrypted": "Bind password could not be encrypted",
|
||||
"Tenant LDAP settings could not be saved": "Tenant LDAP settings could not be saved",
|
||||
"Bind DN could not be decrypted": "Bind DN could not be decrypted",
|
||||
"Bind password could not be decrypted": "Bind password could not be decrypted",
|
||||
"LDAP login": "LDAP login",
|
||||
"LDAP setup is incomplete": "LDAP setup is incomplete",
|
||||
"LDAP connection settings": "LDAP connection settings",
|
||||
"Enable LDAP login for this tenant": "Enable LDAP login for this tenant",
|
||||
"LDAP Host": "LDAP Host",
|
||||
"Encryption": "Encryption",
|
||||
"None (not recommended)": "None (not recommended)",
|
||||
"Verify TLS certificate": "Verify TLS certificate",
|
||||
"Base DN": "Base DN",
|
||||
"Bind DN (service account)": "Bind DN (service account)",
|
||||
"Bind password": "Bind password",
|
||||
"Clear bind password": "Clear bind password",
|
||||
"Test connection": "Test connection",
|
||||
"Search and bind settings": "Search and bind settings",
|
||||
"Bind method": "Bind method",
|
||||
"Search then bind (recommended)": "Search then bind (recommended)",
|
||||
"Direct bind": "Direct bind",
|
||||
"User search filter": "User search filter",
|
||||
"Search scope": "Search scope",
|
||||
"Subtree (recursive)": "Subtree (recursive)",
|
||||
"One level": "One level",
|
||||
"Network timeout (seconds)": "Network timeout (seconds)",
|
||||
"Direct bind username format": "Direct bind username format",
|
||||
"Unique ID attribute": "Unique ID attribute",
|
||||
"Attribute mapping and sync": "Attribute mapping and sync",
|
||||
"Map application fields to LDAP directory attributes.": "Map application fields to LDAP directory attributes.",
|
||||
"Profile sync on LDAP login": "Profile sync on LDAP login",
|
||||
"Allow LDAP-only login (disable local password login)": "Allow LDAP-only login (disable local password login)",
|
||||
"Sync email": "Sync email",
|
||||
"AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID": "AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID",
|
||||
"Login could not be completed. Please contact your administrator.": "Login could not be completed. Please contact your administrator.",
|
||||
"Only for direct bind. Use %s as placeholder. Example: %s@corp.example.com or uid=%s,ou=People,dc=example,dc=com": "Only for direct bind. Use %s as placeholder. Example: %s@corp.example.com or uid=%s,ou=People,dc=example,dc=com",
|
||||
"Optional allow-list, comma or newline separated.": "Optional allow-list, comma or newline separated.",
|
||||
"Please enter your username and password.": "Please enter your username and password.",
|
||||
"Port": "Port",
|
||||
"Search-then-bind uses a service account to find the user DN first, then binds with the user credentials.": "Search-then-bind uses a service account to find the user DN first, then binds with the user credentials.",
|
||||
"Use %s as placeholder for the username. Examples: (&(objectClass=user)(sAMAccountName=%s)) for AD, (&(objectClass=inetOrgPerson)(uid=%s)) for OpenLDAP": "Use %s as placeholder for the username. Examples: (&(objectClass=user)(sAMAccountName=%s)) for AD, (&(objectClass=inetOrgPerson)(uid=%s)) for OpenLDAP",
|
||||
"Username or password not valid": "Username or password not valid",
|
||||
"Write-only. Leave empty to keep current value.": "Write-only. Leave empty to keep current value.",
|
||||
"Your account is deactivated.": "Your account is deactivated."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Auth\ApiTokenEndpointService;
|
||||
@@ -15,6 +16,7 @@ use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthService;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\Auth\EmailVerificationService;
|
||||
use MintyPHP\Service\Auth\LdapAuthService;
|
||||
use MintyPHP\Service\Auth\MicrosoftOidcService;
|
||||
use MintyPHP\Service\Auth\PasswordResetService;
|
||||
use MintyPHP\Service\Auth\RememberMeService;
|
||||
@@ -38,7 +40,9 @@ final class AuthRegistrar implements ContainerRegistrar
|
||||
$container->set(TenantSsoService::class, static fn (AppContainer $c): TenantSsoService => $c->get(AuthServicesFactory::class)->createTenantSsoService());
|
||||
$container->set(MicrosoftOidcService::class, static fn (AppContainer $c): MicrosoftOidcService => $c->get(AuthServicesFactory::class)->createMicrosoftOidcService());
|
||||
$container->set(SsoUserLinkService::class, static fn (AppContainer $c): SsoUserLinkService => $c->get(AuthServicesFactory::class)->createSsoUserLinkService());
|
||||
$container->set(LdapAuthService::class, static fn (AppContainer $c): LdapAuthService => $c->get(AuthServicesFactory::class)->createLdapAuthService());
|
||||
$container->set(TenantMicrosoftAuthRepository::class, static fn (AppContainer $c): TenantMicrosoftAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantMicrosoftAuthRepository());
|
||||
$container->set(TenantLdapAuthRepository::class, static fn (AppContainer $c): TenantLdapAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantLdapAuthRepository());
|
||||
$container->set(ApiTokenRepository::class, static fn (AppContainer $c): ApiTokenRepository => $c->get(AuthRepositoryFactory::class)->createApiTokenRepository());
|
||||
$container->set(RememberTokenRepository::class, static fn (AppContainer $c): RememberTokenRepository => $c->get(AuthRepositoryFactory::class)->createRememberTokenRepository());
|
||||
$container->set(PasswordResetRepository::class, static fn (AppContainer $c): PasswordResetRepository => $c->get(AuthRepositoryFactory::class)->createPasswordResetRepository());
|
||||
|
||||
105
lib/Repository/Tenant/TenantLdapAuthRepository.php
Normal file
105
lib/Repository/Tenant/TenantLdapAuthRepository.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Persists LDAP authentication settings per tenant, including encrypted bind credentials. */
|
||||
class TenantLdapAuthRepository implements TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
private const COLUMNS = 'id, tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created, modified';
|
||||
|
||||
private function unwrap($row): ?array
|
||||
{
|
||||
if (!$row || !is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
if (isset($row['tenant_auth_ldap']) && is_array($row['tenant_auth_ldap'])) {
|
||||
return $row['tenant_auth_ldap'];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function findByTenantId(int $tenantId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id = ? limit 1',
|
||||
(string) $tenantId
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id in (' . $placeholders . ')',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->unwrap($row);
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$tenantId = (int) ($item['tenant_id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$tenantId] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool
|
||||
{
|
||||
$autoRememberMode = $data['auto_remember_mode'] ?? null;
|
||||
$autoRememberModeParam = $autoRememberMode !== null ? (string) (int) $autoRememberMode : null;
|
||||
$attributeMap = $data['attribute_map'] ?? '{"first_name":"givenName","last_name":"sn","email":"mail","phone":"telephoneNumber","mobile":"mobile"}';
|
||||
if (is_array($attributeMap)) {
|
||||
$attributeMap = json_encode($attributeMap, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'insert into tenant_auth_ldap (tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW()) ' .
|
||||
'on duplicate key update enabled = values(enabled), enforce_ldap_login = values(enforce_ldap_login), host = values(host), port = values(port), encryption_mode = values(encryption_mode), verify_tls_certificate = values(verify_tls_certificate), base_dn = values(base_dn), bind_dn_enc = values(bind_dn_enc), bind_password_enc = values(bind_password_enc), ' .
|
||||
'user_search_filter = values(user_search_filter), user_search_scope = values(user_search_scope), bind_method = values(bind_method), bind_username_format = values(bind_username_format), unique_id_attribute = values(unique_id_attribute), attribute_map = values(attribute_map), ' .
|
||||
'sync_profile_on_login = values(sync_profile_on_login), sync_profile_fields = values(sync_profile_fields), allowed_domains = values(allowed_domains), auto_remember_mode = values(auto_remember_mode), network_timeout = values(network_timeout), modified = NOW()',
|
||||
(string) $tenantId,
|
||||
!empty($data['enabled']) ? '1' : '0',
|
||||
!empty($data['enforce_ldap_login']) ? '1' : '0',
|
||||
$data['host'] ?? '',
|
||||
(string) (int) ($data['port'] ?? 389),
|
||||
$data['encryption_mode'] ?? 'starttls',
|
||||
!empty($data['verify_tls_certificate']) ? '1' : '0',
|
||||
$data['base_dn'] ?? '',
|
||||
$data['bind_dn_enc'] ?? null,
|
||||
$data['bind_password_enc'] ?? null,
|
||||
$data['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
$data['user_search_scope'] ?? 'sub',
|
||||
$data['bind_method'] ?? 'search_then_bind',
|
||||
$data['bind_username_format'] ?? null,
|
||||
$data['unique_id_attribute'] ?? 'objectGUID',
|
||||
$attributeMap,
|
||||
!empty($data['sync_profile_on_login']) ? '1' : '0',
|
||||
$data['sync_profile_fields'] ?? null,
|
||||
$data['allowed_domains'] ?? null,
|
||||
$autoRememberModeParam,
|
||||
(string) (int) ($data['network_timeout'] ?? 5)
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
13
lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php
Normal file
13
lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
/** Contract for storing and retrieving LDAP authentication configuration per tenant. */
|
||||
interface TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
public function findByTenantId(int $tenantId): ?array;
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use MintyPHP\Repository\Auth\PasswordResetRepository;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
@@ -25,6 +27,7 @@ class AuthRepositoryFactory
|
||||
private ?EmailVerificationRepository $emailVerificationRepository = null;
|
||||
private ?TenantRepository $tenantRepository = null;
|
||||
private ?TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository = null;
|
||||
private ?TenantLdapAuthRepository $tenantLdapAuthRepository = null;
|
||||
private ?UserExternalIdentityRepository $userExternalIdentityRepository = null;
|
||||
|
||||
public function createApiTokenRepository(): ApiTokenRepositoryInterface
|
||||
@@ -57,6 +60,11 @@ class AuthRepositoryFactory
|
||||
return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository();
|
||||
}
|
||||
|
||||
public function createTenantLdapAuthRepository(): TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
return $this->tenantLdapAuthRepository ??= new TenantLdapAuthRepository();
|
||||
}
|
||||
|
||||
public function createUserExternalIdentityRepository(): UserExternalIdentityRepositoryInterface
|
||||
{
|
||||
return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository();
|
||||
|
||||
@@ -15,6 +15,7 @@ use MintyPHP\Service\User\UserServicesFactory;
|
||||
class AuthServicesFactory
|
||||
{
|
||||
private ?TenantSsoService $tenantSsoService = null;
|
||||
private ?LdapAuthService $ldapAuthService = null;
|
||||
private ?MicrosoftOidcStateStoreService $microsoftOidcStateStoreService = null;
|
||||
private ?MicrosoftOidcService $microsoftOidcService = null;
|
||||
private ?ApiTokenService $apiTokenService = null;
|
||||
@@ -125,11 +126,19 @@ class AuthServicesFactory
|
||||
return $this->tenantSsoService ??= new TenantSsoService(
|
||||
$this->authGatewayFactory->createAuthTenantGateway(),
|
||||
$this->authRepositoryFactory->createTenantMicrosoftAuthRepository(),
|
||||
$this->authRepositoryFactory->createTenantLdapAuthRepository(),
|
||||
$this->authGatewayFactory->createAuthSettingsGateway(),
|
||||
$this->authGatewayFactory->createAuthCryptoGateway()
|
||||
);
|
||||
}
|
||||
|
||||
public function createLdapAuthService(): LdapAuthService
|
||||
{
|
||||
return $this->ldapAuthService ??= new LdapAuthService(
|
||||
new LdapConnectionGateway()
|
||||
);
|
||||
}
|
||||
|
||||
public function createMicrosoftOidcStateStore(): MicrosoftOidcStateStoreService
|
||||
{
|
||||
return $this->microsoftOidcStateStoreService ??= new MicrosoftOidcStateStoreService($this->sessionStore);
|
||||
|
||||
294
lib/Service/Auth/LdapAuthService.php
Normal file
294
lib/Service/Auth/LdapAuthService.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
67
lib/Service/Auth/LdapConnectionGateway.php
Normal file
67
lib/Service/Auth/LdapConnectionGateway.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
/**
|
||||
* Thin wrapper around PHP ldap_* functions for testability.
|
||||
*
|
||||
* Every public method maps 1:1 to a native ldap_* call.
|
||||
* In tests, this gateway is replaced with a mock.
|
||||
*/
|
||||
class LdapConnectionGateway
|
||||
{
|
||||
/** @return \LDAP\Connection|false */
|
||||
public function connect(string $uri): \LDAP\Connection|false
|
||||
{
|
||||
return ldap_connect($uri);
|
||||
}
|
||||
|
||||
public function setOption(\LDAP\Connection $conn, int $option, mixed $value): bool
|
||||
{
|
||||
return ldap_set_option($conn, $option, $value);
|
||||
}
|
||||
|
||||
public function startTls(\LDAP\Connection $conn): bool
|
||||
{
|
||||
return ldap_start_tls($conn);
|
||||
}
|
||||
|
||||
public function bind(\LDAP\Connection $conn, ?string $dn = null, ?string $password = null): bool
|
||||
{
|
||||
return @ldap_bind($conn, $dn, $password);
|
||||
}
|
||||
|
||||
/** @return \LDAP\Result|false */
|
||||
public function search(\LDAP\Connection $conn, string $baseDn, string $filter, array $attributes = [], int $scope = 0): \LDAP\Result|false
|
||||
{
|
||||
if ($scope === 1) {
|
||||
return @ldap_list($conn, $baseDn, $filter, $attributes);
|
||||
}
|
||||
return @ldap_search($conn, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
public function getEntries(\LDAP\Connection $conn, \LDAP\Result $result): array|false
|
||||
{
|
||||
return ldap_get_entries($conn, $result);
|
||||
}
|
||||
|
||||
public function escape(string $value, string $ignore = '', int $flags = 0): string
|
||||
{
|
||||
return ldap_escape($value, $ignore, $flags);
|
||||
}
|
||||
|
||||
public function errno(\LDAP\Connection $conn): int
|
||||
{
|
||||
return ldap_errno($conn);
|
||||
}
|
||||
|
||||
public function error(\LDAP\Connection $conn): string
|
||||
{
|
||||
return ldap_error($conn);
|
||||
}
|
||||
|
||||
public function unbind(\LDAP\Connection $conn): bool
|
||||
{
|
||||
return @ldap_unbind($conn);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,168 @@ class SsoUserLinkService
|
||||
$this->avatarService->saveBinary($userUuid, $binary, $avatarMime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link or provision a user from LDAP authentication result.
|
||||
*
|
||||
* @param array $tenant Tenant record
|
||||
* @param array $ldapResult Result from LdapAuthService::authenticate()
|
||||
* @param array $ldapConfig Effective LDAP provider config
|
||||
* @return array{ok: bool, user_id?: int, created?: bool, error?: string}
|
||||
*/
|
||||
public function linkOrProvisionLdapUser(array $tenant, array $ldapResult, array $ldapConfig): array
|
||||
{
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
$uniqueId = trim((string) ($ldapResult['unique_id'] ?? ''));
|
||||
$dn = trim((string) ($ldapResult['dn'] ?? ''));
|
||||
$attributes = $ldapResult['attributes'] ?? [];
|
||||
$host = trim((string) ($ldapConfig['host'] ?? ''));
|
||||
|
||||
$email = strtolower(trim((string) ($attributes['email'] ?? '')));
|
||||
|
||||
if ($tenantId <= 0 || $host === '') {
|
||||
return ['ok' => false, 'error' => 'identity_invalid'];
|
||||
}
|
||||
|
||||
$providerKey = $this->tenantSsoService->ldapProviderKey();
|
||||
$tid = $host;
|
||||
$oid = $uniqueId !== '' ? $uniqueId : $dn;
|
||||
$issuer = $host;
|
||||
$subject = $dn;
|
||||
|
||||
if ($oid === '') {
|
||||
return ['ok' => false, 'error' => 'identity_invalid'];
|
||||
}
|
||||
|
||||
$identity = $this->externalIdentityGateway->findByProviderTidOid($providerKey, $tid, $oid);
|
||||
|
||||
if ($identity) {
|
||||
$userId = (int) ($identity['user_id'] ?? 0);
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || empty($user['active'])) {
|
||||
return ['ok' => false, 'error' => 'user_inactive'];
|
||||
}
|
||||
$tenantIds = array_values(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId)));
|
||||
if (!in_array($tenantId, $tenantIds, true)) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_assigned'];
|
||||
}
|
||||
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
|
||||
return ['ok' => true, 'user_id' => $userId, 'created' => false];
|
||||
}
|
||||
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if ($user) {
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
if ($userId <= 0 || empty($user['active'])) {
|
||||
return ['ok' => false, 'error' => 'user_inactive'];
|
||||
}
|
||||
|
||||
$this->ensureTenantAssignment($userId, $tenantId);
|
||||
$this->createIdentityLinkForProvider($providerKey, $userId, $tid, $oid, $issuer, $subject, $email);
|
||||
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
|
||||
return ['ok' => true, 'user_id' => $userId, 'created' => false];
|
||||
}
|
||||
}
|
||||
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'error' => 'email_missing'];
|
||||
}
|
||||
|
||||
$firstName = trim((string) ($attributes['first_name'] ?? ''));
|
||||
$lastName = trim((string) ($attributes['last_name'] ?? ''));
|
||||
if ($firstName === '') {
|
||||
$firstName = ucfirst(explode('@', $email, 2)[0]);
|
||||
}
|
||||
|
||||
$createdId = $this->userWriteRepository->create([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $email,
|
||||
'password' => $this->randomPassword(),
|
||||
'locale' => null,
|
||||
'totp_secret' => '',
|
||||
'theme' => $this->resolveInitialTheme($this->settingsGateway->getAppTheme()),
|
||||
'primary_tenant_id' => $tenantId,
|
||||
'current_tenant_id' => $tenantId,
|
||||
'active' => 1,
|
||||
'created_by' => null,
|
||||
'active_changed_at' => gmdate('Y-m-d H:i:s'),
|
||||
'active_changed_by' => null,
|
||||
]);
|
||||
|
||||
if (!$createdId) {
|
||||
return ['ok' => false, 'error' => 'user_create_failed'];
|
||||
}
|
||||
|
||||
$userId = (int) $createdId;
|
||||
$this->userWriteRepository->setEmailVerified($userId);
|
||||
$this->userAssignmentService->syncTenants($userId, [$tenantId]);
|
||||
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
|
||||
if ($defaultRoleId) {
|
||||
$this->userAssignmentService->syncRoles($userId, [$defaultRoleId]);
|
||||
}
|
||||
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
|
||||
if ($defaultDepartmentId) {
|
||||
$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]);
|
||||
}
|
||||
|
||||
$this->createIdentityLinkForProvider($providerKey, $userId, $tid, $oid, $issuer, $subject, $email);
|
||||
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
|
||||
return ['ok' => true, 'user_id' => $userId, 'created' => true];
|
||||
}
|
||||
|
||||
private function syncProfileFromLdap(int $userId, array $attributes, array $ldapConfig): void
|
||||
{
|
||||
if ($userId <= 0 || empty($ldapConfig['sync_profile_on_login'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$syncFields = $this->tenantSsoService->normalizeLdapProfileSyncFields($ldapConfig['sync_profile_fields'] ?? []);
|
||||
if (!$syncFields) {
|
||||
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
|
||||
}
|
||||
if (!$syncFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
foreach ($syncFields as $field) {
|
||||
$value = trim((string) ($attributes[$field] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$updates[$field] = $value;
|
||||
}
|
||||
|
||||
if ($updates) {
|
||||
$this->userWriteRepository->updateProfileFieldsFromSso($userId, $updates);
|
||||
}
|
||||
}
|
||||
|
||||
private function createIdentityLinkForProvider(
|
||||
string $providerKey,
|
||||
int $userId,
|
||||
string $tid,
|
||||
string $oid,
|
||||
string $issuer,
|
||||
string $subject,
|
||||
string $email
|
||||
): void {
|
||||
try {
|
||||
$this->externalIdentityGateway->createLink([
|
||||
'user_id' => $userId,
|
||||
'provider' => $providerKey,
|
||||
'oid' => $oid,
|
||||
'tid' => $tid,
|
||||
'issuer' => $issuer,
|
||||
'subject' => $subject,
|
||||
'email_at_link_time' => $email,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Ignore duplicate-link race conditions.
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureTenantAssignment(int $userId, int $tenantId): void
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId))));
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
|
||||
|
||||
class TenantSsoService
|
||||
{
|
||||
public const PROVIDER_MICROSOFT = 'microsoft';
|
||||
public const PROVIDER_LDAP = 'ldap';
|
||||
|
||||
private const PROFILE_SYNC_FIELD_OPTIONS = [
|
||||
'first_name' => 'Sync first name',
|
||||
@@ -16,11 +18,24 @@ class TenantSsoService
|
||||
'avatar' => 'Sync avatar image',
|
||||
];
|
||||
|
||||
private const LDAP_PROFILE_SYNC_FIELD_OPTIONS = [
|
||||
'first_name' => 'Sync first name',
|
||||
'last_name' => 'Sync last name',
|
||||
'phone' => 'Sync phone',
|
||||
'mobile' => 'Sync mobile',
|
||||
'email' => 'Sync email',
|
||||
];
|
||||
|
||||
private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name'];
|
||||
|
||||
private const VALID_LDAP_ENCRYPTION_MODES = ['none', 'starttls', 'ldaps'];
|
||||
private const VALID_LDAP_BIND_METHODS = ['search_then_bind', 'direct_bind'];
|
||||
private const VALID_LDAP_SEARCH_SCOPES = ['sub', 'one'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AuthTenantGateway $tenantGateway,
|
||||
private readonly TenantMicrosoftAuthRepositoryInterface $tenantMicrosoftAuthRepository,
|
||||
private readonly TenantLdapAuthRepositoryInterface $tenantLdapAuthRepository,
|
||||
private readonly AuthSettingsGateway $settingsGateway,
|
||||
private readonly AuthCryptoGateway $cryptoGateway
|
||||
) {
|
||||
@@ -31,6 +46,11 @@ class TenantSsoService
|
||||
return self::PROVIDER_MICROSOFT;
|
||||
}
|
||||
|
||||
public function ldapProviderKey(): string
|
||||
{
|
||||
return self::PROVIDER_LDAP;
|
||||
}
|
||||
|
||||
public function findTenantByLoginSlug(string $slug): ?array
|
||||
{
|
||||
$slug = trim(strtolower($slug));
|
||||
@@ -121,12 +141,17 @@ class TenantSsoService
|
||||
|
||||
public function isLocalPasswordLoginAllowed(int $tenantId): bool
|
||||
{
|
||||
$auth = $this->getTenantMicrosoftAuth($tenantId);
|
||||
if (!$auth['enabled']) {
|
||||
return true;
|
||||
$microsoftAuth = $this->getTenantMicrosoftAuth($tenantId);
|
||||
if ($microsoftAuth['enabled'] && $microsoftAuth['enforce_microsoft_login']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$auth['enforce_microsoft_login'];
|
||||
$ldapAuth = $this->getTenantLdapAuth($tenantId);
|
||||
if ($ldapAuth['enabled'] && $ldapAuth['enforce_ldap_login']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resolveTenantLoginMethods(int $tenantId): array
|
||||
@@ -137,17 +162,24 @@ class TenantSsoService
|
||||
'local' => false,
|
||||
'microsoft' => false,
|
||||
'microsoft_reason' => 'tenant_not_found',
|
||||
'ldap' => false,
|
||||
'ldap_reason' => 'tenant_not_found',
|
||||
];
|
||||
}
|
||||
|
||||
$configResult = $this->getEffectiveMicrosoftProviderConfig($tenant);
|
||||
$microsoftResult = $this->getEffectiveMicrosoftProviderConfig($tenant);
|
||||
$ldapResult = $this->getEffectiveLdapProviderConfig($tenant);
|
||||
|
||||
return [
|
||||
'local' => $this->isLocalPasswordLoginAllowed($tenantId),
|
||||
'microsoft' => !empty($configResult['ok']),
|
||||
'microsoft_reason' => $configResult['ok'] ?? false
|
||||
'microsoft' => !empty($microsoftResult['ok']),
|
||||
'microsoft_reason' => $microsoftResult['ok'] ?? false
|
||||
? ''
|
||||
: (string) ($configResult['error'] ?? 'sso_disabled'),
|
||||
: (string) ($microsoftResult['error'] ?? 'sso_disabled'),
|
||||
'ldap' => !empty($ldapResult['ok']),
|
||||
'ldap_reason' => $ldapResult['ok'] ?? false
|
||||
? ''
|
||||
: (string) ($ldapResult['error'] ?? 'ldap_disabled'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -518,6 +550,357 @@ class TenantSsoService
|
||||
};
|
||||
}
|
||||
|
||||
// ── LDAP provider methods ─────────────────────────────────────────
|
||||
|
||||
public function getTenantLdapAuth(int $tenantId): array
|
||||
{
|
||||
$row = $this->tenantLdapAuthRepository->findByTenantId($tenantId) ?? [];
|
||||
$syncProfileOnLogin = !empty($row['sync_profile_on_login']);
|
||||
$syncFields = $this->normalizeLdapProfileSyncFields($row['sync_profile_fields'] ?? []);
|
||||
if ($syncProfileOnLogin && !$syncFields) {
|
||||
$syncFields = self::DEFAULT_PROFILE_SYNC_FIELDS;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($row['enabled']),
|
||||
'enforce_ldap_login' => !empty($row['enforce_ldap_login']),
|
||||
'host' => trim((string) ($row['host'] ?? '')),
|
||||
'port' => (int) ($row['port'] ?? 389),
|
||||
'encryption_mode' => $this->normalizeLdapEnum($row['encryption_mode'] ?? 'starttls', self::VALID_LDAP_ENCRYPTION_MODES, 'starttls'),
|
||||
'verify_tls_certificate' => !array_key_exists('verify_tls_certificate', $row) || !empty($row['verify_tls_certificate']),
|
||||
'base_dn' => trim((string) ($row['base_dn'] ?? '')),
|
||||
'bind_dn_enc' => trim((string) ($row['bind_dn_enc'] ?? '')),
|
||||
'bind_password_enc' => trim((string) ($row['bind_password_enc'] ?? '')),
|
||||
'user_search_filter' => trim((string) ($row['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))')),
|
||||
'user_search_scope' => $this->normalizeLdapEnum($row['user_search_scope'] ?? 'sub', self::VALID_LDAP_SEARCH_SCOPES, 'sub'),
|
||||
'bind_method' => $this->normalizeLdapEnum($row['bind_method'] ?? 'search_then_bind', self::VALID_LDAP_BIND_METHODS, 'search_then_bind'),
|
||||
'bind_username_format' => trim((string) ($row['bind_username_format'] ?? '')),
|
||||
'unique_id_attribute' => trim((string) ($row['unique_id_attribute'] ?? 'objectGUID')),
|
||||
'attribute_map' => $this->normalizeLdapAttributeMap($row['attribute_map'] ?? null),
|
||||
'sync_profile_on_login' => $syncProfileOnLogin,
|
||||
'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields),
|
||||
'sync_profile_fields_list' => $syncFields,
|
||||
'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')),
|
||||
'auto_remember_mode' => array_key_exists('auto_remember_mode', $row) && $row['auto_remember_mode'] !== null
|
||||
? (int) $row['auto_remember_mode']
|
||||
: null,
|
||||
'network_timeout' => (int) ($row['network_timeout'] ?? 5),
|
||||
];
|
||||
}
|
||||
|
||||
public function getEffectiveLdapProviderConfig(array $tenant): array
|
||||
{
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
|
||||
$auth = $this->getTenantLdapAuth($tenantId);
|
||||
if (!$auth['enabled']) {
|
||||
return ['ok' => false, 'error' => 'ldap_disabled'];
|
||||
}
|
||||
|
||||
if ($auth['host'] === '') {
|
||||
return ['ok' => false, 'error' => 'ldap_host_missing'];
|
||||
}
|
||||
|
||||
if ($auth['base_dn'] === '') {
|
||||
return ['ok' => false, 'error' => 'ldap_base_dn_missing'];
|
||||
}
|
||||
|
||||
$bindDn = '';
|
||||
$bindPassword = '';
|
||||
if ($auth['bind_dn_enc'] !== '') {
|
||||
try {
|
||||
$bindDn = $this->cryptoGateway->decryptString($auth['bind_dn_enc']);
|
||||
} catch (\Throwable) {
|
||||
return ['ok' => false, 'error' => 'ldap_bind_dn_decrypt_failed'];
|
||||
}
|
||||
}
|
||||
if ($auth['bind_password_enc'] !== '') {
|
||||
try {
|
||||
$bindPassword = $this->cryptoGateway->decryptString($auth['bind_password_enc']);
|
||||
} catch (\Throwable) {
|
||||
return ['ok' => false, 'error' => 'ldap_bind_password_decrypt_failed'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'config' => [
|
||||
'provider' => self::PROVIDER_LDAP,
|
||||
'host' => $auth['host'],
|
||||
'port' => $auth['port'],
|
||||
'encryption_mode' => $auth['encryption_mode'],
|
||||
'verify_tls_certificate' => $auth['verify_tls_certificate'],
|
||||
'base_dn' => $auth['base_dn'],
|
||||
'bind_dn' => $bindDn,
|
||||
'bind_password' => $bindPassword,
|
||||
'user_search_filter' => $auth['user_search_filter'],
|
||||
'user_search_scope' => $auth['user_search_scope'],
|
||||
'bind_method' => $auth['bind_method'],
|
||||
'bind_username_format' => $auth['bind_username_format'],
|
||||
'unique_id_attribute' => $auth['unique_id_attribute'],
|
||||
'attribute_map' => $auth['attribute_map'],
|
||||
'sync_profile_on_login' => $auth['sync_profile_on_login'],
|
||||
'sync_profile_fields' => $auth['sync_profile_fields_list'],
|
||||
'allowed_domains' => $auth['allowed_domains'],
|
||||
'enforce_ldap_login' => $auth['enforce_ldap_login'],
|
||||
'network_timeout' => $auth['network_timeout'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function buildLdapUiState(int $tenantId, array $input = []): array
|
||||
{
|
||||
$existing = $this->getTenantLdapAuth($tenantId);
|
||||
$enabled = array_key_exists('ldap_enabled', $input)
|
||||
? !empty($input['ldap_enabled'])
|
||||
: $existing['enabled'];
|
||||
$enforce = array_key_exists('enforce_ldap_login', $input)
|
||||
? !empty($input['enforce_ldap_login'])
|
||||
: $existing['enforce_ldap_login'];
|
||||
$host = array_key_exists('ldap_host', $input)
|
||||
? trim((string) ($input['ldap_host'] ?? ''))
|
||||
: $existing['host'];
|
||||
$baseDn = array_key_exists('ldap_base_dn', $input)
|
||||
? trim((string) ($input['ldap_base_dn'] ?? ''))
|
||||
: $existing['base_dn'];
|
||||
|
||||
$configComplete = !$enabled || ($host !== '' && $baseDn !== '');
|
||||
$configErrorCode = '';
|
||||
if ($enabled && $host === '') {
|
||||
$configErrorCode = 'ldap_host_missing';
|
||||
} elseif ($enabled && $baseDn === '') {
|
||||
$configErrorCode = 'ldap_base_dn_missing';
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'config_complete' => $configComplete,
|
||||
'config_error_code' => $configErrorCode,
|
||||
'config_error_label' => $this->ldapConfigErrorLabel($configErrorCode),
|
||||
'password_mode' => !$enabled
|
||||
? 'local_only'
|
||||
: ($enforce ? 'ldap_only' : 'local_and_ldap'),
|
||||
];
|
||||
}
|
||||
|
||||
public function saveTenantLdapAuth(int $tenantId, array $input): array
|
||||
{
|
||||
$tenant = $this->tenantGateway->findById($tenantId);
|
||||
if (!$tenant) {
|
||||
return ['ok' => false, 'errors' => [t('Tenant not found')]];
|
||||
}
|
||||
|
||||
$enabled = !empty($input['ldap_enabled']);
|
||||
$enforce = !empty($input['enforce_ldap_login']);
|
||||
$host = trim((string) ($input['ldap_host'] ?? ''));
|
||||
$port = (int) ($input['ldap_port'] ?? 389);
|
||||
$encryptionMode = $this->normalizeLdapEnum($input['ldap_encryption_mode'] ?? 'starttls', self::VALID_LDAP_ENCRYPTION_MODES, 'starttls');
|
||||
$verifyTlsCertificate = !empty($input['ldap_verify_tls_certificate']);
|
||||
$baseDn = trim((string) ($input['ldap_base_dn'] ?? ''));
|
||||
$bindDn = trim((string) ($input['ldap_bind_dn'] ?? ''));
|
||||
$bindPassword = trim((string) ($input['ldap_bind_password'] ?? ''));
|
||||
$clearBindPassword = !empty($input['clear_ldap_bind_password']);
|
||||
$userSearchFilter = trim((string) ($input['ldap_user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))'));
|
||||
$userSearchScope = $this->normalizeLdapEnum($input['ldap_user_search_scope'] ?? 'sub', self::VALID_LDAP_SEARCH_SCOPES, 'sub');
|
||||
$bindMethod = $this->normalizeLdapEnum($input['ldap_bind_method'] ?? 'search_then_bind', self::VALID_LDAP_BIND_METHODS, 'search_then_bind');
|
||||
$bindUsernameFormat = trim((string) ($input['ldap_bind_username_format'] ?? ''));
|
||||
$uniqueIdAttribute = trim((string) ($input['ldap_unique_id_attribute'] ?? 'objectGUID'));
|
||||
$attributeMap = $this->normalizeLdapAttributeMap($input['ldap_attribute_map'] ?? null);
|
||||
$syncProfileOnLogin = !empty($input['ldap_sync_profile_on_login']);
|
||||
$syncProfileFields = $this->normalizeLdapProfileSyncFields($input['ldap_sync_profile_fields'] ?? []);
|
||||
if ($syncProfileOnLogin && !$syncProfileFields) {
|
||||
$syncProfileFields = self::DEFAULT_PROFILE_SYNC_FIELDS;
|
||||
}
|
||||
$syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields);
|
||||
$allowedDomains = $this->normalizeDomains((string) ($input['ldap_allowed_domains'] ?? ''));
|
||||
$networkTimeout = max(1, min(30, (int) ($input['ldap_network_timeout'] ?? 5)));
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($enabled && $host === '') {
|
||||
$errors[] = t('LDAP host is required');
|
||||
}
|
||||
if ($enabled && $baseDn === '') {
|
||||
$errors[] = t('LDAP base DN is required');
|
||||
}
|
||||
if ($enabled && $port < 1 || $port > 65535) {
|
||||
$errors[] = t('LDAP port must be between 1 and 65535');
|
||||
}
|
||||
if ($enabled && !str_contains($userSearchFilter, '%s')) {
|
||||
$errors[] = t('Search filter must contain %s placeholder');
|
||||
}
|
||||
if ($enabled && $enforce && ($host === '' || $baseDn === '')) {
|
||||
$errors[] = t('LDAP-only login requires a complete configuration');
|
||||
}
|
||||
if ($bindMethod === 'direct_bind' && $bindUsernameFormat === '') {
|
||||
$errors[] = t('Direct bind method requires a username format');
|
||||
}
|
||||
|
||||
$existing = $this->getTenantLdapAuth($tenantId);
|
||||
|
||||
$bindDnEnc = (string) ($existing['bind_dn_enc'] ?? '');
|
||||
if ($bindDn !== '') {
|
||||
if (!$this->cryptoGateway->isConfigured()) {
|
||||
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
|
||||
} else {
|
||||
try {
|
||||
$bindDnEnc = $this->cryptoGateway->encryptString($bindDn);
|
||||
} catch (\Throwable) {
|
||||
$errors[] = t('Bind DN could not be encrypted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bindPasswordEnc = (string) ($existing['bind_password_enc'] ?? '');
|
||||
if ($clearBindPassword) {
|
||||
$bindPasswordEnc = '';
|
||||
} elseif ($bindPassword !== '') {
|
||||
if (!$this->cryptoGateway->isConfigured()) {
|
||||
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
|
||||
} else {
|
||||
try {
|
||||
$bindPasswordEnc = $this->cryptoGateway->encryptString($bindPassword);
|
||||
} catch (\Throwable) {
|
||||
$errors[] = t('Bind password could not be encrypted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$autoRememberMode = $this->normalizeAutoRememberMode($input['ldap_auto_remember_mode'] ?? null);
|
||||
$attributeMapJson = json_encode($attributeMap, JSON_THROW_ON_ERROR);
|
||||
|
||||
$saved = $this->tenantLdapAuthRepository->upsertByTenantId($tenantId, [
|
||||
'enabled' => $enabled,
|
||||
'enforce_ldap_login' => $enabled ? $enforce : false,
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'encryption_mode' => $encryptionMode,
|
||||
'verify_tls_certificate' => $verifyTlsCertificate,
|
||||
'base_dn' => $baseDn,
|
||||
'bind_dn_enc' => $bindDnEnc !== '' ? $bindDnEnc : null,
|
||||
'bind_password_enc' => $bindPasswordEnc !== '' ? $bindPasswordEnc : null,
|
||||
'user_search_filter' => $userSearchFilter,
|
||||
'user_search_scope' => $userSearchScope,
|
||||
'bind_method' => $bindMethod,
|
||||
'bind_username_format' => $bindUsernameFormat !== '' ? $bindUsernameFormat : null,
|
||||
'unique_id_attribute' => $uniqueIdAttribute,
|
||||
'attribute_map' => $attributeMapJson,
|
||||
'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false,
|
||||
'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null,
|
||||
'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains,
|
||||
'auto_remember_mode' => $autoRememberMode,
|
||||
'network_timeout' => $networkTimeout,
|
||||
]);
|
||||
|
||||
if (!$saved) {
|
||||
return ['ok' => false, 'errors' => [t('Tenant LDAP settings could not be saved')]];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function shouldAutoRememberOnLdapLogin(int $tenantId): bool
|
||||
{
|
||||
$auth = $this->getTenantLdapAuth($tenantId);
|
||||
$mode = $auth['auto_remember_mode'] ?? null;
|
||||
if ($mode === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($mode === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function ldapProfileSyncFieldOptions(): array
|
||||
{
|
||||
return self::LDAP_PROFILE_SYNC_FIELD_OPTIONS;
|
||||
}
|
||||
|
||||
public function normalizeLdapProfileSyncFields($raw): array
|
||||
{
|
||||
if (is_string($raw)) {
|
||||
$raw = preg_split('/[\s,;]+/', $raw) ?: [];
|
||||
} elseif (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowed = array_fill_keys(array_keys(self::LDAP_PROFILE_SYNC_FIELD_OPTIONS), true);
|
||||
$normalized = [];
|
||||
foreach ($raw as $item) {
|
||||
$key = strtolower(trim((string) $item));
|
||||
if ($key === '' || !isset($allowed[$key])) {
|
||||
continue;
|
||||
}
|
||||
$normalized[$key] = true;
|
||||
}
|
||||
|
||||
return array_keys($normalized);
|
||||
}
|
||||
|
||||
public function normalizeLdapAttributeMap(mixed $raw): array
|
||||
{
|
||||
$default = [
|
||||
'first_name' => 'givenName',
|
||||
'last_name' => 'sn',
|
||||
'email' => 'mail',
|
||||
'phone' => 'telephoneNumber',
|
||||
'mobile' => 'mobile',
|
||||
];
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return $default;
|
||||
}
|
||||
$raw = $decoded;
|
||||
}
|
||||
|
||||
if (!is_array($raw)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($raw as $key => $value) {
|
||||
$key = trim((string) $key);
|
||||
$value = trim((string) $value);
|
||||
if ($key !== '' && $value !== '') {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result ?: $default;
|
||||
}
|
||||
|
||||
private function ldapConfigErrorLabel(string $errorCode): string
|
||||
{
|
||||
return match ($errorCode) {
|
||||
'ldap_host_missing' => t('LDAP host is required'),
|
||||
'ldap_base_dn_missing' => t('LDAP base DN is required'),
|
||||
'ldap_bind_dn_decrypt_failed' => t('Bind DN could not be decrypted'),
|
||||
'ldap_bind_password_decrypt_failed' => t('Bind password could not be decrypted'),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeLdapEnum(string $value, array $valid, string $default): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
return in_array($value, $valid, true) ? $value : $default;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
|
||||
@@ -678,6 +678,245 @@ $openOverrideCard = $detailsOpenAll;
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<?php
|
||||
// ── LDAP configuration section ────────────────────────────────
|
||||
$ldapUiState = is_array($ldapUiState ?? null) ? $ldapUiState : [];
|
||||
$ldapEnabled = !empty($values['enabled'] ?? false) ? false : (!empty($values['ldap_enabled'] ?? $ldapConfig['enabled'] ?? false));
|
||||
if (isset($values['ldap_enabled'])) {
|
||||
$ldapEnabled = in_array(strtolower(trim((string) $values['ldap_enabled'])), ['1', 'true', 'yes', 'on'], true);
|
||||
} elseif (isset($ldapConfig['enabled'])) {
|
||||
$ldapEnabled = !empty($ldapConfig['enabled']);
|
||||
}
|
||||
$ldapEnforce = !empty($values['enforce_ldap_login'] ?? $ldapConfig['enforce_ldap_login'] ?? false);
|
||||
$ldapHost = trim((string) ($values['ldap_host'] ?? $values['host'] ?? $ldapConfig['host'] ?? ''));
|
||||
$ldapPort = (int) ($values['ldap_port'] ?? $values['port'] ?? $ldapConfig['port'] ?? 389);
|
||||
$ldapEncryptionMode = (string) ($values['ldap_encryption_mode'] ?? $values['encryption_mode'] ?? $ldapConfig['encryption_mode'] ?? 'starttls');
|
||||
$ldapVerifyTls = !array_key_exists('verify_tls_certificate', ($ldapConfig ?? [])) || !empty($values['ldap_verify_tls_certificate'] ?? $ldapConfig['verify_tls_certificate'] ?? true);
|
||||
$ldapBaseDn = trim((string) ($values['ldap_base_dn'] ?? $values['base_dn'] ?? $ldapConfig['base_dn'] ?? ''));
|
||||
$ldapUserSearchFilter = trim((string) ($values['ldap_user_search_filter'] ?? $values['user_search_filter'] ?? $ldapConfig['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))'));
|
||||
$ldapUserSearchScope = (string) ($values['ldap_user_search_scope'] ?? $values['user_search_scope'] ?? $ldapConfig['user_search_scope'] ?? 'sub');
|
||||
$ldapBindMethod = (string) ($values['ldap_bind_method'] ?? $values['bind_method'] ?? $ldapConfig['bind_method'] ?? 'search_then_bind');
|
||||
$ldapBindUsernameFormat = trim((string) ($values['ldap_bind_username_format'] ?? $values['bind_username_format'] ?? $ldapConfig['bind_username_format'] ?? ''));
|
||||
$ldapUniqueIdAttribute = trim((string) ($values['ldap_unique_id_attribute'] ?? $values['unique_id_attribute'] ?? $ldapConfig['unique_id_attribute'] ?? 'objectGUID'));
|
||||
$ldapAttributeMap = $ldapConfig['attribute_map'] ?? [];
|
||||
if (is_string($ldapAttributeMap)) {
|
||||
$ldapAttributeMap = json_decode($ldapAttributeMap, true) ?: [];
|
||||
}
|
||||
$ldapSyncProfileOnLogin = !empty($values['ldap_sync_profile_on_login'] ?? $ldapConfig['sync_profile_on_login'] ?? false);
|
||||
$ldapSyncFieldOptions = $tenantSsoService->ldapProfileSyncFieldOptions();
|
||||
$ldapSyncProfileFields = $tenantSsoService->normalizeLdapProfileSyncFields($values['ldap_sync_profile_fields'] ?? $ldapConfig['sync_profile_fields_list'] ?? []);
|
||||
$ldapAllowedDomains = trim((string) ($values['ldap_allowed_domains'] ?? $ldapConfig['allowed_domains'] ?? ''));
|
||||
$ldapNetworkTimeout = (int) ($values['ldap_network_timeout'] ?? $ldapConfig['network_timeout'] ?? 5);
|
||||
$ldapConfigComplete = !empty($ldapUiState['config_complete']);
|
||||
$ldapConfigErrorLabel = trim((string) ($ldapUiState['config_error_label'] ?? ''));
|
||||
$openLdapSetupCard = $detailsOpenAll || !$ldapEnabled || ($ldapEnabled && !$ldapConfigComplete);
|
||||
?>
|
||||
<hr>
|
||||
<h4><?php e(t('LDAP Authentication')); ?></h4>
|
||||
|
||||
<div class="app-form-info-tiles">
|
||||
<?php
|
||||
$infoTileLabel = t('LDAP login');
|
||||
$infoTileValue = $ldapEnabled ? t('Active') : t('Inactive');
|
||||
$infoTileTone = $ldapEnabled ? 'success' : 'neutral';
|
||||
require templatePath('partials/app-form-info-tile.phtml');
|
||||
$infoTileLabel = t('Configuration complete');
|
||||
$infoTileValue = $ldapConfigComplete ? t('Yes') : t('No');
|
||||
$infoTileTone = $ldapConfigComplete ? 'success' : 'warning';
|
||||
require templatePath('partials/app-form-info-tile.phtml');
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php if ($ldapEnabled && !$ldapConfigComplete && $ldapConfigErrorLabel !== ''): ?>
|
||||
<blockquote data-variant="warning">
|
||||
<strong><?php e(t('LDAP setup is incomplete')); ?></strong><br>
|
||||
<?php e($ldapConfigErrorLabel); ?>
|
||||
</blockquote>
|
||||
<?php endif; ?>
|
||||
|
||||
<details class="app-details-card" name="tenant-ldap-setup" <?php e($openLdapSetupCard ? 'open' : ''); ?>>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('LDAP connection settings')); ?></span>
|
||||
<span class="app-details-card-summary-meta">
|
||||
<span class="badge" data-variant="<?php e($ldapEnabled ? 'success' : 'neutral'); ?>">
|
||||
<?php e(t('LDAP login')); ?>: <?php e($ldapEnabled ? t('Active') : t('Inactive')); ?>
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="ldap_enabled" value="1"
|
||||
<?php e($ldapEnabled ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
|
||||
<span><?php e(t('Enable LDAP login for this tenant')); ?></span>
|
||||
</label>
|
||||
<hr>
|
||||
<div class="grid grid-1-1">
|
||||
<label>
|
||||
<span><?php e(t('LDAP Host')); ?></span>
|
||||
<input type="text" name="ldap_host" value="<?php e($ldapHost); ?>"
|
||||
placeholder="ldap.example.com" <?php e($readonlyAttr); ?>>
|
||||
</label>
|
||||
<label>
|
||||
<span><?php e(t('Port')); ?></span>
|
||||
<input type="number" name="ldap_port" value="<?php e((string) $ldapPort); ?>"
|
||||
min="1" max="65535" <?php e($readonlyAttr); ?>>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-1-1">
|
||||
<label>
|
||||
<span><?php e(t('Encryption')); ?></span>
|
||||
<select name="ldap_encryption_mode" <?php e($disabledAttr); ?>>
|
||||
<option value="starttls" <?php e($ldapEncryptionMode === 'starttls' ? 'selected' : ''); ?>>STARTTLS</option>
|
||||
<option value="ldaps" <?php e($ldapEncryptionMode === 'ldaps' ? 'selected' : ''); ?>>LDAPS (SSL)</option>
|
||||
<option value="none" <?php e($ldapEncryptionMode === 'none' ? 'selected' : ''); ?>><?php e(t('None (not recommended)')); ?></option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<input type="checkbox" name="ldap_verify_tls_certificate" value="1"
|
||||
<?php e($ldapVerifyTls ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
|
||||
<span><?php e(t('Verify TLS certificate')); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span><?php e(t('Base DN')); ?></span>
|
||||
<input type="text" name="ldap_base_dn" value="<?php e($ldapBaseDn); ?>"
|
||||
placeholder="DC=corp,DC=example,DC=com" <?php e($readonlyAttr); ?>>
|
||||
</label>
|
||||
<hr>
|
||||
<label>
|
||||
<span><?php e(t('Bind DN (service account)')); ?></span>
|
||||
<input type="text" name="ldap_bind_dn" value="" autocomplete="off"
|
||||
placeholder="CN=svc-ldap,OU=Service Accounts,DC=corp,DC=example,DC=com" <?php e($readonlyAttr); ?>>
|
||||
<small class="muted"><?php e(t('Write-only. Leave empty to keep current value.')); ?></small>
|
||||
</label>
|
||||
<label>
|
||||
<span><?php e(t('Bind password')); ?></span>
|
||||
<input type="password" name="ldap_bind_password" value="" autocomplete="new-password" <?php e($readonlyAttr); ?>>
|
||||
<small class="muted"><?php e(t('Write-only. Leave empty to keep current value.')); ?></small>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<input type="checkbox" name="clear_ldap_bind_password" value="1" <?php e($disabledAttr); ?>>
|
||||
<span><?php e(t('Clear bind password')); ?></span>
|
||||
</label>
|
||||
<hr>
|
||||
<p>
|
||||
<button type="button" class="secondary outline small" id="ldap-test-connection-button"
|
||||
data-ldap-test-url="<?php e(lurl('admin/tenants/ldap-test-connection')); ?>"
|
||||
data-tenant-id="<?php e((string) $tenantId); ?>">
|
||||
<i class="bi bi-plug" aria-hidden="true"></i> <?php e(t('Test connection')); ?>
|
||||
</button>
|
||||
<span id="ldap-test-connection-result" aria-live="polite"></span>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="tenant-ldap-search" <?php e($detailsOpenAll ? 'open' : ''); ?>>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Search and bind settings')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label>
|
||||
<span><?php e(t('Bind method')); ?></span>
|
||||
<select name="ldap_bind_method" <?php e($disabledAttr); ?>>
|
||||
<option value="search_then_bind" <?php e($ldapBindMethod === 'search_then_bind' ? 'selected' : ''); ?>><?php e(t('Search then bind (recommended)')); ?></option>
|
||||
<option value="direct_bind" <?php e($ldapBindMethod === 'direct_bind' ? 'selected' : ''); ?>><?php e(t('Direct bind')); ?></option>
|
||||
</select>
|
||||
<small class="muted"><?php e(t('Search-then-bind uses a service account to find the user DN first, then binds with the user credentials.')); ?></small>
|
||||
</label>
|
||||
<label>
|
||||
<span><?php e(t('User search filter')); ?></span>
|
||||
<input type="text" name="ldap_user_search_filter" value="<?php e($ldapUserSearchFilter); ?>"
|
||||
placeholder="(&(objectClass=user)(sAMAccountName=%s))" <?php e($readonlyAttr); ?>>
|
||||
<small class="muted"><?php e(t('Use %s as placeholder for the username. Examples: (&(objectClass=user)(sAMAccountName=%s)) for AD, (&(objectClass=inetOrgPerson)(uid=%s)) for OpenLDAP')); ?></small>
|
||||
</label>
|
||||
<div class="grid grid-1-1">
|
||||
<label>
|
||||
<span><?php e(t('Search scope')); ?></span>
|
||||
<select name="ldap_user_search_scope" <?php e($disabledAttr); ?>>
|
||||
<option value="sub" <?php e($ldapUserSearchScope === 'sub' ? 'selected' : ''); ?>><?php e(t('Subtree (recursive)')); ?></option>
|
||||
<option value="one" <?php e($ldapUserSearchScope === 'one' ? 'selected' : ''); ?>><?php e(t('One level')); ?></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span><?php e(t('Network timeout (seconds)')); ?></span>
|
||||
<input type="number" name="ldap_network_timeout" value="<?php e((string) $ldapNetworkTimeout); ?>"
|
||||
min="1" max="30" <?php e($readonlyAttr); ?>>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span><?php e(t('Direct bind username format')); ?></span>
|
||||
<input type="text" name="ldap_bind_username_format" value="<?php e($ldapBindUsernameFormat); ?>"
|
||||
placeholder="%s@corp.example.com" <?php e($readonlyAttr); ?>>
|
||||
<small class="muted"><?php e(t('Only for direct bind. Use %s as placeholder. Example: %s@corp.example.com or uid=%s,ou=People,dc=example,dc=com')); ?></small>
|
||||
</label>
|
||||
<label>
|
||||
<span><?php e(t('Unique ID attribute')); ?></span>
|
||||
<input type="text" name="ldap_unique_id_attribute" value="<?php e($ldapUniqueIdAttribute); ?>"
|
||||
placeholder="objectGUID" <?php e($readonlyAttr); ?>>
|
||||
<small class="muted"><?php e(t('AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID')); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="tenant-ldap-mapping" <?php e($detailsOpenAll ? 'open' : ''); ?>>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Attribute mapping and sync')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<small class="muted"><?php e(t('Map application fields to LDAP directory attributes.')); ?></small>
|
||||
<div class="grid grid-1-1">
|
||||
<?php
|
||||
$defaultMap = [
|
||||
'first_name' => 'givenName',
|
||||
'last_name' => 'sn',
|
||||
'email' => 'mail',
|
||||
'phone' => 'telephoneNumber',
|
||||
'mobile' => 'mobile',
|
||||
];
|
||||
foreach ($defaultMap as $appField => $defaultLdapAttr):
|
||||
$currentLdapAttr = $ldapAttributeMap[$appField] ?? $defaultLdapAttr;
|
||||
?>
|
||||
<label>
|
||||
<span><?php e(t(ucfirst(str_replace('_', ' ', $appField)))); ?> →</span>
|
||||
<input type="text" name="ldap_attribute_map[<?php e($appField); ?>]"
|
||||
value="<?php e($currentLdapAttr); ?>" <?php e($readonlyAttr); ?>>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<hr>
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="ldap_sync_profile_on_login" value="1"
|
||||
<?php e($ldapSyncProfileOnLogin ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
|
||||
<span><?php e(t('Profile sync on LDAP login')); ?></span>
|
||||
</label>
|
||||
<small class="muted"><?php e(t('User profile fields are global and affect all tenants of this user')); ?></small>
|
||||
<div class="grid grid-1-1">
|
||||
<?php foreach ($ldapSyncFieldOptions as $syncFieldKey => $syncFieldLabel): ?>
|
||||
<label class="app-field">
|
||||
<input type="checkbox" name="ldap_sync_profile_fields[]"
|
||||
value="<?php e($syncFieldKey); ?>"
|
||||
<?php e(in_array($syncFieldKey, $ldapSyncProfileFields, true) ? 'checked' : ''); ?>
|
||||
<?php e($disabledAttr); ?>>
|
||||
<span><?php e(t($syncFieldLabel)); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<hr>
|
||||
<label class="app-field">
|
||||
<input type="checkbox" role="switch" name="enforce_ldap_login" value="1"
|
||||
<?php e($ldapEnforce ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
|
||||
<span><?php e(t('Allow LDAP-only login (disable local password login)')); ?></span>
|
||||
</label>
|
||||
<small class="muted"><?php e(t('LDAP-only login requires a complete configuration')); ?></small>
|
||||
<hr>
|
||||
<label>
|
||||
<span><?php e(t('Allowed email domains')); ?></span>
|
||||
<textarea name="ldap_allowed_domains" rows="2" placeholder="example.com" <?php e($readonlyAttr); ?>><?php e($ldapAllowedDomains); ?></textarea>
|
||||
<small><?php e(t('Optional allow-list, comma or newline separated.')); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
@@ -67,11 +67,13 @@ $customFieldDefinitions = $canManageCustomFields
|
||||
: [];
|
||||
$ssoConfig = $canManageSso ? $tenantSsoService->getTenantMicrosoftAuth($tenantId) : [];
|
||||
$ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState($tenantId, $ssoConfig) : [];
|
||||
$ldapConfig = $canManageSso ? $tenantSsoService->getTenantLdapAuth($tenantId) : [];
|
||||
$ldapUiState = $canManageSso ? $tenantSsoService->buildLdapUiState($tenantId) : [];
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']);
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
$form = array_merge($tenant, $ssoConfig);
|
||||
$form = array_merge($tenant, $ssoConfig, $ldapConfig);
|
||||
|
||||
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
|
||||
@@ -119,6 +121,10 @@ if ($request->isMethod('POST')) {
|
||||
if (!($ssoSaveResult['ok'] ?? false)) {
|
||||
$errorBag->merge($ssoSaveResult['errors'] ?? [t('Tenant SSO settings could not be saved')]);
|
||||
}
|
||||
$ldapSaveResult = $tenantSsoService->saveTenantLdapAuth($tenantId, $post);
|
||||
if (!($ldapSaveResult['ok'] ?? false)) {
|
||||
$errorBag->merge($ldapSaveResult['errors'] ?? [t('Tenant LDAP settings could not be saved')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +155,30 @@ if ($request->isMethod('POST')) {
|
||||
'auto_remember_mode' => $post['microsoft_auto_remember_mode'] ?? null,
|
||||
]);
|
||||
$ssoUiState = $tenantSsoService->buildMicrosoftUiState($tenantId, $post);
|
||||
$ldapUiState = $tenantSsoService->buildLdapUiState($tenantId, $post);
|
||||
$form = array_merge($form, [
|
||||
'ldap_enabled' => !empty($post['ldap_enabled']) ? '1' : '0',
|
||||
'enforce_ldap_login' => !empty($post['enforce_ldap_login']) ? '1' : '0',
|
||||
'ldap_host' => trim((string) ($post['ldap_host'] ?? '')),
|
||||
'ldap_port' => (int) ($post['ldap_port'] ?? 389),
|
||||
'ldap_encryption_mode' => (string) ($post['ldap_encryption_mode'] ?? 'starttls'),
|
||||
'ldap_verify_tls_certificate' => !empty($post['ldap_verify_tls_certificate']) ? '1' : '0',
|
||||
'ldap_base_dn' => trim((string) ($post['ldap_base_dn'] ?? '')),
|
||||
'ldap_user_search_filter' => trim((string) ($post['ldap_user_search_filter'] ?? '')),
|
||||
'ldap_bind_method' => (string) ($post['ldap_bind_method'] ?? 'search_then_bind'),
|
||||
'ldap_bind_username_format' => trim((string) ($post['ldap_bind_username_format'] ?? '')),
|
||||
'ldap_unique_id_attribute' => trim((string) ($post['ldap_unique_id_attribute'] ?? 'objectGUID')),
|
||||
'ldap_sync_profile_on_login' => !empty($post['ldap_sync_profile_on_login']) ? '1' : '0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($canManageSso && empty($ssoUiState)) {
|
||||
$ssoUiState = $tenantSsoService->buildMicrosoftUiState($tenantId, $form);
|
||||
}
|
||||
if ($canManageSso && empty($ldapUiState)) {
|
||||
$ldapUiState = $tenantSsoService->buildLdapUiState($tenantId);
|
||||
}
|
||||
|
||||
$validationSummaryErrors = $errorBag->toArray();
|
||||
$errors = $errorBag->toFlatList();
|
||||
|
||||
75
pages/admin/tenants/ldap-test-connection/data().php
Normal file
75
pages/admin/tenants/ldap-test-connection/data().php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'CSRF token invalid']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(\MintyPHP\Http\SessionStoreInterface::class)->all();
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$decision = app(\MintyPHP\Service\Access\AuthorizationService::class)->authorize(
|
||||
TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT,
|
||||
[
|
||||
'actor_user_id' => $currentUserId,
|
||||
'target_tenant_id' => (int) (requestInput()->bodyAll()['tenant_id'] ?? 0),
|
||||
]
|
||||
);
|
||||
if (!$decision->isAllowed()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$capabilities = $decision->attribute('capabilities', []);
|
||||
if (!is_array($capabilities) || empty($capabilities['can_manage_sso'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$post = requestInput()->bodyAll();
|
||||
$host = trim((string) ($post['ldap_host'] ?? ''));
|
||||
$port = (int) ($post['ldap_port'] ?? 389);
|
||||
$encryptionMode = (string) ($post['ldap_encryption_mode'] ?? 'starttls');
|
||||
$verifyTls = !empty($post['ldap_verify_tls_certificate']);
|
||||
$baseDn = trim((string) ($post['ldap_base_dn'] ?? ''));
|
||||
$bindDn = trim((string) ($post['ldap_bind_dn'] ?? ''));
|
||||
$bindPassword = trim((string) ($post['ldap_bind_password'] ?? ''));
|
||||
$networkTimeout = max(1, min(10, (int) ($post['ldap_network_timeout'] ?? 5)));
|
||||
|
||||
if ($host === '') {
|
||||
echo json_encode(['ok' => false, 'error' => t('LDAP host is required')]);
|
||||
return;
|
||||
}
|
||||
|
||||
$ldapAuthService = app(\MintyPHP\Service\Auth\LdapAuthService::class);
|
||||
|
||||
$config = [
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'encryption_mode' => $encryptionMode,
|
||||
'verify_tls_certificate' => $verifyTls,
|
||||
'base_dn' => $baseDn,
|
||||
'bind_dn' => $bindDn,
|
||||
'bind_password' => $bindPassword,
|
||||
'network_timeout' => $networkTimeout,
|
||||
];
|
||||
|
||||
$result = $ldapAuthService->testConnection($config);
|
||||
|
||||
echo json_encode($result);
|
||||
@@ -47,7 +47,7 @@ $tenantCandidates = [];
|
||||
$tenantCandidateById = [];
|
||||
$selectedTenantId = 0;
|
||||
$selectedTenant = null;
|
||||
$selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => ''];
|
||||
$selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => '', 'ldap' => false, 'ldap_reason' => ''];
|
||||
$errorBag = formErrors();
|
||||
$setLoginWarning = static function (string $message) use ($errorBag): void {
|
||||
$errorBag->addGlobal($message);
|
||||
@@ -209,7 +209,7 @@ if (requestInput()->method() === 'POST') {
|
||||
$tenantCandidateById = $resolved['candidate_by_id'];
|
||||
$postedTenantId = (int) (requestInput()->bodyAll()['tenant_id'] ?? 0);
|
||||
|
||||
if (in_array($postedStage, ['choose_tenant', 'login_password'], true)
|
||||
if (in_array($postedStage, ['choose_tenant', 'login_password', 'login_ldap'], true)
|
||||
&& ($postedTenantId <= 0 || !isset($tenantCandidateById[$postedTenantId]))) {
|
||||
$stage = 'choose_tenant';
|
||||
$setLoginWarning(t('We could not continue with these login details'));
|
||||
@@ -237,6 +237,7 @@ if (requestInput()->method() === 'POST') {
|
||||
$selectedTenantSlug = trim((string) $selectedTenant['slug']);
|
||||
$isMicrosoftOnlyTenant = empty($selectedTenantMethods['local'])
|
||||
&& !empty($selectedTenantMethods['microsoft'])
|
||||
&& empty($selectedTenantMethods['ldap'])
|
||||
&& $selectedTenantSlug !== '';
|
||||
|
||||
if (
|
||||
@@ -260,6 +261,96 @@ if (requestInput()->method() === 'POST') {
|
||||
$stage = count($tenantCandidates) === 1 ? 'methods' : 'choose_tenant';
|
||||
} elseif ($postedStage === 'choose_tenant') {
|
||||
$stage = 'methods';
|
||||
} elseif ($postedStage === 'login_ldap') {
|
||||
$stage = 'methods';
|
||||
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;
|
||||
$passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey);
|
||||
if (!($passwordLimitResult['allowed'] ?? true)) {
|
||||
$setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60));
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($selectedTenantMethods['ldap'])) {
|
||||
$setLoginWarning(t('LDAP login is not available for this tenant.'));
|
||||
} else {
|
||||
$ldapUsername = trim((string) (requestInput()->bodyAll()['ldap_username'] ?? ''));
|
||||
$ldapPassword = (string) (requestInput()->bodyAll()['password'] ?? '');
|
||||
$remember = !empty(requestInput()->bodyAll()['remember']);
|
||||
|
||||
$ldapAuthService = $authServicesFactory->createLdapAuthService();
|
||||
$ssoUserLinkService = $authServicesFactory->createSsoUserLinkService();
|
||||
|
||||
$tenantRecord = app(\MintyPHP\Service\Auth\AuthGatewayFactory::class)
|
||||
->createAuthTenantGateway()
|
||||
->findById($selectedTenantId);
|
||||
if (!$tenantRecord) {
|
||||
$setLoginWarning(t('We could not continue with these login details'));
|
||||
} else {
|
||||
$ldapConfigResult = $tenantSsoService->getEffectiveLdapProviderConfig($tenantRecord);
|
||||
if (!($ldapConfigResult['ok'] ?? false)) {
|
||||
$setLoginWarning(t('LDAP login is not available for this tenant.'));
|
||||
} else {
|
||||
$ldapConfig = $ldapConfigResult['config'];
|
||||
$allowedDomains = trim((string) ($ldapConfig['allowed_domains'] ?? ''));
|
||||
if ($allowedDomains !== '' && $email !== '') {
|
||||
$domainList = array_filter(array_map('trim', explode(',', $allowedDomains)));
|
||||
if (!$tenantSsoService->isEmailDomainAllowed($email, $domainList)) {
|
||||
$setLoginWarning(t('Your email domain is not allowed for LDAP login on this tenant.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$ldapResult = $ldapAuthService->authenticate($ldapConfig, $ldapUsername !== '' ? $ldapUsername : $email, $ldapPassword);
|
||||
if (!($ldapResult['ok'] ?? false)) {
|
||||
$ldapError = $ldapResult['error'] ?? 'unknown';
|
||||
if ($ldapError === 'credentials_empty') {
|
||||
$setLoginWarning(t('Please enter your username and password.'));
|
||||
} elseif ($ldapError === 'connection_failed') {
|
||||
$setLoginWarning(t('LDAP server is not reachable. Please try again later.'));
|
||||
} else {
|
||||
$setLoginWarning(t('Username or password not valid'));
|
||||
}
|
||||
$passwordFailureResult = $rateLimiterService->registerFailure(
|
||||
$loginPasswordScope,
|
||||
$passwordAttemptKey,
|
||||
$loginPasswordLimit,
|
||||
$loginPasswordWindow,
|
||||
$loginPasswordBlock
|
||||
);
|
||||
if (!($passwordFailureResult['allowed'] ?? true)) {
|
||||
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
|
||||
}
|
||||
} else {
|
||||
$linkResult = $ssoUserLinkService->linkOrProvisionLdapUser($tenantRecord, $ldapResult, $ldapConfig);
|
||||
if (!($linkResult['ok'] ?? false)) {
|
||||
$linkError = $linkResult['error'] ?? 'unknown';
|
||||
if ($linkError === 'user_inactive') {
|
||||
$setLoginWarning(t('Your account is deactivated.'));
|
||||
} elseif ($linkError === 'email_missing') {
|
||||
$setLoginWarning(t('Your LDAP account does not have an email address configured.'));
|
||||
} else {
|
||||
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
|
||||
}
|
||||
} else {
|
||||
$ldapUserId = (int) ($linkResult['user_id'] ?? 0);
|
||||
$loginResult = $authService->loginUserById($ldapUserId, $selectedTenantId, 'ldap');
|
||||
if (!($loginResult['ok'] ?? false)) {
|
||||
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
|
||||
} else {
|
||||
$rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey);
|
||||
if ($remember || $tenantSsoService->shouldAutoRememberOnLdapLogin($selectedTenantId)) {
|
||||
$rememberMeService->rememberUser($ldapUserId);
|
||||
}
|
||||
$sessionStore->set('session_started_at', time());
|
||||
$sessionStore->set('session_last_activity', time());
|
||||
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($postedStage === 'login_password') {
|
||||
$stage = 'methods';
|
||||
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;
|
||||
|
||||
@@ -22,7 +22,7 @@ $tenantHint = trim((string) ($tenantHint ?? ''));
|
||||
$tenantCandidates = is_array($tenantCandidates ?? null) ? $tenantCandidates : [];
|
||||
$selectedTenantId = (int) ($selectedTenantId ?? 0);
|
||||
$selectedTenant = is_array($selectedTenant ?? null) ? $selectedTenant : null;
|
||||
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false];
|
||||
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false, 'ldap' => false];
|
||||
$selectedTenantSlug = (string) ($selectedTenant['slug'] ?? '');
|
||||
$selectedTenantLabel = trim((string) ($selectedTenant['description'] ?? ''));
|
||||
if ($selectedTenantLabel === '') {
|
||||
@@ -183,7 +183,46 @@ $returnToSanitized = (string) ($returnToSanitized ?? '');
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($selectedTenantMethods['local']) && !empty($selectedTenantMethods['microsoft'])): ?>
|
||||
<?php if (!empty($selectedTenantMethods['ldap'])): ?>
|
||||
<?php
|
||||
$hasMethodAbove = !empty($selectedTenantMethods['microsoft']);
|
||||
?>
|
||||
<?php if ($hasMethodAbove): ?>
|
||||
<div class="login-alt-separator" aria-hidden="true">
|
||||
<span><?php e(t('Or')); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="login-ldap-form" data-login-submit-lock="1">
|
||||
<input type="hidden" name="stage" value="login_ldap">
|
||||
<input type="hidden" name="email" value="<?php e($email); ?>">
|
||||
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
|
||||
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
|
||||
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
|
||||
<label for="ldap_username">
|
||||
<span><?php e(t('LDAP Username')); ?></span>
|
||||
<input required type="text" name="ldap_username" id="ldap_username" autocomplete="username" />
|
||||
</label>
|
||||
<label for="ldap_password">
|
||||
<span><?php e(t('Password')); ?></span>
|
||||
<input required type="password" name="password" id="ldap_password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
|
||||
</label>
|
||||
<p>
|
||||
<label class="app-field inline">
|
||||
<input type="checkbox" name="remember" value="1">
|
||||
<span><?php e(t('Remember me')); ?></span>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit"><i class="bi bi-shield-lock" aria-hidden="true"></i> <?php e(t('Sign in with LDAP')); ?></button>
|
||||
</p>
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$hasMethodAboveLocal = !empty($selectedTenantMethods['microsoft']) || !empty($selectedTenantMethods['ldap']);
|
||||
?>
|
||||
<?php if (!empty($selectedTenantMethods['local']) && $hasMethodAboveLocal): ?>
|
||||
<div class="login-alt-separator" aria-hidden="true">
|
||||
<span><?php e(t('Or')); ?></span>
|
||||
</div>
|
||||
@@ -214,7 +253,7 @@ $returnToSanitized = (string) ($returnToSanitized ?? '');
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft'])): ?>
|
||||
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft']) && empty($selectedTenantMethods['ldap'])): ?>
|
||||
<div class="notice" data-variant="warning" role="status" aria-live="polite">
|
||||
<?php e(t('No login method is available for this tenant')); ?>
|
||||
</div>
|
||||
|
||||
341
tests/Service/Auth/LdapAuthServiceTest.php
Normal file
341
tests/Service/Auth/LdapAuthServiceTest.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Auth\LdapAuthService;
|
||||
use MintyPHP\Service\Auth\LdapConnectionGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LdapAuthServiceTest extends TestCase
|
||||
{
|
||||
private function newService(?LdapConnectionGateway $ldap = null): LdapAuthService
|
||||
{
|
||||
return new LdapAuthService(
|
||||
$ldap ?? $this->createMock(LdapConnectionGateway::class)
|
||||
);
|
||||
}
|
||||
|
||||
private function baseConfig(): array
|
||||
{
|
||||
return [
|
||||
'host' => 'ldap.example.com',
|
||||
'port' => 389,
|
||||
'encryption_mode' => 'none',
|
||||
'verify_tls_certificate' => false,
|
||||
'base_dn' => 'DC=example,DC=com',
|
||||
'bind_dn' => 'CN=svc,DC=example,DC=com',
|
||||
'bind_password' => 'secret',
|
||||
'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
'user_search_scope' => 'sub',
|
||||
'bind_method' => 'search_then_bind',
|
||||
'bind_username_format' => '',
|
||||
'unique_id_attribute' => 'objectGUID',
|
||||
'attribute_map' => '{"first_name":"givenName","last_name":"sn","email":"mail"}',
|
||||
'network_timeout' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsCredentialsEmptyForEmptyUsername(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($this->baseConfig(), '', 'password');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('credentials_empty', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsCredentialsEmptyForEmptyPassword(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', '');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('credentials_empty', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsConnectionFailedWhenConnectFails(): void
|
||||
{
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn(false);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsConnectionFailedForEmptyHost(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['host'] = '';
|
||||
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($config, 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindServiceBindFails(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('service_bind_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindUserNotFound(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('search')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindInvalidPassword(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Test'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'test@example.com'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$bindCallCount = 0;
|
||||
$ldap->method('bind')->willReturnCallback(function () use (&$bindCallCount) {
|
||||
$bindCallCount++;
|
||||
return $bindCallCount <= 1;
|
||||
});
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'testuser', 'wrongpass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('invalid_credentials', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindSuccess(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Test'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'test@example.com'],
|
||||
'objectguid' => ['count' => 1, 0 => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'testuser', 'correctpass');
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('CN=Test User,DC=example,DC=com', $result['dn']);
|
||||
$this->assertSame('Test', $result['attributes']['first_name']);
|
||||
$this->assertSame('User', $result['attributes']['last_name']);
|
||||
$this->assertSame('test@example.com', $result['attributes']['email']);
|
||||
}
|
||||
|
||||
public function testAuthenticateDirectBindSuccess(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['bind_method'] = 'direct_bind';
|
||||
$config['bind_username_format'] = '%s@example.com';
|
||||
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Direct'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'direct@example.com'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($config, 'testuser', 'pass');
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('Direct', $result['attributes']['first_name']);
|
||||
}
|
||||
|
||||
public function testLdapInjectionIsEscaped(): void
|
||||
{
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$escapedValue = '';
|
||||
$ldap->method('escape')->willReturnCallback(function (string $v) use (&$escapedValue) {
|
||||
$escapedValue = $v;
|
||||
return 'escaped_' . $v;
|
||||
});
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('search')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user*)(|(uid=*)', 'pass');
|
||||
|
||||
$this->assertSame('user*)(|(uid=*)', $escapedValue);
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testBinaryGuidToUuid(): void
|
||||
{
|
||||
$binary = hex2bin('a1a2a3a4b1b2c1c2d1d2e1e2e3e4e5e6');
|
||||
$uuid = LdapAuthService::binaryGuidToUuid($binary);
|
||||
$this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uuid);
|
||||
}
|
||||
|
||||
public function testBinaryGuidToUuidNon16Bytes(): void
|
||||
{
|
||||
$result = LdapAuthService::binaryGuidToUuid('short');
|
||||
$this->assertSame(bin2hex('short'), $result);
|
||||
}
|
||||
|
||||
public function testTestConnectionSuccess(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->testConnection($this->baseConfig());
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testTestConnectionBindFailure(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(false);
|
||||
$ldap->method('error')->willReturn('Invalid credentials');
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->testConnection($this->baseConfig());
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('Service account bind failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testStarttlsFailureReturnsConnectionFailed(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['encryption_mode'] = 'starttls';
|
||||
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('startTls')->willReturn(false);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($config, 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
private function createMockConnection(): \LDAP\Connection
|
||||
{
|
||||
// We need a real LDAP\Connection object for type hints.
|
||||
// Since we can't create one without ldap_connect, use reflection.
|
||||
// For tests where ldap extension may not be available, skip.
|
||||
if (!extension_loaded('ldap')) {
|
||||
$this->markTestSkipped('LDAP extension not available');
|
||||
}
|
||||
|
||||
// ldap_connect with a URI doesn't actually connect in PHP 8.1+
|
||||
$conn = ldap_connect('ldap://127.0.0.1:1');
|
||||
if ($conn === false) {
|
||||
$this->markTestSkipped('Could not create LDAP connection resource');
|
||||
}
|
||||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
private function createMockSearchResult(): \LDAP\Result
|
||||
{
|
||||
// LDAP\Result can't be mocked directly, but since our gateway wraps
|
||||
// all ldap_* calls, the actual result object is opaque to LdapAuthService.
|
||||
// The gateway mock returns it, and we just need a type-compatible value.
|
||||
// Use a real connection + search if ldap is available; otherwise skip.
|
||||
if (!extension_loaded('ldap')) {
|
||||
$this->markTestSkipped('LDAP extension not available');
|
||||
}
|
||||
|
||||
// Create a real but unused Result object via reflection workaround.
|
||||
// Since we mock getEntries(), the actual result content doesn't matter.
|
||||
// But LDAP\Result has no public constructor, so we use a trick:
|
||||
// We accept that the gateway returns LDAP\Result|false and our mocks
|
||||
// already handle this. For this test helper, return a mock-compatible value.
|
||||
// Since LdapConnectionGateway::search returns \LDAP\Result|false,
|
||||
// and we mock the gateway, we can use createMock on a stdClass and
|
||||
// rely on the mock returning it. But PHPUnit mock of LdapConnectionGateway
|
||||
// allows any return value when configured via willReturn().
|
||||
// So we just need any object - the gateway mock will return it to the service,
|
||||
// and the service passes it back to gateway->getEntries().
|
||||
$conn = ldap_connect('ldap://127.0.0.1:1');
|
||||
if ($conn === false) {
|
||||
$this->markTestSkipped('Could not create LDAP connection');
|
||||
}
|
||||
|
||||
// We can't get a real LDAP\Result without a real server, but since
|
||||
// the service only passes it to the mocked gateway, we can create
|
||||
// a test double. PHPUnit allows returning any value from willReturn().
|
||||
// Let's use a simple approach: the gateway mock already handles this.
|
||||
// For the type system, we use an anonymous class that extends nothing
|
||||
// but the mock system handles the actual passing.
|
||||
|
||||
// Actually, since we mock LdapConnectionGateway::search() to return
|
||||
// this value, and LdapConnectionGateway::getEntries() is also mocked,
|
||||
// the value just needs to be truthy (not false).
|
||||
// We can use createStub to get a compatible value.
|
||||
return $this->createStub(\LDAP\Result::class);
|
||||
}
|
||||
}
|
||||
@@ -804,6 +804,154 @@ class SsoUserLinkServiceTest extends TestCase
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
// ── LDAP user link tests ──────────────────────────────────────────
|
||||
|
||||
public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyHost(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
|
||||
['host' => '']
|
||||
);
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('identity_invalid', $result['error']);
|
||||
}
|
||||
|
||||
public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyOid(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
['unique_id' => '', 'dn' => '', 'attributes' => []],
|
||||
['host' => 'ldap.example.com']
|
||||
);
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('identity_invalid', $result['error']);
|
||||
}
|
||||
|
||||
public function testLinkOrProvisionLdapUserLinksExistingIdentity(): void
|
||||
{
|
||||
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
|
||||
$identityGateway->method('findByProviderTidOid')
|
||||
->willReturn(['user_id' => 42]);
|
||||
|
||||
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepo->method('find')
|
||||
->willReturn(['id' => 42, 'active' => 1]);
|
||||
|
||||
$userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
|
||||
$userTenantRepo->method('listTenantIdsByUserId')
|
||||
->willReturn([1]);
|
||||
|
||||
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
||||
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
|
||||
$tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn([]);
|
||||
$tenantSsoService->method('defaultProfileSyncFields')->willReturn([]);
|
||||
|
||||
$service = $this->newService($userReadRepo, null, null, $userTenantRepo, null, $tenantSsoService, null, $identityGateway);
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => 'test@example.com']],
|
||||
['host' => 'ldap.example.com', 'sync_profile_on_login' => false]
|
||||
);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(42, $result['user_id']);
|
||||
$this->assertFalse($result['created']);
|
||||
}
|
||||
|
||||
public function testLinkOrProvisionLdapUserReturnsInactiveForDisabledUser(): void
|
||||
{
|
||||
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
|
||||
$identityGateway->method('findByProviderTidOid')
|
||||
->willReturn(['user_id' => 42]);
|
||||
|
||||
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepo->method('find')
|
||||
->willReturn(['id' => 42, 'active' => 0]);
|
||||
|
||||
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
||||
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
|
||||
|
||||
$service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
|
||||
['host' => 'ldap.example.com']
|
||||
);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_inactive', $result['error']);
|
||||
}
|
||||
|
||||
public function testLinkOrProvisionLdapUserProvisionsNewUser(): void
|
||||
{
|
||||
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
|
||||
$identityGateway->method('findByProviderTidOid')->willReturn(null);
|
||||
|
||||
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepo->method('findByEmail')->willReturn(null);
|
||||
|
||||
$userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
|
||||
$userWriteRepo->method('create')->willReturn(99);
|
||||
$userWriteRepo->method('setEmailVerified')->willReturn(true);
|
||||
$userWriteRepo->method('updateProfileFieldsFromSso')->willReturn(true);
|
||||
|
||||
$userAssignment = $this->createMock(UserAssignmentService::class);
|
||||
|
||||
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
|
||||
$settingsGateway->method('getDefaultRoleId')->willReturn(0);
|
||||
$settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
|
||||
$settingsGateway->method('getAppTheme')->willReturn('light');
|
||||
$settingsGateway->method('normalizeTheme')->willReturn('light');
|
||||
|
||||
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
||||
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
|
||||
$tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn(['first_name', 'last_name']);
|
||||
$tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);
|
||||
|
||||
$service = $this->newService($userReadRepo, $userWriteRepo, $userAssignment, null, $settingsGateway, $tenantSsoService, null, $identityGateway);
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
[
|
||||
'unique_id' => 'guid-new',
|
||||
'dn' => 'CN=New User,DC=example,DC=com',
|
||||
'attributes' => [
|
||||
'first_name' => 'New',
|
||||
'last_name' => 'User',
|
||||
'email' => 'new@example.com',
|
||||
],
|
||||
],
|
||||
['host' => 'ldap.example.com', 'sync_profile_on_login' => true, 'sync_profile_fields' => ['first_name', 'last_name']]
|
||||
);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(99, $result['user_id']);
|
||||
$this->assertTrue($result['created']);
|
||||
}
|
||||
|
||||
public function testLinkOrProvisionLdapUserReturnsEmailMissingWhenNoEmail(): void
|
||||
{
|
||||
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
|
||||
$identityGateway->method('findByProviderTidOid')->willReturn(null);
|
||||
|
||||
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
|
||||
|
||||
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
||||
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
|
||||
|
||||
$service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
|
||||
$result = $service->linkOrProvisionLdapUser(
|
||||
['id' => 1],
|
||||
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => '']],
|
||||
['host' => 'ldap.example.com']
|
||||
);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('email_missing', $result['error']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Factory helper
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
|
||||
use MintyPHP\Service\Auth\AuthCryptoGateway;
|
||||
use MintyPHP\Service\Auth\AuthSettingsGateway;
|
||||
@@ -458,13 +459,139 @@ class TenantSsoServiceTest extends TestCase
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
// ── LDAP tests ────────────────────────────────────────────────────
|
||||
|
||||
public function testGetTenantLdapAuthReturnsDefaults(): void
|
||||
{
|
||||
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepository->method('findByTenantId')->willReturn(null);
|
||||
|
||||
$service = $this->newService(null, null, null, null, $ldapRepository);
|
||||
$result = $service->getTenantLdapAuth(1);
|
||||
|
||||
$this->assertFalse($result['enabled']);
|
||||
$this->assertFalse($result['enforce_ldap_login']);
|
||||
$this->assertSame('', $result['host']);
|
||||
$this->assertSame(389, $result['port']);
|
||||
$this->assertSame('starttls', $result['encryption_mode']);
|
||||
}
|
||||
|
||||
public function testGetEffectiveLdapProviderConfigReturnsDisabledWhenNotEnabled(): void
|
||||
{
|
||||
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepository->method('findByTenantId')->willReturn(['enabled' => 0]);
|
||||
|
||||
$service = $this->newService(null, null, null, null, $ldapRepository);
|
||||
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('ldap_disabled', $result['error']);
|
||||
}
|
||||
|
||||
public function testGetEffectiveLdapProviderConfigReturnsHostMissing(): void
|
||||
{
|
||||
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepository->method('findByTenantId')->willReturn([
|
||||
'enabled' => 1,
|
||||
'host' => '',
|
||||
'base_dn' => 'DC=example,DC=com',
|
||||
]);
|
||||
|
||||
$service = $this->newService(null, null, null, null, $ldapRepository);
|
||||
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('ldap_host_missing', $result['error']);
|
||||
}
|
||||
|
||||
public function testGetEffectiveLdapProviderConfigReturnsBaseDnMissing(): void
|
||||
{
|
||||
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepository->method('findByTenantId')->willReturn([
|
||||
'enabled' => 1,
|
||||
'host' => 'ldap.example.com',
|
||||
'base_dn' => '',
|
||||
]);
|
||||
|
||||
$service = $this->newService(null, null, null, null, $ldapRepository);
|
||||
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('ldap_base_dn_missing', $result['error']);
|
||||
}
|
||||
|
||||
public function testGetEffectiveLdapProviderConfigSuccessNoBindCredentials(): void
|
||||
{
|
||||
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepository->method('findByTenantId')->willReturn([
|
||||
'enabled' => 1,
|
||||
'host' => 'ldap.example.com',
|
||||
'port' => 389,
|
||||
'encryption_mode' => 'starttls',
|
||||
'base_dn' => 'DC=example,DC=com',
|
||||
'bind_dn_enc' => '',
|
||||
'bind_password_enc' => '',
|
||||
'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
]);
|
||||
|
||||
$service = $this->newService(null, null, null, null, $ldapRepository);
|
||||
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('ldap', $result['config']['provider']);
|
||||
$this->assertSame('ldap.example.com', $result['config']['host']);
|
||||
}
|
||||
|
||||
public function testResolveTenantLoginMethodsIncludesLdap(): void
|
||||
{
|
||||
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
||||
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
|
||||
|
||||
$microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
|
||||
$microsoftRepo->method('findByTenantId')->willReturn(['enabled' => 0]);
|
||||
|
||||
$ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$ldapRepo->method('findByTenantId')->willReturn([
|
||||
'enabled' => 1,
|
||||
'host' => 'ldap.example.com',
|
||||
'base_dn' => 'DC=example,DC=com',
|
||||
'bind_dn_enc' => '',
|
||||
'bind_password_enc' => '',
|
||||
]);
|
||||
|
||||
$service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo);
|
||||
$result = $service->resolveTenantLoginMethods(5);
|
||||
|
||||
$this->assertTrue($result['local']);
|
||||
$this->assertFalse($result['microsoft']);
|
||||
$this->assertTrue($result['ldap']);
|
||||
}
|
||||
|
||||
public function testNormalizeLdapAttributeMapParsesJson(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$map = $service->normalizeLdapAttributeMap('{"first_name":"givenName","email":"mail"}');
|
||||
$this->assertSame('givenName', $map['first_name']);
|
||||
$this->assertSame('mail', $map['email']);
|
||||
}
|
||||
|
||||
public function testNormalizeLdapAttributeMapReturnsDefaultForInvalidJson(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$map = $service->normalizeLdapAttributeMap('not json');
|
||||
$this->assertArrayHasKey('first_name', $map);
|
||||
$this->assertSame('givenName', $map['first_name']);
|
||||
}
|
||||
|
||||
private function newService(
|
||||
?TenantMicrosoftAuthRepositoryInterface $repository = null,
|
||||
?AuthTenantGateway $tenantGateway = null,
|
||||
?AuthSettingsGateway $settingsGateway = null,
|
||||
?AuthCryptoGateway $cryptoGateway = null
|
||||
?AuthCryptoGateway $cryptoGateway = null,
|
||||
?TenantLdapAuthRepositoryInterface $ldapRepository = null
|
||||
): TenantSsoService {
|
||||
$repository = $repository ?? $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
|
||||
$ldapRepository = $ldapRepository ?? $this->createMock(TenantLdapAuthRepositoryInterface::class);
|
||||
$tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
|
||||
if ($settingsGateway === null) {
|
||||
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
|
||||
@@ -482,6 +609,7 @@ class TenantSsoServiceTest extends TestCase
|
||||
return new TenantSsoService(
|
||||
$tenantGateway,
|
||||
$repository,
|
||||
$ldapRepository,
|
||||
$settingsGateway,
|
||||
$cryptoGateway
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user