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); + ?> +
++ + +
+ +
+ + +
+