1
0

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

@@ -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>