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:
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user