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;