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

@@ -2,6 +2,7 @@
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
use MintyPHP\Service\Auth\AuthCryptoGateway;
use MintyPHP\Service\Auth\AuthSettingsGateway;
@@ -458,13 +459,139 @@ class TenantSsoServiceTest extends TestCase
$this->assertTrue($result['ok']);
}
// ── LDAP tests ────────────────────────────────────────────────────
public function testGetTenantLdapAuthReturnsDefaults(): void
{
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepository->method('findByTenantId')->willReturn(null);
$service = $this->newService(null, null, null, null, $ldapRepository);
$result = $service->getTenantLdapAuth(1);
$this->assertFalse($result['enabled']);
$this->assertFalse($result['enforce_ldap_login']);
$this->assertSame('', $result['host']);
$this->assertSame(389, $result['port']);
$this->assertSame('starttls', $result['encryption_mode']);
}
public function testGetEffectiveLdapProviderConfigReturnsDisabledWhenNotEnabled(): void
{
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepository->method('findByTenantId')->willReturn(['enabled' => 0]);
$service = $this->newService(null, null, null, null, $ldapRepository);
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('ldap_disabled', $result['error']);
}
public function testGetEffectiveLdapProviderConfigReturnsHostMissing(): void
{
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepository->method('findByTenantId')->willReturn([
'enabled' => 1,
'host' => '',
'base_dn' => 'DC=example,DC=com',
]);
$service = $this->newService(null, null, null, null, $ldapRepository);
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('ldap_host_missing', $result['error']);
}
public function testGetEffectiveLdapProviderConfigReturnsBaseDnMissing(): void
{
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepository->method('findByTenantId')->willReturn([
'enabled' => 1,
'host' => 'ldap.example.com',
'base_dn' => '',
]);
$service = $this->newService(null, null, null, null, $ldapRepository);
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
$this->assertFalse($result['ok']);
$this->assertSame('ldap_base_dn_missing', $result['error']);
}
public function testGetEffectiveLdapProviderConfigSuccessNoBindCredentials(): void
{
$ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepository->method('findByTenantId')->willReturn([
'enabled' => 1,
'host' => 'ldap.example.com',
'port' => 389,
'encryption_mode' => 'starttls',
'base_dn' => 'DC=example,DC=com',
'bind_dn_enc' => '',
'bind_password_enc' => '',
'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))',
]);
$service = $this->newService(null, null, null, null, $ldapRepository);
$result = $service->getEffectiveLdapProviderConfig(['id' => 5]);
$this->assertTrue($result['ok']);
$this->assertSame('ldap', $result['config']['provider']);
$this->assertSame('ldap.example.com', $result['config']['host']);
}
public function testResolveTenantLoginMethodsIncludesLdap(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$microsoftRepo->method('findByTenantId')->willReturn(['enabled' => 0]);
$ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class);
$ldapRepo->method('findByTenantId')->willReturn([
'enabled' => 1,
'host' => 'ldap.example.com',
'base_dn' => 'DC=example,DC=com',
'bind_dn_enc' => '',
'bind_password_enc' => '',
]);
$service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo);
$result = $service->resolveTenantLoginMethods(5);
$this->assertTrue($result['local']);
$this->assertFalse($result['microsoft']);
$this->assertTrue($result['ldap']);
}
public function testNormalizeLdapAttributeMapParsesJson(): void
{
$service = $this->newService();
$map = $service->normalizeLdapAttributeMap('{"first_name":"givenName","email":"mail"}');
$this->assertSame('givenName', $map['first_name']);
$this->assertSame('mail', $map['email']);
}
public function testNormalizeLdapAttributeMapReturnsDefaultForInvalidJson(): void
{
$service = $this->newService();
$map = $service->normalizeLdapAttributeMap('not json');
$this->assertArrayHasKey('first_name', $map);
$this->assertSame('givenName', $map['first_name']);
}
private function newService(
?TenantMicrosoftAuthRepositoryInterface $repository = null,
?AuthTenantGateway $tenantGateway = null,
?AuthSettingsGateway $settingsGateway = null,
?AuthCryptoGateway $cryptoGateway = null
?AuthCryptoGateway $cryptoGateway = null,
?TenantLdapAuthRepositoryInterface $ldapRepository = null
): TenantSsoService {
$repository = $repository ?? $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$ldapRepository = $ldapRepository ?? $this->createMock(TenantLdapAuthRepositoryInterface::class);
$tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
if ($settingsGateway === null) {
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
@@ -482,6 +609,7 @@ class TenantSsoServiceTest extends TestCase
return new TenantSsoService(
$tenantGateway,
$repository,
$ldapRepository,
$settingsGateway,
$cryptoGateway
);