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