Files
breadcrumb-the-shire/lib/Service/Auth/TenantSsoService.php
fs 4e359fe659 feat(auth): sync display name and job title from Microsoft Graph, fix tenant SSO form handling
Fetches displayName/jobTitle from Graph API and syncs them via SSO profile sync. Also fixes LDAP enabled state in tenant form, adds LDAP save on tenant create, and hardens LDAP port validation precedence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:13:21 +02:00

980 lines
40 KiB
PHP

<?php
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',
'last_name' => 'Sync last name',
'display_name' => 'Sync display name',
'job_title' => 'Sync job title',
'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<array{id: int, methods: array}> 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)
|| in_array('job_title', $fields, true)
|| in_array('display_name', $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);
try {
$attributeMapJson = json_encode($attributeMap, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return ['ok' => false, 'errors' => [t('LDAP attribute map could not be encoded')]];
}
$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, '-');
}
}