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

@@ -804,6 +804,154 @@ class SsoUserLinkServiceTest extends TestCase
$this->assertTrue($result['ok']);
}
// ── LDAP user link tests ──────────────────────────────────────────
public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyHost(): void
{
$service = $this->newService();
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
['host' => '']
);
$this->assertFalse($result['ok']);
$this->assertSame('identity_invalid', $result['error']);
}
public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyOid(): void
{
$service = $this->newService();
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
['unique_id' => '', 'dn' => '', 'attributes' => []],
['host' => 'ldap.example.com']
);
$this->assertFalse($result['ok']);
$this->assertSame('identity_invalid', $result['error']);
}
public function testLinkOrProvisionLdapUserLinksExistingIdentity(): void
{
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
$identityGateway->method('findByProviderTidOid')
->willReturn(['user_id' => 42]);
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepo->method('find')
->willReturn(['id' => 42, 'active' => 1]);
$userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
$userTenantRepo->method('listTenantIdsByUserId')
->willReturn([1]);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
$tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn([]);
$tenantSsoService->method('defaultProfileSyncFields')->willReturn([]);
$service = $this->newService($userReadRepo, null, null, $userTenantRepo, null, $tenantSsoService, null, $identityGateway);
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => 'test@example.com']],
['host' => 'ldap.example.com', 'sync_profile_on_login' => false]
);
$this->assertTrue($result['ok']);
$this->assertSame(42, $result['user_id']);
$this->assertFalse($result['created']);
}
public function testLinkOrProvisionLdapUserReturnsInactiveForDisabledUser(): void
{
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
$identityGateway->method('findByProviderTidOid')
->willReturn(['user_id' => 42]);
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepo->method('find')
->willReturn(['id' => 42, 'active' => 0]);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
$service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
['host' => 'ldap.example.com']
);
$this->assertFalse($result['ok']);
$this->assertSame('user_inactive', $result['error']);
}
public function testLinkOrProvisionLdapUserProvisionsNewUser(): void
{
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
$identityGateway->method('findByProviderTidOid')->willReturn(null);
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
$userReadRepo->method('findByEmail')->willReturn(null);
$userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
$userWriteRepo->method('create')->willReturn(99);
$userWriteRepo->method('setEmailVerified')->willReturn(true);
$userWriteRepo->method('updateProfileFieldsFromSso')->willReturn(true);
$userAssignment = $this->createMock(UserAssignmentService::class);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getDefaultRoleId')->willReturn(0);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
$settingsGateway->method('getAppTheme')->willReturn('light');
$settingsGateway->method('normalizeTheme')->willReturn('light');
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
$tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn(['first_name', 'last_name']);
$tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);
$service = $this->newService($userReadRepo, $userWriteRepo, $userAssignment, null, $settingsGateway, $tenantSsoService, null, $identityGateway);
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
[
'unique_id' => 'guid-new',
'dn' => 'CN=New User,DC=example,DC=com',
'attributes' => [
'first_name' => 'New',
'last_name' => 'User',
'email' => 'new@example.com',
],
],
['host' => 'ldap.example.com', 'sync_profile_on_login' => true, 'sync_profile_fields' => ['first_name', 'last_name']]
);
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['user_id']);
$this->assertTrue($result['created']);
}
public function testLinkOrProvisionLdapUserReturnsEmailMissingWhenNoEmail(): void
{
$identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
$identityGateway->method('findByProviderTidOid')->willReturn(null);
$userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
$tenantSsoService = $this->createMock(TenantSsoService::class);
$tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
$service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
$result = $service->linkOrProvisionLdapUser(
['id' => 1],
['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => '']],
['host' => 'ldap.example.com']
);
$this->assertFalse($result['ok']);
$this->assertSame('email_missing', $result['error']);
}
// ---------------------------------------------------------------
// Factory helper
// ---------------------------------------------------------------