diff --git a/db/init/init.sql b/db/init/init.sql index 09cc5af..6546ca4 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -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, diff --git a/db/updates/2026-03-25-tenant-auth-ldap.sql b/db/updates/2026-03-25-tenant-auth-ldap.sql new file mode 100644 index 0000000..a8bce33 --- /dev/null +++ b/db/updates/2026-03-25-tenant-auth-ldap.sql @@ -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; diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index ee5a428..8c6a5ae 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -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 diff --git a/i18n/default_de.json b/i18n/default_de.json index f300dca..1e5bc8c 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -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." } diff --git a/i18n/default_en.json b/i18n/default_en.json index 0725ca4..6dd9f98 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -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." } diff --git a/lib/App/Container/Registrars/AuthRegistrar.php b/lib/App/Container/Registrars/AuthRegistrar.php index 52bbf86..46a74f6 100644 --- a/lib/App/Container/Registrars/AuthRegistrar.php +++ b/lib/App/Container/Registrars/AuthRegistrar.php @@ -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()); diff --git a/lib/Repository/Tenant/TenantLdapAuthRepository.php b/lib/Repository/Tenant/TenantLdapAuthRepository.php new file mode 100644 index 0000000..d777d5f --- /dev/null +++ b/lib/Repository/Tenant/TenantLdapAuthRepository.php @@ -0,0 +1,105 @@ +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; + } +} diff --git a/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php b/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php new file mode 100644 index 0000000..611ef2d --- /dev/null +++ b/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php @@ -0,0 +1,13 @@ +tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository(); } + public function createTenantLdapAuthRepository(): TenantLdapAuthRepositoryInterface + { + return $this->tenantLdapAuthRepository ??= new TenantLdapAuthRepository(); + } + public function createUserExternalIdentityRepository(): UserExternalIdentityRepositoryInterface { return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository(); diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php index c80421f..3ed79a5 100644 --- a/lib/Service/Auth/AuthServicesFactory.php +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -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); diff --git a/lib/Service/Auth/LdapAuthService.php b/lib/Service/Auth/LdapAuthService.php new file mode 100644 index 0000000..0648882 --- /dev/null +++ b/lib/Service/Auth/LdapAuthService.php @@ -0,0 +1,294 @@ +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; + } +} diff --git a/lib/Service/Auth/LdapConnectionGateway.php b/lib/Service/Auth/LdapConnectionGateway.php new file mode 100644 index 0000000..2e4649d --- /dev/null +++ b/lib/Service/Auth/LdapConnectionGateway.php @@ -0,0 +1,67 @@ +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)))); diff --git a/lib/Service/Auth/TenantSsoService.php b/lib/Service/Auth/TenantSsoService.php index a4b0d9f..589c3a9 100644 --- a/lib/Service/Auth/TenantSsoService.php +++ b/lib/Service/Auth/TenantSsoService.php @@ -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)); diff --git a/pages/admin/tenants/_form.phtml b/pages/admin/tenants/_form.phtml index c0fcb96..081e85d 100644 --- a/pages/admin/tenants/_form.phtml +++ b/pages/admin/tenants/_form.phtml @@ -678,6 +678,245 @@ $openOverrideCard = $detailsOpenAll; + + 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); + ?> +
+

+ +
+ +
+ + +
+
+ +
+ + +
> + + + + + : + + + +
+ +
+
+ + +
+
+ + +
+ +
+ + + +
+

+ + +

+
+
+ +
> + + + +
+ + +
+ + +
+ + +
+
+ +
> + + + +
+ +
+ 'givenName', + 'last_name' => 'sn', + 'email' => 'mail', + 'phone' => 'telephoneNumber', + 'mobile' => 'mobile', + ]; + foreach ($defaultMap as $appField => $defaultLdapAttr): + $currentLdapAttr = $ldapAttributeMap[$appField] ?? $defaultLdapAttr; + ?> + + +
+
+ + +
+ $syncFieldLabel): ?> + + +
+
+ + +
+ +
+
diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 9e19a36..1a471f0 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -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(); diff --git a/pages/admin/tenants/ldap-test-connection/data().php b/pages/admin/tenants/ldap-test-connection/data().php new file mode 100644 index 0000000..4699fb4 --- /dev/null +++ b/pages/admin/tenants/ldap-test-connection/data().php @@ -0,0 +1,75 @@ +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); diff --git a/pages/auth/login().php b/pages/auth/login().php index 68e2c7b..dd242ea 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -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; diff --git a/pages/auth/login(login).phtml b/pages/auth/login(login).phtml index 5befa9c..07544fb 100644 --- a/pages/auth/login(login).phtml +++ b/pages/auth/login(login).phtml @@ -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 ?? ''); - + + + + + +
+ + + + + + + +

+ +

+

+ +

+ +
+ + + + @@ -214,7 +253,7 @@ $returnToSanitized = (string) ($returnToSanitized ?? ''); - +
diff --git a/tests/Service/Auth/LdapAuthServiceTest.php b/tests/Service/Auth/LdapAuthServiceTest.php new file mode 100644 index 0000000..6ab69fa --- /dev/null +++ b/tests/Service/Auth/LdapAuthServiceTest.php @@ -0,0 +1,341 @@ +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); + } +} diff --git a/tests/Service/Auth/SsoUserLinkServiceTest.php b/tests/Service/Auth/SsoUserLinkServiceTest.php index c5d6f8e..9de678b 100644 --- a/tests/Service/Auth/SsoUserLinkServiceTest.php +++ b/tests/Service/Auth/SsoUserLinkServiceTest.php @@ -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 // --------------------------------------------------------------- diff --git a/tests/Service/Auth/TenantSsoServiceTest.php b/tests/Service/Auth/TenantSsoServiceTest.php index c28f9db..f9eb6f8 100644 --- a/tests/Service/Auth/TenantSsoServiceTest.php +++ b/tests/Service/Auth/TenantSsoServiceTest.php @@ -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 );