feat: add LDAP authentication as per-tenant login method

Add LDAP as a third authentication option alongside local password and
Microsoft Entra ID SSO. Per-tenant configuration supports Active Directory,
OpenLDAP, and FreeIPA with configurable attribute mapping, search-then-bind
and direct-bind methods, encrypted bind credentials (AES-256-GCM),
profile sync on login, and user auto-provisioning via external identity
linking.

Includes admin UI for connection settings with test-connection button,
login page LDAP form, 25 new PHPUnit tests, and de/en translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 07:26:04 +01:00
parent a0d816caaa
commit d1eeac6692
22 changed files with 2325 additions and 18 deletions

View File

@@ -678,6 +678,245 @@ $openOverrideCard = $detailsOpenAll;
</label>
</div>
</details>
<?php
// ── LDAP configuration section ────────────────────────────────
$ldapUiState = is_array($ldapUiState ?? null) ? $ldapUiState : [];
$ldapEnabled = !empty($values['enabled'] ?? false) ? false : (!empty($values['ldap_enabled'] ?? $ldapConfig['enabled'] ?? false));
if (isset($values['ldap_enabled'])) {
$ldapEnabled = in_array(strtolower(trim((string) $values['ldap_enabled'])), ['1', 'true', 'yes', 'on'], true);
} elseif (isset($ldapConfig['enabled'])) {
$ldapEnabled = !empty($ldapConfig['enabled']);
}
$ldapEnforce = !empty($values['enforce_ldap_login'] ?? $ldapConfig['enforce_ldap_login'] ?? false);
$ldapHost = trim((string) ($values['ldap_host'] ?? $values['host'] ?? $ldapConfig['host'] ?? ''));
$ldapPort = (int) ($values['ldap_port'] ?? $values['port'] ?? $ldapConfig['port'] ?? 389);
$ldapEncryptionMode = (string) ($values['ldap_encryption_mode'] ?? $values['encryption_mode'] ?? $ldapConfig['encryption_mode'] ?? 'starttls');
$ldapVerifyTls = !array_key_exists('verify_tls_certificate', ($ldapConfig ?? [])) || !empty($values['ldap_verify_tls_certificate'] ?? $ldapConfig['verify_tls_certificate'] ?? true);
$ldapBaseDn = trim((string) ($values['ldap_base_dn'] ?? $values['base_dn'] ?? $ldapConfig['base_dn'] ?? ''));
$ldapUserSearchFilter = trim((string) ($values['ldap_user_search_filter'] ?? $values['user_search_filter'] ?? $ldapConfig['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))'));
$ldapUserSearchScope = (string) ($values['ldap_user_search_scope'] ?? $values['user_search_scope'] ?? $ldapConfig['user_search_scope'] ?? 'sub');
$ldapBindMethod = (string) ($values['ldap_bind_method'] ?? $values['bind_method'] ?? $ldapConfig['bind_method'] ?? 'search_then_bind');
$ldapBindUsernameFormat = trim((string) ($values['ldap_bind_username_format'] ?? $values['bind_username_format'] ?? $ldapConfig['bind_username_format'] ?? ''));
$ldapUniqueIdAttribute = trim((string) ($values['ldap_unique_id_attribute'] ?? $values['unique_id_attribute'] ?? $ldapConfig['unique_id_attribute'] ?? 'objectGUID'));
$ldapAttributeMap = $ldapConfig['attribute_map'] ?? [];
if (is_string($ldapAttributeMap)) {
$ldapAttributeMap = json_decode($ldapAttributeMap, true) ?: [];
}
$ldapSyncProfileOnLogin = !empty($values['ldap_sync_profile_on_login'] ?? $ldapConfig['sync_profile_on_login'] ?? false);
$ldapSyncFieldOptions = $tenantSsoService->ldapProfileSyncFieldOptions();
$ldapSyncProfileFields = $tenantSsoService->normalizeLdapProfileSyncFields($values['ldap_sync_profile_fields'] ?? $ldapConfig['sync_profile_fields_list'] ?? []);
$ldapAllowedDomains = trim((string) ($values['ldap_allowed_domains'] ?? $ldapConfig['allowed_domains'] ?? ''));
$ldapNetworkTimeout = (int) ($values['ldap_network_timeout'] ?? $ldapConfig['network_timeout'] ?? 5);
$ldapConfigComplete = !empty($ldapUiState['config_complete']);
$ldapConfigErrorLabel = trim((string) ($ldapUiState['config_error_label'] ?? ''));
$openLdapSetupCard = $detailsOpenAll || !$ldapEnabled || ($ldapEnabled && !$ldapConfigComplete);
?>
<hr>
<h4><?php e(t('LDAP Authentication')); ?></h4>
<div class="app-form-info-tiles">
<?php
$infoTileLabel = t('LDAP login');
$infoTileValue = $ldapEnabled ? t('Active') : t('Inactive');
$infoTileTone = $ldapEnabled ? 'success' : 'neutral';
require templatePath('partials/app-form-info-tile.phtml');
$infoTileLabel = t('Configuration complete');
$infoTileValue = $ldapConfigComplete ? t('Yes') : t('No');
$infoTileTone = $ldapConfigComplete ? 'success' : 'warning';
require templatePath('partials/app-form-info-tile.phtml');
?>
</div>
<?php if ($ldapEnabled && !$ldapConfigComplete && $ldapConfigErrorLabel !== ''): ?>
<blockquote data-variant="warning">
<strong><?php e(t('LDAP setup is incomplete')); ?></strong><br>
<?php e($ldapConfigErrorLabel); ?>
</blockquote>
<?php endif; ?>
<details class="app-details-card" name="tenant-ldap-setup" <?php e($openLdapSetupCard ? 'open' : ''); ?>>
<summary>
<span class="app-details-card-summary-title"><?php e(t('LDAP connection settings')); ?></span>
<span class="app-details-card-summary-meta">
<span class="badge" data-variant="<?php e($ldapEnabled ? 'success' : 'neutral'); ?>">
<?php e(t('LDAP login')); ?>: <?php e($ldapEnabled ? t('Active') : t('Inactive')); ?>
</span>
</span>
</summary>
<div class="app-details-card-container">
<label class="app-field">
<input type="checkbox" role="switch" name="ldap_enabled" value="1"
<?php e($ldapEnabled ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
<span><?php e(t('Enable LDAP login for this tenant')); ?></span>
</label>
<hr>
<div class="grid grid-1-1">
<label>
<span><?php e(t('LDAP Host')); ?></span>
<input type="text" name="ldap_host" value="<?php e($ldapHost); ?>"
placeholder="ldap.example.com" <?php e($readonlyAttr); ?>>
</label>
<label>
<span><?php e(t('Port')); ?></span>
<input type="number" name="ldap_port" value="<?php e((string) $ldapPort); ?>"
min="1" max="65535" <?php e($readonlyAttr); ?>>
</label>
</div>
<div class="grid grid-1-1">
<label>
<span><?php e(t('Encryption')); ?></span>
<select name="ldap_encryption_mode" <?php e($disabledAttr); ?>>
<option value="starttls" <?php e($ldapEncryptionMode === 'starttls' ? 'selected' : ''); ?>>STARTTLS</option>
<option value="ldaps" <?php e($ldapEncryptionMode === 'ldaps' ? 'selected' : ''); ?>>LDAPS (SSL)</option>
<option value="none" <?php e($ldapEncryptionMode === 'none' ? 'selected' : ''); ?>><?php e(t('None (not recommended)')); ?></option>
</select>
</label>
<label class="app-field">
<input type="checkbox" name="ldap_verify_tls_certificate" value="1"
<?php e($ldapVerifyTls ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
<span><?php e(t('Verify TLS certificate')); ?></span>
</label>
</div>
<label>
<span><?php e(t('Base DN')); ?></span>
<input type="text" name="ldap_base_dn" value="<?php e($ldapBaseDn); ?>"
placeholder="DC=corp,DC=example,DC=com" <?php e($readonlyAttr); ?>>
</label>
<hr>
<label>
<span><?php e(t('Bind DN (service account)')); ?></span>
<input type="text" name="ldap_bind_dn" value="" autocomplete="off"
placeholder="CN=svc-ldap,OU=Service Accounts,DC=corp,DC=example,DC=com" <?php e($readonlyAttr); ?>>
<small class="muted"><?php e(t('Write-only. Leave empty to keep current value.')); ?></small>
</label>
<label>
<span><?php e(t('Bind password')); ?></span>
<input type="password" name="ldap_bind_password" value="" autocomplete="new-password" <?php e($readonlyAttr); ?>>
<small class="muted"><?php e(t('Write-only. Leave empty to keep current value.')); ?></small>
</label>
<label class="app-field">
<input type="checkbox" name="clear_ldap_bind_password" value="1" <?php e($disabledAttr); ?>>
<span><?php e(t('Clear bind password')); ?></span>
</label>
<hr>
<p>
<button type="button" class="secondary outline small" id="ldap-test-connection-button"
data-ldap-test-url="<?php e(lurl('admin/tenants/ldap-test-connection')); ?>"
data-tenant-id="<?php e((string) $tenantId); ?>">
<i class="bi bi-plug" aria-hidden="true"></i> <?php e(t('Test connection')); ?>
</button>
<span id="ldap-test-connection-result" aria-live="polite"></span>
</p>
</div>
</details>
<details class="app-details-card" name="tenant-ldap-search" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Search and bind settings')); ?></span>
</summary>
<div class="app-details-card-container">
<label>
<span><?php e(t('Bind method')); ?></span>
<select name="ldap_bind_method" <?php e($disabledAttr); ?>>
<option value="search_then_bind" <?php e($ldapBindMethod === 'search_then_bind' ? 'selected' : ''); ?>><?php e(t('Search then bind (recommended)')); ?></option>
<option value="direct_bind" <?php e($ldapBindMethod === 'direct_bind' ? 'selected' : ''); ?>><?php e(t('Direct bind')); ?></option>
</select>
<small class="muted"><?php e(t('Search-then-bind uses a service account to find the user DN first, then binds with the user credentials.')); ?></small>
</label>
<label>
<span><?php e(t('User search filter')); ?></span>
<input type="text" name="ldap_user_search_filter" value="<?php e($ldapUserSearchFilter); ?>"
placeholder="(&amp;(objectClass=user)(sAMAccountName=%s))" <?php e($readonlyAttr); ?>>
<small class="muted"><?php e(t('Use %s as placeholder for the username. Examples: (&(objectClass=user)(sAMAccountName=%s)) for AD, (&(objectClass=inetOrgPerson)(uid=%s)) for OpenLDAP')); ?></small>
</label>
<div class="grid grid-1-1">
<label>
<span><?php e(t('Search scope')); ?></span>
<select name="ldap_user_search_scope" <?php e($disabledAttr); ?>>
<option value="sub" <?php e($ldapUserSearchScope === 'sub' ? 'selected' : ''); ?>><?php e(t('Subtree (recursive)')); ?></option>
<option value="one" <?php e($ldapUserSearchScope === 'one' ? 'selected' : ''); ?>><?php e(t('One level')); ?></option>
</select>
</label>
<label>
<span><?php e(t('Network timeout (seconds)')); ?></span>
<input type="number" name="ldap_network_timeout" value="<?php e((string) $ldapNetworkTimeout); ?>"
min="1" max="30" <?php e($readonlyAttr); ?>>
</label>
</div>
<label>
<span><?php e(t('Direct bind username format')); ?></span>
<input type="text" name="ldap_bind_username_format" value="<?php e($ldapBindUsernameFormat); ?>"
placeholder="%s@corp.example.com" <?php e($readonlyAttr); ?>>
<small class="muted"><?php e(t('Only for direct bind. Use %s as placeholder. Example: %s@corp.example.com or uid=%s,ou=People,dc=example,dc=com')); ?></small>
</label>
<label>
<span><?php e(t('Unique ID attribute')); ?></span>
<input type="text" name="ldap_unique_id_attribute" value="<?php e($ldapUniqueIdAttribute); ?>"
placeholder="objectGUID" <?php e($readonlyAttr); ?>>
<small class="muted"><?php e(t('AD: objectGUID, OpenLDAP: entryUUID, FreeIPA: ipaUniqueID')); ?></small>
</label>
</div>
</details>
<details class="app-details-card" name="tenant-ldap-mapping" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Attribute mapping and sync')); ?></span>
</summary>
<div class="app-details-card-container">
<small class="muted"><?php e(t('Map application fields to LDAP directory attributes.')); ?></small>
<div class="grid grid-1-1">
<?php
$defaultMap = [
'first_name' => 'givenName',
'last_name' => 'sn',
'email' => 'mail',
'phone' => 'telephoneNumber',
'mobile' => 'mobile',
];
foreach ($defaultMap as $appField => $defaultLdapAttr):
$currentLdapAttr = $ldapAttributeMap[$appField] ?? $defaultLdapAttr;
?>
<label>
<span><?php e(t(ucfirst(str_replace('_', ' ', $appField)))); ?> &rarr;</span>
<input type="text" name="ldap_attribute_map[<?php e($appField); ?>]"
value="<?php e($currentLdapAttr); ?>" <?php e($readonlyAttr); ?>>
</label>
<?php endforeach; ?>
</div>
<hr>
<label class="app-field">
<input type="checkbox" role="switch" name="ldap_sync_profile_on_login" value="1"
<?php e($ldapSyncProfileOnLogin ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
<span><?php e(t('Profile sync on LDAP login')); ?></span>
</label>
<small class="muted"><?php e(t('User profile fields are global and affect all tenants of this user')); ?></small>
<div class="grid grid-1-1">
<?php foreach ($ldapSyncFieldOptions as $syncFieldKey => $syncFieldLabel): ?>
<label class="app-field">
<input type="checkbox" name="ldap_sync_profile_fields[]"
value="<?php e($syncFieldKey); ?>"
<?php e(in_array($syncFieldKey, $ldapSyncProfileFields, true) ? 'checked' : ''); ?>
<?php e($disabledAttr); ?>>
<span><?php e(t($syncFieldLabel)); ?></span>
</label>
<?php endforeach; ?>
</div>
<hr>
<label class="app-field">
<input type="checkbox" role="switch" name="enforce_ldap_login" value="1"
<?php e($ldapEnforce ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
<span><?php e(t('Allow LDAP-only login (disable local password login)')); ?></span>
</label>
<small class="muted"><?php e(t('LDAP-only login requires a complete configuration')); ?></small>
<hr>
<label>
<span><?php e(t('Allowed email domains')); ?></span>
<textarea name="ldap_allowed_domains" rows="2" placeholder="example.com" <?php e($readonlyAttr); ?>><?php e($ldapAllowedDomains); ?></textarea>
<small><?php e(t('Optional allow-list, comma or newline separated.')); ?></small>
</label>
</div>
</details>
</div>
<?php endif; ?>

View File

@@ -67,11 +67,13 @@ $customFieldDefinitions = $canManageCustomFields
: [];
$ssoConfig = $canManageSso ? $tenantSsoService->getTenantMicrosoftAuth($tenantId) : [];
$ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState($tenantId, $ssoConfig) : [];
$ldapConfig = $canManageSso ? $tenantSsoService->getTenantLdapAuth($tenantId) : [];
$ldapUiState = $canManageSso ? $tenantSsoService->buildLdapUiState($tenantId) : [];
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']);
$errorBag = formErrors();
$errors = [];
$form = array_merge($tenant, $ssoConfig);
$form = array_merge($tenant, $ssoConfig, $ldapConfig);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
@@ -119,6 +121,10 @@ if ($request->isMethod('POST')) {
if (!($ssoSaveResult['ok'] ?? false)) {
$errorBag->merge($ssoSaveResult['errors'] ?? [t('Tenant SSO settings could not be saved')]);
}
$ldapSaveResult = $tenantSsoService->saveTenantLdapAuth($tenantId, $post);
if (!($ldapSaveResult['ok'] ?? false)) {
$errorBag->merge($ldapSaveResult['errors'] ?? [t('Tenant LDAP settings could not be saved')]);
}
}
}
@@ -149,12 +155,30 @@ if ($request->isMethod('POST')) {
'auto_remember_mode' => $post['microsoft_auto_remember_mode'] ?? null,
]);
$ssoUiState = $tenantSsoService->buildMicrosoftUiState($tenantId, $post);
$ldapUiState = $tenantSsoService->buildLdapUiState($tenantId, $post);
$form = array_merge($form, [
'ldap_enabled' => !empty($post['ldap_enabled']) ? '1' : '0',
'enforce_ldap_login' => !empty($post['enforce_ldap_login']) ? '1' : '0',
'ldap_host' => trim((string) ($post['ldap_host'] ?? '')),
'ldap_port' => (int) ($post['ldap_port'] ?? 389),
'ldap_encryption_mode' => (string) ($post['ldap_encryption_mode'] ?? 'starttls'),
'ldap_verify_tls_certificate' => !empty($post['ldap_verify_tls_certificate']) ? '1' : '0',
'ldap_base_dn' => trim((string) ($post['ldap_base_dn'] ?? '')),
'ldap_user_search_filter' => trim((string) ($post['ldap_user_search_filter'] ?? '')),
'ldap_bind_method' => (string) ($post['ldap_bind_method'] ?? 'search_then_bind'),
'ldap_bind_username_format' => trim((string) ($post['ldap_bind_username_format'] ?? '')),
'ldap_unique_id_attribute' => trim((string) ($post['ldap_unique_id_attribute'] ?? 'objectGUID')),
'ldap_sync_profile_on_login' => !empty($post['ldap_sync_profile_on_login']) ? '1' : '0',
]);
}
}
if ($canManageSso && empty($ssoUiState)) {
$ssoUiState = $tenantSsoService->buildMicrosoftUiState($tenantId, $form);
}
if ($canManageSso && empty($ldapUiState)) {
$ldapUiState = $tenantSsoService->buildLdapUiState($tenantId);
}
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();

View File

@@ -0,0 +1,75 @@
<?php
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
header('Content-Type: application/json; charset=utf-8');
if (requestInput()->method() !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
return;
}
if (!Session::checkCsrfToken()) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'CSRF token invalid']);
return;
}
$session = app(\MintyPHP\Http\SessionStoreInterface::class)->all();
$currentUserId = (int) ($session['user']['id'] ?? 0);
$decision = app(\MintyPHP\Service\Access\AuthorizationService::class)->authorize(
TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT,
[
'actor_user_id' => $currentUserId,
'target_tenant_id' => (int) (requestInput()->bodyAll()['tenant_id'] ?? 0),
]
);
if (!$decision->isAllowed()) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Forbidden']);
return;
}
$capabilities = $decision->attribute('capabilities', []);
if (!is_array($capabilities) || empty($capabilities['can_manage_sso'])) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Forbidden']);
return;
}
$post = requestInput()->bodyAll();
$host = trim((string) ($post['ldap_host'] ?? ''));
$port = (int) ($post['ldap_port'] ?? 389);
$encryptionMode = (string) ($post['ldap_encryption_mode'] ?? 'starttls');
$verifyTls = !empty($post['ldap_verify_tls_certificate']);
$baseDn = trim((string) ($post['ldap_base_dn'] ?? ''));
$bindDn = trim((string) ($post['ldap_bind_dn'] ?? ''));
$bindPassword = trim((string) ($post['ldap_bind_password'] ?? ''));
$networkTimeout = max(1, min(10, (int) ($post['ldap_network_timeout'] ?? 5)));
if ($host === '') {
echo json_encode(['ok' => false, 'error' => t('LDAP host is required')]);
return;
}
$ldapAuthService = app(\MintyPHP\Service\Auth\LdapAuthService::class);
$config = [
'host' => $host,
'port' => $port,
'encryption_mode' => $encryptionMode,
'verify_tls_certificate' => $verifyTls,
'base_dn' => $baseDn,
'bind_dn' => $bindDn,
'bind_password' => $bindPassword,
'network_timeout' => $networkTimeout,
];
$result = $ldapAuthService->testConnection($config);
echo json_encode($result);

