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:
341
tests/Service/Auth/LdapAuthServiceTest.php
Normal file
341
tests/Service/Auth/LdapAuthServiceTest.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Auth\LdapAuthService;
|
||||
use MintyPHP\Service\Auth\LdapConnectionGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LdapAuthServiceTest extends TestCase
|
||||
{
|
||||
private function newService(?LdapConnectionGateway $ldap = null): LdapAuthService
|
||||
{
|
||||
return new LdapAuthService(
|
||||
$ldap ?? $this->createMock(LdapConnectionGateway::class)
|
||||
);
|
||||
}
|
||||
|
||||
private function baseConfig(): array
|
||||
{
|
||||
return [
|
||||
'host' => 'ldap.example.com',
|
||||
'port' => 389,
|
||||
'encryption_mode' => 'none',
|
||||
'verify_tls_certificate' => false,
|
||||
'base_dn' => 'DC=example,DC=com',
|
||||
'bind_dn' => 'CN=svc,DC=example,DC=com',
|
||||
'bind_password' => 'secret',
|
||||
'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
'user_search_scope' => 'sub',
|
||||
'bind_method' => 'search_then_bind',
|
||||
'bind_username_format' => '',
|
||||
'unique_id_attribute' => 'objectGUID',
|
||||
'attribute_map' => '{"first_name":"givenName","last_name":"sn","email":"mail"}',
|
||||
'network_timeout' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsCredentialsEmptyForEmptyUsername(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($this->baseConfig(), '', 'password');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('credentials_empty', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsCredentialsEmptyForEmptyPassword(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', '');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('credentials_empty', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsConnectionFailedWhenConnectFails(): void
|
||||
{
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn(false);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateReturnsConnectionFailedForEmptyHost(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['host'] = '';
|
||||
|
||||
$service = $this->newService();
|
||||
$result = $service->authenticate($config, 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindServiceBindFails(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('service_bind_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindUserNotFound(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('search')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindInvalidPassword(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Test'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'test@example.com'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$bindCallCount = 0;
|
||||
$ldap->method('bind')->willReturnCallback(function () use (&$bindCallCount) {
|
||||
$bindCallCount++;
|
||||
return $bindCallCount <= 1;
|
||||
});
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'testuser', 'wrongpass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('invalid_credentials', $result['error']);
|
||||
}
|
||||
|
||||
public function testAuthenticateSearchThenBindSuccess(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Test'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'test@example.com'],
|
||||
'objectguid' => ['count' => 1, 0 => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'testuser', 'correctpass');
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('CN=Test User,DC=example,DC=com', $result['dn']);
|
||||
$this->assertSame('Test', $result['attributes']['first_name']);
|
||||
$this->assertSame('User', $result['attributes']['last_name']);
|
||||
$this->assertSame('test@example.com', $result['attributes']['email']);
|
||||
}
|
||||
|
||||
public function testAuthenticateDirectBindSuccess(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['bind_method'] = 'direct_bind';
|
||||
$config['bind_username_format'] = '%s@example.com';
|
||||
|
||||
$conn = $this->createMockConnection();
|
||||
$searchResult = $this->createMockSearchResult();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('escape')->willReturnCallback(fn ($v) => $v);
|
||||
$ldap->method('search')->willReturn($searchResult);
|
||||
$ldap->method('getEntries')->willReturn([
|
||||
'count' => 1,
|
||||
0 => [
|
||||
'dn' => 'CN=Test User,DC=example,DC=com',
|
||||
'givenname' => ['count' => 1, 0 => 'Direct'],
|
||||
'sn' => ['count' => 1, 0 => 'User'],
|
||||
'mail' => ['count' => 1, 0 => 'direct@example.com'],
|
||||
],
|
||||
]);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($config, 'testuser', 'pass');
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('Direct', $result['attributes']['first_name']);
|
||||
}
|
||||
|
||||
public function testLdapInjectionIsEscaped(): void
|
||||
{
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$escapedValue = '';
|
||||
$ldap->method('escape')->willReturnCallback(function (string $v) use (&$escapedValue) {
|
||||
$escapedValue = $v;
|
||||
return 'escaped_' . $v;
|
||||
});
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('search')->willReturn(false);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($this->baseConfig(), 'user*)(|(uid=*)', 'pass');
|
||||
|
||||
$this->assertSame('user*)(|(uid=*)', $escapedValue);
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testBinaryGuidToUuid(): void
|
||||
{
|
||||
$binary = hex2bin('a1a2a3a4b1b2c1c2d1d2e1e2e3e4e5e6');
|
||||
$uuid = LdapAuthService::binaryGuidToUuid($binary);
|
||||
$this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uuid);
|
||||
}
|
||||
|
||||
public function testBinaryGuidToUuidNon16Bytes(): void
|
||||
{
|
||||
$result = LdapAuthService::binaryGuidToUuid('short');
|
||||
$this->assertSame(bin2hex('short'), $result);
|
||||
}
|
||||
|
||||
public function testTestConnectionSuccess(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(true);
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->testConnection($this->baseConfig());
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testTestConnectionBindFailure(): void
|
||||
{
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('bind')->willReturn(false);
|
||||
$ldap->method('error')->willReturn('Invalid credentials');
|
||||
$ldap->method('unbind')->willReturn(true);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->testConnection($this->baseConfig());
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertStringContainsString('Service account bind failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testStarttlsFailureReturnsConnectionFailed(): void
|
||||
{
|
||||
$config = $this->baseConfig();
|
||||
$config['encryption_mode'] = 'starttls';
|
||||
|
||||
$conn = $this->createMockConnection();
|
||||
$ldap = $this->createMock(LdapConnectionGateway::class);
|
||||
$ldap->method('connect')->willReturn($conn);
|
||||
$ldap->method('setOption')->willReturn(true);
|
||||
$ldap->method('startTls')->willReturn(false);
|
||||
|
||||
$service = $this->newService($ldap);
|
||||
$result = $service->authenticate($config, 'user', 'pass');
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('connection_failed', $result['error']);
|
||||
}
|
||||
|
||||
private function createMockConnection(): \LDAP\Connection
|
||||
{
|
||||
// We need a real LDAP\Connection object for type hints.
|
||||
// Since we can't create one without ldap_connect, use reflection.
|
||||
// For tests where ldap extension may not be available, skip.
|
||||
if (!extension_loaded('ldap')) {
|
||||
$this->markTestSkipped('LDAP extension not available');
|
||||
}
|
||||
|
||||
// ldap_connect with a URI doesn't actually connect in PHP 8.1+
|
||||
$conn = ldap_connect('ldap://127.0.0.1:1');
|
||||
if ($conn === false) {
|
||||
$this->markTestSkipped('Could not create LDAP connection resource');
|
||||
}
|
||||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
private function createMockSearchResult(): \LDAP\Result
|
||||
{
|
||||
// LDAP\Result can't be mocked directly, but since our gateway wraps
|
||||
// all ldap_* calls, the actual result object is opaque to LdapAuthService.
|
||||
// The gateway mock returns it, and we just need a type-compatible value.
|
||||
// Use a real connection + search if ldap is available; otherwise skip.
|
||||
if (!extension_loaded('ldap')) {
|
||||
$this->markTestSkipped('LDAP extension not available');
|
||||
}
|
||||
|
||||
// Create a real but unused Result object via reflection workaround.
|
||||
// Since we mock getEntries(), the actual result content doesn't matter.
|
||||
// But LDAP\Result has no public constructor, so we use a trick:
|
||||
// We accept that the gateway returns LDAP\Result|false and our mocks
|
||||
// already handle this. For this test helper, return a mock-compatible value.
|
||||
// Since LdapConnectionGateway::search returns \LDAP\Result|false,
|
||||
// and we mock the gateway, we can use createMock on a stdClass and
|
||||
// rely on the mock returning it. But PHPUnit mock of LdapConnectionGateway
|
||||
// allows any return value when configured via willReturn().
|
||||
// So we just need any object - the gateway mock will return it to the service,
|
||||
// and the service passes it back to gateway->getEntries().
|
||||
$conn = ldap_connect('ldap://127.0.0.1:1');
|
||||
if ($conn === false) {
|
||||
$this->markTestSkipped('Could not create LDAP connection');
|
||||
}
|
||||
|
||||
// We can't get a real LDAP\Result without a real server, but since
|
||||
// the service only passes it to the mocked gateway, we can create
|
||||
// a test double. PHPUnit allows returning any value from willReturn().
|
||||
// Let's use a simple approach: the gateway mock already handles this.
|
||||
// For the type system, we use an anonymous class that extends nothing
|
||||
// but the mock system handles the actual passing.
|
||||
|
||||
// Actually, since we mock LdapConnectionGateway::search() to return
|
||||
// this value, and LdapConnectionGateway::getEntries() is also mocked,
|
||||
// the value just needs to be truthy (not false).
|
||||
// We can use createStub to get a compatible value.
|
||||
return $this->createStub(\LDAP\Result::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user