'Sync first name', 'last_name' => 'Sync last name', 'phone' => 'Sync phone', 'mobile' => 'Sync mobile', '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 ) { } public function microsoftProviderKey(): string { return self::PROVIDER_MICROSOFT; } public function ldapProviderKey(): string { return self::PROVIDER_LDAP; } public function findTenantByLoginSlug(string $slug): ?array { $slug = trim(strtolower($slug)); if ($slug === '') { return null; } if (preg_match('/^[a-f0-9-]{36}$/', $slug)) { $tenant = $this->tenantGateway->findByUuid($slug); if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') { return $tenant; } } $matches = []; foreach ($this->tenantGateway->list() as $tenant) { if ((string) ($tenant['status'] ?? 'active') !== 'active') { continue; } if ($this->slugify((string) ($tenant['description'] ?? '')) === $slug) { $matches[] = $tenant; } } // Reject ambiguous slugs — if two tenants have the same name-slug, neither matches. if (count($matches) !== 1) { return null; } return $matches[0]; } public function tenantLoginSlug(array $tenant): string { $slug = $this->slugify((string) ($tenant['description'] ?? '')); if ($slug !== '') { return $slug; } return strtolower((string) ($tenant['uuid'] ?? '')); } public function getTenantMicrosoftAuth(int $tenantId): array { $row = $this->tenantMicrosoftAuthRepository->findByTenantId($tenantId) ?? []; $syncProfileOnLogin = !empty($row['sync_profile_on_login']); $syncFields = $this->normalizeProfileSyncFields($row['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncFields) { $syncFields = $this->defaultProfileSyncFields(); } return [ 'enabled' => !empty($row['enabled']), 'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']), 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields), 'sync_profile_fields_list' => $syncFields, 'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))), 'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')), 'use_shared_app' => !array_key_exists('use_shared_app', $row) || !empty($row['use_shared_app']), 'client_id_override' => trim((string) ($row['client_id_override'] ?? '')), 'client_secret_override_enc' => trim((string) ($row['client_secret_override_enc'] ?? '')), 'auto_remember_mode' => array_key_exists('auto_remember_mode', $row) && $row['auto_remember_mode'] !== null ? (int) $row['auto_remember_mode'] : null, ]; } public function buildMicrosoftUiState(int $tenantId, array $input = []): array { $existing = $this->getTenantMicrosoftAuth($tenantId); $state = $this->normalizeMicrosoftInputState($input, $existing); $configState = $this->evaluateMicrosoftConfigState($state); return [ 'enabled' => $state['enabled'], 'config_complete' => !$state['enabled'] || $configState['complete'], 'config_error_code' => (string) ($configState['error_code'] ?? ''), 'config_error_label' => $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')), 'password_mode' => !$state['enabled'] ? 'local_only' : ($state['enforce_microsoft_login'] ? 'microsoft_only' : 'local_and_microsoft'), 'credential_source' => $state['use_shared_app'] ? 'shared' : 'override', 'sync_needs_graph' => $state['sync_profile_on_login'] && $this->profileSyncFieldsRequireGraph($state['sync_profile_fields']), ]; } public function isLocalPasswordLoginAllowed(int $tenantId): bool { $microsoftAuth = $this->getTenantMicrosoftAuth($tenantId); if ($microsoftAuth['enabled'] && $microsoftAuth['enforce_microsoft_login']) { return false; } $ldapAuth = $this->getTenantLdapAuth($tenantId); if ($ldapAuth['enabled'] && $ldapAuth['enforce_ldap_login']) { return false; } return true; } /** * Discover tenants that accept SSO login for the given email domain. * Only tenants with explicitly configured (non-empty) allowed_domains are returned. * Returns SSO-only methods (local is always false since the user has no local account). * * @return list Tenant candidates with SSO methods */ public function discoverTenantsByEmailDomain(string $email): array { $email = strtolower(trim($email)); $parts = explode('@', $email); if (count($parts) !== 2 || $parts[1] === '') { return []; } $domain = $parts[1]; $microsoftMatches = $this->tenantMicrosoftAuthRepository->findEnabledWithExplicitDomain($domain); $ldapMatches = $this->tenantLdapAuthRepository->findEnabledWithExplicitDomain($domain); $tenantIds = []; foreach ($microsoftMatches as $match) { $tenantIds[(int) $match['tenant_id']] = true; } foreach ($ldapMatches as $match) { $tenantIds[(int) $match['tenant_id']] = true; } if (!$tenantIds) { return []; } $candidates = []; foreach (array_keys($tenantIds) as $tenantId) { $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { continue; } $methods = $this->resolveTenantLoginMethods($tenantId); // Discovery candidates never offer local password (user has no local account) $methods['local'] = false; // Skip tenants where no SSO method is actually functional if (empty($methods['microsoft']) && empty($methods['ldap'])) { continue; } $candidates[] = [ 'id' => $tenantId, 'tenant' => $tenant, 'methods' => $methods, ]; } return $candidates; } public function resolveTenantLoginMethods(int $tenantId): array { $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { return [ 'local' => false, 'microsoft' => false, 'microsoft_reason' => 'tenant_not_found', 'ldap' => false, 'ldap_reason' => 'tenant_not_found', ]; } $microsoftResult = $this->getEffectiveMicrosoftProviderConfig($tenant); $ldapResult = $this->getEffectiveLdapProviderConfig($tenant); return [ 'local' => $this->isLocalPasswordLoginAllowed($tenantId), 'microsoft' => !empty($microsoftResult['ok']), 'microsoft_reason' => $microsoftResult['ok'] ?? false ? '' : (string) ($microsoftResult['error'] ?? 'sso_disabled'), 'ldap' => !empty($ldapResult['ok']), 'ldap_reason' => $ldapResult['ok'] ?? false ? '' : (string) ($ldapResult['error'] ?? 'ldap_disabled'), ]; } public function getEffectiveMicrosoftProviderConfig(array $tenant): array { $tenantId = (int) ($tenant['id'] ?? 0); if ($tenantId <= 0) { return ['ok' => false, 'error' => 'tenant_not_found']; } $auth = $this->getTenantMicrosoftAuth($tenantId); if (!$auth['enabled']) { return ['ok' => false, 'error' => 'sso_disabled']; } $entraTenantId = $auth['entra_tenant_id']; if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { return ['ok' => false, 'error' => 'tenant_id_invalid']; } $useSharedApp = $auth['use_shared_app']; $clientId = ''; $clientSecret = ''; if ($useSharedApp) { $clientId = $this->settingsGateway->getMicrosoftSharedClientId(); $clientSecret = $this->settingsGateway->getMicrosoftSharedClientSecret(); if (trim($clientId) === '' || trim($clientSecret) === '') { return ['ok' => false, 'error' => 'shared_credentials_missing']; } } else { $clientId = (string) $auth['client_id_override']; if (trim($clientId) === '') { return ['ok' => false, 'error' => 'client_id_override_required']; } if ($auth['client_secret_override_enc'] === '') { return ['ok' => false, 'error' => 'client_secret_override_required']; } if ($auth['client_secret_override_enc'] !== '') { try { $clientSecret = $this->cryptoGateway->decryptString($auth['client_secret_override_enc']); } catch (\Throwable $exception) { return ['ok' => false, 'error' => 'secret_invalid']; } } if (trim($clientSecret) === '') { return ['ok' => false, 'error' => 'client_secret_override_required']; } } $clientId = trim($clientId); $clientSecret = trim($clientSecret); return [ 'ok' => true, 'config' => [ 'provider' => self::PROVIDER_MICROSOFT, 'tenant_id' => $entraTenantId, 'client_id' => $clientId, 'client_secret' => $clientSecret, 'allowed_domains' => $auth['allowed_domains'], 'enforce_microsoft_login' => $auth['enforce_microsoft_login'], 'sync_profile_on_login' => $auth['sync_profile_on_login'], 'sync_profile_fields' => $auth['sync_profile_fields_list'], 'sync_profile_needs_graph' => $auth['sync_profile_on_login'] && $this->profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']), 'authority' => $this->settingsGateway->getMicrosoftAuthority(), 'use_shared_app' => $useSharedApp, ], ]; } public function saveTenantMicrosoftAuth(int $tenantId, array $input): array { $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant) { return ['ok' => false, 'errors' => [t('Tenant not found')]]; } $enabled = !empty($input['microsoft_enabled']); $enforce = !empty($input['enforce_microsoft_login']); $entraTenantId = strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))); $allowedDomains = $this->normalizeDomains((string) ($input['allowed_domains'] ?? '')); $syncProfileOnLogin = !empty($input['sync_profile_on_login']); $syncProfileFields = $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { $syncProfileFields = $this->defaultProfileSyncFields(); } $syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields); $useSharedApp = !empty($input['use_shared_app']); $clientIdOverride = trim((string) ($input['client_id_override'] ?? '')); $clientSecretOverride = trim((string) ($input['client_secret_override'] ?? '')); $clearSecret = !empty($input['clear_client_secret_override']); $errors = []; $existing = $this->getTenantMicrosoftAuth($tenantId); $clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? ''); if ($clearSecret) { $clientSecretOverrideEnc = ''; } elseif ($clientSecretOverride !== '') { if (!$this->cryptoGateway->isConfigured()) { $errors[] = t('APP_CRYPTO_KEY is missing or invalid'); } else { try { $clientSecretOverrideEnc = $this->cryptoGateway->encryptString($clientSecretOverride); } catch (\Throwable $exception) { $errors[] = t('Client secret could not be encrypted'); } } } $stateForValidation = [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enforce, 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $syncProfileFields, 'entra_tenant_id' => $entraTenantId, 'use_shared_app' => $useSharedApp, 'client_id_override' => $clientIdOverride, 'client_secret_override_enc' => $clientSecretOverrideEnc, ]; $configState = $this->evaluateMicrosoftConfigState($stateForValidation); if ($enabled && !($configState['complete'] ?? false)) { $label = $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')); if ($label !== '') { $errors[] = $label; } } if ($enabled && $enforce && !($configState['complete'] ?? false)) { $errors[] = t('Microsoft-only login requires a complete configuration'); } if ($errors) { return ['ok' => false, 'errors' => $errors]; } $autoRememberMode = $this->normalizeAutoRememberMode($input['microsoft_auto_remember_mode'] ?? null); $saved = $this->tenantMicrosoftAuthRepository->upsertByTenantId($tenantId, [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enabled ? $enforce : false, 'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false, 'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null, 'entra_tenant_id' => $enabled ? $entraTenantId : null, 'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains, 'use_shared_app' => $useSharedApp, 'client_id_override' => $useSharedApp ? null : ($clientIdOverride !== '' ? $clientIdOverride : null), 'client_secret_override_enc' => $useSharedApp ? null : ($clientSecretOverrideEnc !== '' ? $clientSecretOverrideEnc : null), 'auto_remember_mode' => $autoRememberMode, ]); if (!$saved) { return ['ok' => false, 'errors' => [t('Tenant SSO settings could not be saved')]]; } return ['ok' => true]; } // Empty allowedDomains list means all email domains are permitted. public function isEmailDomainAllowed(string $email, array $allowedDomains): bool { $allowedDomains = array_values(array_filter(array_map( static fn ($domain): string => strtolower(trim((string) $domain)), $allowedDomains ), static fn (string $domain): bool => $domain !== '')); if (!$allowedDomains) { return true; } $parts = explode('@', strtolower(trim($email))); if (count($parts) !== 2) { return false; } return in_array($parts[1], $allowedDomains, true); } public function profileSyncFieldOptions(): array { return self::PROFILE_SYNC_FIELD_OPTIONS; } public function defaultProfileSyncFields(): array { return self::DEFAULT_PROFILE_SYNC_FIELDS; } public function normalizeProfileSyncFields($raw): array { if (is_string($raw)) { $raw = preg_split('/[\s,;]+/', $raw) ?: []; } elseif (!is_array($raw)) { return []; } $allowed = array_fill_keys(array_keys(self::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 profileSyncFieldsCsv(array $fields): string { $fields = $this->normalizeProfileSyncFields($fields); return implode(',', $fields); } public function profileSyncFieldsRequireGraph(array $fields): bool { $fields = $this->normalizeProfileSyncFields($fields); return in_array('phone', $fields, true) || in_array('mobile', $fields, true) || in_array('avatar', $fields, true); } public function shouldAutoRememberOnMicrosoftLogin(int $tenantId): bool { $auth = $this->getTenantMicrosoftAuth($tenantId); $mode = $auth['auto_remember_mode'] ?? null; if ($mode === 1) { return true; } if ($mode === 0) { return false; } return $this->settingsGateway->isMicrosoftAutoRememberDefault(); } private function normalizeAutoRememberMode(mixed $value): ?int { if ($value === null || $value === '' || $value === 'inherit') { return null; } $intVal = (int) $value; return in_array($intVal, [0, 1], true) ? $intVal : null; } private function normalizeDomains(string $raw): string { $parts = preg_split('/[,\n;\r]+/', $raw) ?: []; $domains = []; foreach ($parts as $part) { $domain = strtolower(trim($part)); $domain = preg_replace('/^@+/', '', $domain); if ($domain === '') { continue; } if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $domain)) { continue; } $domains[] = $domain; } $domains = array_values(array_unique($domains)); return implode(',', $domains); } private function normalizeMicrosoftInputState(array $input, array $existing): array { $enabled = array_key_exists('microsoft_enabled', $input) ? !empty($input['microsoft_enabled']) : !empty($existing['enabled']); $enforce = array_key_exists('enforce_microsoft_login', $input) ? !empty($input['enforce_microsoft_login']) : !empty($existing['enforce_microsoft_login']); $syncProfileOnLogin = array_key_exists('sync_profile_on_login', $input) ? !empty($input['sync_profile_on_login']) : !empty($existing['sync_profile_on_login']); $syncProfileFields = array_key_exists('sync_profile_fields', $input) ? $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []) : $this->normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { $syncProfileFields = $this->defaultProfileSyncFields(); } $entraTenantId = array_key_exists('entra_tenant_id', $input) ? strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))) : strtolower(trim((string) ($existing['entra_tenant_id'] ?? ''))); $useSharedApp = array_key_exists('use_shared_app', $input) ? !empty($input['use_shared_app']) : !empty($existing['use_shared_app']); $clientIdOverride = array_key_exists('client_id_override', $input) ? trim((string) ($input['client_id_override'] ?? '')) : trim((string) ($existing['client_id_override'] ?? '')); $clientSecretOverrideEnc = trim((string) ($existing['client_secret_override_enc'] ?? '')); if (array_key_exists('clear_client_secret_override', $input) && !empty($input['clear_client_secret_override'])) { $clientSecretOverrideEnc = ''; } elseif (trim((string) ($input['client_secret_override'] ?? '')) !== '') { // For UI status, a newly typed secret is considered present before persisting. $clientSecretOverrideEnc = '__pending_secret__'; } return [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enforce, 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $syncProfileFields, 'entra_tenant_id' => $entraTenantId, 'use_shared_app' => $useSharedApp, 'client_id_override' => $clientIdOverride, 'client_secret_override_enc' => $clientSecretOverrideEnc, ]; } private function evaluateMicrosoftConfigState(array $state): array { if (empty($state['enabled'])) { return ['complete' => true, 'error_code' => '']; } $entraTenantId = strtolower(trim((string) ($state['entra_tenant_id'] ?? ''))); if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { return ['complete' => false, 'error_code' => 'tenant_id_invalid']; } if (!empty($state['use_shared_app'])) { $sharedClientId = trim($this->settingsGateway->getMicrosoftSharedClientId()); $sharedClientSecret = trim($this->settingsGateway->getMicrosoftSharedClientSecret()); if ($sharedClientId === '' || $sharedClientSecret === '') { return ['complete' => false, 'error_code' => 'shared_credentials_missing']; } return ['complete' => true, 'error_code' => '']; } $clientIdOverride = trim((string) ($state['client_id_override'] ?? '')); if ($clientIdOverride === '') { return ['complete' => false, 'error_code' => 'client_id_override_required']; } $secretEnc = trim((string) ($state['client_secret_override_enc'] ?? '')); if ($secretEnc === '') { return ['complete' => false, 'error_code' => 'client_secret_override_required']; } if ($secretEnc !== '__pending_secret__') { try { $secret = trim($this->cryptoGateway->decryptString($secretEnc)); } catch (\Throwable $exception) { return ['complete' => false, 'error_code' => 'secret_invalid']; } if ($secret === '') { return ['complete' => false, 'error_code' => 'client_secret_override_required']; } } return ['complete' => true, 'error_code' => '']; } private function microsoftConfigErrorLabel(string $errorCode): string { return match ($errorCode) { 'tenant_id_invalid' => t('Entra tenant ID is invalid'), 'shared_credentials_missing' => t('Shared credentials missing'), 'client_id_override_required' => t('Client ID override is required'), 'client_secret_override_required' => t('Client secret override is required'), 'secret_invalid' => t('Client secret override is invalid'), 'client_credentials_missing' => t('Client credentials are missing'), default => '', }; } // ── 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)); if ($value === '') { return ''; } $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; return trim($value, '-'); } }