View File

@@ -47,7 +47,7 @@ $tenantCandidates = [];
$tenantCandidateById = [];
$selectedTenantId = 0;
$selectedTenant = null;
$selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => ''];
$selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => '', 'ldap' => false, 'ldap_reason' => ''];
$errorBag = formErrors();
$setLoginWarning = static function (string $message) use ($errorBag): void {
$errorBag->addGlobal($message);
@@ -209,7 +209,7 @@ if (requestInput()->method() === 'POST') {
$tenantCandidateById = $resolved['candidate_by_id'];
$postedTenantId = (int) (requestInput()->bodyAll()['tenant_id'] ?? 0);
if (in_array($postedStage, ['choose_tenant', 'login_password'], true)
if (in_array($postedStage, ['choose_tenant', 'login_password', 'login_ldap'], true)
&& ($postedTenantId <= 0 || !isset($tenantCandidateById[$postedTenantId]))) {
$stage = 'choose_tenant';
$setLoginWarning(t('We could not continue with these login details'));
@@ -237,6 +237,7 @@ if (requestInput()->method() === 'POST') {
$selectedTenantSlug = trim((string) $selectedTenant['slug']);
$isMicrosoftOnlyTenant = empty($selectedTenantMethods['local'])
&& !empty($selectedTenantMethods['microsoft'])
&& empty($selectedTenantMethods['ldap'])
&& $selectedTenantSlug !== '';
if (
@@ -260,6 +261,96 @@ if (requestInput()->method() === 'POST') {
$stage = count($tenantCandidates) === 1 ? 'methods' : 'choose_tenant';
} elseif ($postedStage === 'choose_tenant') {
$stage = 'methods';
} elseif ($postedStage === 'login_ldap') {
$stage = 'methods';
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;
$passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey);
if (!($passwordLimitResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60));
return;
}
if (empty($selectedTenantMethods['ldap'])) {
$setLoginWarning(t('LDAP login is not available for this tenant.'));
} else {
$ldapUsername = trim((string) (requestInput()->bodyAll()['ldap_username'] ?? ''));
$ldapPassword = (string) (requestInput()->bodyAll()['password'] ?? '');
$remember = !empty(requestInput()->bodyAll()['remember']);
$ldapAuthService = $authServicesFactory->createLdapAuthService();
$ssoUserLinkService = $authServicesFactory->createSsoUserLinkService();
$tenantRecord = app(\MintyPHP\Service\Auth\AuthGatewayFactory::class)
->createAuthTenantGateway()
->findById($selectedTenantId);
if (!$tenantRecord) {
$setLoginWarning(t('We could not continue with these login details'));
} else {
$ldapConfigResult = $tenantSsoService->getEffectiveLdapProviderConfig($tenantRecord);
if (!($ldapConfigResult['ok'] ?? false)) {
$setLoginWarning(t('LDAP login is not available for this tenant.'));
} else {
$ldapConfig = $ldapConfigResult['config'];
$allowedDomains = trim((string) ($ldapConfig['allowed_domains'] ?? ''));
if ($allowedDomains !== '' && $email !== '') {
$domainList = array_filter(array_map('trim', explode(',', $allowedDomains)));
if (!$tenantSsoService->isEmailDomainAllowed($email, $domainList)) {
$setLoginWarning(t('Your email domain is not allowed for LDAP login on this tenant.'));
return;
}
}
$ldapResult = $ldapAuthService->authenticate($ldapConfig, $ldapUsername !== '' ? $ldapUsername : $email, $ldapPassword);
if (!($ldapResult['ok'] ?? false)) {
$ldapError = $ldapResult['error'] ?? 'unknown';
if ($ldapError === 'credentials_empty') {
$setLoginWarning(t('Please enter your username and password.'));
} elseif ($ldapError === 'connection_failed') {
$setLoginWarning(t('LDAP server is not reachable. Please try again later.'));
} else {
$setLoginWarning(t('Username or password not valid'));
}
$passwordFailureResult = $rateLimiterService->registerFailure(
$loginPasswordScope,
$passwordAttemptKey,
$loginPasswordLimit,
$loginPasswordWindow,
$loginPasswordBlock
);
if (!($passwordFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
}
} else {
$linkResult = $ssoUserLinkService->linkOrProvisionLdapUser($tenantRecord, $ldapResult, $ldapConfig);
if (!($linkResult['ok'] ?? false)) {
$linkError = $linkResult['error'] ?? 'unknown';
if ($linkError === 'user_inactive') {
$setLoginWarning(t('Your account is deactivated.'));
} elseif ($linkError === 'email_missing') {
$setLoginWarning(t('Your LDAP account does not have an email address configured.'));
} else {
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
}
} else {
$ldapUserId = (int) ($linkResult['user_id'] ?? 0);
$loginResult = $authService->loginUserById($ldapUserId, $selectedTenantId, 'ldap');
if (!($loginResult['ok'] ?? false)) {
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
} else {
$rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey);
if ($remember || $tenantSsoService->shouldAutoRememberOnLdapLogin($selectedTenantId)) {
$rememberMeService->rememberUser($ldapUserId);
}
$sessionStore->set('session_started_at', time());
$sessionStore->set('session_last_activity', time());
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
return;
}
}
}
}
}
}
} elseif ($postedStage === 'login_password') {
$stage = 'methods';
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;

View File

@@ -22,7 +22,7 @@ $tenantHint = trim((string) ($tenantHint ?? ''));
$tenantCandidates = is_array($tenantCandidates ?? null) ? $tenantCandidates : [];
$selectedTenantId = (int) ($selectedTenantId ?? 0);
$selectedTenant = is_array($selectedTenant ?? null) ? $selectedTenant : null;
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false];
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false, 'ldap' => false];
$selectedTenantSlug = (string) ($selectedTenant['slug'] ?? '');
$selectedTenantLabel = trim((string) ($selectedTenant['description'] ?? ''));
if ($selectedTenantLabel === '') {
@@ -183,7 +183,46 @@ $returnToSanitized = (string) ($returnToSanitized ?? '');
</a>
<?php endif; ?>
<?php if (!empty($selectedTenantMethods['local']) && !empty($selectedTenantMethods['microsoft'])): ?>
<?php if (!empty($selectedTenantMethods['ldap'])): ?>
<?php
$hasMethodAbove = !empty($selectedTenantMethods['microsoft']);
?>
<?php if ($hasMethodAbove): ?>
<div class="login-alt-separator" aria-hidden="true">
<span><?php e(t('Or')); ?></span>
</div>
<?php endif; ?>
<form method="post" class="login-ldap-form" data-login-submit-lock="1">
<input type="hidden" name="stage" value="login_ldap">
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="ldap_username">
<span><?php e(t('LDAP Username')); ?></span>
<input required type="text" name="ldap_username" id="ldap_username" autocomplete="username" />
</label>
<label for="ldap_password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="ldap_password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<label class="app-field inline">
<input type="checkbox" name="remember" value="1">
<span><?php e(t('Remember me')); ?></span>
</label>
</p>
<p>
<button type="submit"><i class="bi bi-shield-lock" aria-hidden="true"></i> <?php e(t('Sign in with LDAP')); ?></button>
</p>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
<?php
$hasMethodAboveLocal = !empty($selectedTenantMethods['microsoft']) || !empty($selectedTenantMethods['ldap']);
?>
<?php if (!empty($selectedTenantMethods['local']) && $hasMethodAboveLocal): ?>
<div class="login-alt-separator" aria-hidden="true">
<span><?php e(t('Or')); ?></span>
</div>
@@ -214,7 +253,7 @@ $returnToSanitized = (string) ($returnToSanitized ?? '');
<?php endif; ?>
</div>
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft'])): ?>
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft']) && empty($selectedTenantMethods['ldap'])): ?>
<div class="notice" data-variant="warning" role="status" aria-live="polite">
<?php e(t('No login method is available for this tenant')); ?>
</div>