diff --git a/resources/schema/mysql/baseline.sql b/resources/schema/mysql/baseline.sql index 9326fe11..cbebd77a 100644 --- a/resources/schema/mysql/baseline.sql +++ b/resources/schema/mysql/baseline.sql @@ -43,3 +43,9 @@ CREATE TABLE IF NOT EXISTS ldap_change_journal_seq ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT IGNORE INTO ldap_change_journal_seq (id, seq) VALUES (1, 0); + +CREATE TABLE IF NOT EXISTS ldap_replica_pwpolicy_state ( + lc_dn VARCHAR(768) NOT NULL, + state JSON NOT NULL, + PRIMARY KEY (lc_dn) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/resources/schema/sqlite/baseline.sql b/resources/schema/sqlite/baseline.sql index a9fedfb5..afaf770a 100644 --- a/resources/schema/sqlite/baseline.sql +++ b/resources/schema/sqlite/baseline.sql @@ -45,3 +45,8 @@ CREATE TABLE IF NOT EXISTS ldap_change_journal_seq ( ); INSERT OR IGNORE INTO ldap_change_journal_seq (id, seq) VALUES (1, 0); + +CREATE TABLE IF NOT EXISTS ldap_replica_pwpolicy_state ( + lc_dn TEXT NOT NULL PRIMARY KEY, + state TEXT NOT NULL +); diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index 15ce210f..852168d2 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -26,6 +26,7 @@ use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\ReplicaPasswordStateStoreProviderInterface; use FreeDSx\Ldap\Server\Process\BackgroundTask\PcntlBackgroundTasks; use FreeDSx\Ldap\Server\Process\BackgroundTask\SwooleBackgroundTasks; use FreeDSx\Ldap\Sync\Consumer\LdapReplica; @@ -408,11 +409,15 @@ private function makeServerProtocolFactory(): ServerProtocolFactory } /** - * The replica-local password-policy state store, backing always-enforced lockout on a read-only replica. + * The replica-local password-policy state store, persisted by the storage backend when it can, else in memory. */ private function makeReplicaPasswordStateStore(): ReplicaPasswordStateStoreInterface { - return new InMemoryReplicaPasswordStateStore(); + $storage = $this->get(ServerOptions::class)->getStorage(); + + return $storage instanceof ReplicaPasswordStateStoreProviderInterface + ? $storage->replicaPasswordStateStore() + : new InMemoryReplicaPasswordStateStore(); } private function makeServerProtocolFactoryInterface(): ServerProtocolFactoryInterface diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStore.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStore.php new file mode 100644 index 00000000..8c62f538 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStore.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoTransactor; +use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordState; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; + +use function is_array; +use function is_string; +use function json_decode; +use function json_encode; + +/** + * Replica-local password-policy state persisted as a JSON row per subject, sharing a PdoStorage connection. + * + * @author Chad Sikorra + */ +final readonly class PdoReplicaPasswordStateStore implements ReplicaPasswordStateStoreInterface +{ + private const TABLE = 'ldap_replica_pwpolicy_state'; + + public function __construct(private PdoTransactor $transactor) {} + + public function load(Dn $dn): ReplicaPasswordState + { + $statement = $this->transactor + ->pdo() + ->prepare('SELECT state FROM ' . self::TABLE . ' WHERE lc_dn = ?'); + $statement->execute([$this->key($dn)]); + $state = $statement->fetchColumn(); + + return is_string($state) + ? $this->decode($state) + : ReplicaPasswordState::empty(); + } + + public function apply( + Dn $dn, + OperationalChanges $changes, + ): void { + if ($changes->isEmpty()) { + return; + } + + $this->transactor->atomic(function () use ($dn, $changes): void { + $key = $this->key($dn); + $next = $this->load($dn)->withChanges($changes); + + $this->transactor + ->pdo() + ->prepare('DELETE FROM ' . self::TABLE . ' WHERE lc_dn = ?') + ->execute([$key]); + + if ($next->isEmpty()) { + return; + } + + $this->transactor + ->pdo() + ->prepare('INSERT INTO ' . self::TABLE . ' (lc_dn, state) VALUES (?, ?)') + ->execute([ + $key, + $this->encode($next), + ]); + }); + } + + private function key(Dn $dn): string + { + return $dn->normalize()->toString(); + } + + private function encode(ReplicaPasswordState $state): string + { + $json = json_encode($state->toArray()); + if ($json === false) { + throw new StorageIoException('Failed to encode replica password-policy state.'); + } + + return $json; + } + + private function decode(string $state): ReplicaPasswordState + { + /** @var array>|null $decoded */ + $decoded = json_decode( + $state, + true, + ); + if (!is_array($decoded)) { + throw new StorageIoException('Failed to decode replica password-policy state; storage row is corrupted.'); + } + + return ReplicaPasswordState::fromArray($decoded); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php index ab255879..2aace728 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -38,6 +38,8 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\ReplicaPasswordStateStoreProviderInterface; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; use FreeDSx\Ldap\Server\Backend\ResettableInterface; @@ -53,14 +55,14 @@ * * @author Chad Sikorra */ -final class PdoStorage implements EntryStorageInterface, ResettableInterface, ChangeJournalingInterface +final class PdoStorage implements EntryStorageInterface, ResettableInterface, ChangeJournalingInterface, ReplicaPasswordStateStoreProviderInterface { use ChangeJournalingTrait; /** * The current schema revision shipped in resources/schema. */ - public const SCHEMA_VERSION = 1; + public const SCHEMA_VERSION = 2; private const STATEMENT_CACHE_MAX = 64; @@ -288,6 +290,11 @@ public function atomic(callable $operation): void $this->transactor->atomic(fn() => $operation($this)); } + public function replicaPasswordStateStore(): ReplicaPasswordStateStoreInterface + { + return new PdoReplicaPasswordStateStore($this->transactor); + } + protected function buildJournal(ChangeJournalConfig $config): ChangeJournalInterface { return new PdoChangeJournal( diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/ReplicaPasswordStateStoreProviderInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/ReplicaPasswordStateStoreProviderInterface.php new file mode 100644 index 00000000..f5767f32 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/ReplicaPasswordStateStoreProviderInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage; + +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; + +/** + * A storage adapter that can vend a persistent replica-local password-policy state store. + * + * @author Chad Sikorra + */ +interface ReplicaPasswordStateStoreProviderInterface +{ + /** + * The replica-local password-policy state store backed by this adapter's connection. + */ + public function replicaPasswordStateStore(): ReplicaPasswordStateStoreInterface; +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php index 15d764ac..a1ba4acb 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php @@ -50,11 +50,40 @@ public static function empty(): self return new self(); } + /** + * @param array> $values + */ + public static function fromArray(array $values): self + { + $attributes = []; + foreach ($values as $name => $vals) { + $attributes[] = Attribute::fromArray( + $name, + $vals, + ); + } + + return new self($attributes); + } + public function isEmpty(): bool { return $this->attributes === []; } + /** + * @return array> + */ + public function toArray(): array + { + $values = []; + foreach ($this->attributes as $attribute) { + $values[$attribute->getName()] = array_values($attribute->getValues()); + } + + return $values; + } + /** * Apply engine-emitted operational deltas (replace / reset) and return the resulting state. */ diff --git a/tests/integration/Sync/SyncReplNativeTest.php b/tests/integration/Sync/SyncReplNativeTest.php index 0d63b114..e6a7cb4d 100644 --- a/tests/integration/Sync/SyncReplNativeTest.php +++ b/tests/integration/Sync/SyncReplNativeTest.php @@ -73,6 +73,7 @@ public function testItStreamsEveryEntryOnAFreshRefreshOnlyPoll(): void 'cn=alice,ou=people,dc=foo,dc=bar', 'cn=bob,ou=people,dc=foo,dc=bar', 'cn=carol,ou=people,dc=foo,dc=bar', + 'cn=lockme,ou=people,dc=foo,dc=bar', 'cn=user,dc=foo,dc=bar', 'dc=foo,dc=bar', 'ou=people,dc=foo,dc=bar', diff --git a/tests/integration/Sync/SyncReplReplicaTestCase.php b/tests/integration/Sync/SyncReplReplicaTestCase.php index 20562187..e2d6d937 100644 --- a/tests/integration/Sync/SyncReplReplicaTestCase.php +++ b/tests/integration/Sync/SyncReplReplicaTestCase.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\BindException; use FreeDSx\Ldap\Exception\ReferralException; use FreeDSx\Ldap\LdapClient; use Tests\Integration\FreeDSx\Ldap\ServerTestCase; @@ -98,6 +99,68 @@ public function test_a_client_write_to_the_replica_is_referred_to_the_provider() } } + public function test_repeated_failed_binds_lock_the_account_locally_on_the_replica(): void + { + $lockme = 'cn=lockme,ou=people,dc=foo,dc=bar'; + self::assertNotNull($this->waitForReplica($lockme)); + + // The correct password works before any failures. + $this->assertBind( + $lockme, + '12345', + true, + ); + + // Two failed binds, each on a separate connection, reach pwdMaxFailure and lock locally. + $this->assertBind( + $lockme, + 'wrong', + false, + ); + $this->assertBind( + $lockme, + 'wrong', + false, + ); + + // The correct password is now rejected: the replica enforces its local lock across connections. + $this->assertBind( + $lockme, + '12345', + false, + ); + } + + private function assertBind( + string $dn, + string $password, + bool $shouldSucceed, + ): void { + $client = $this->buildClient('tcp'); + + try { + $client->bind( + $dn, + $password, + ); + self::assertTrue( + $shouldSucceed, + "Expected the bind for {$dn} to fail.", + ); + } catch (BindException $e) { + self::assertFalse( + $shouldSucceed, + "Expected the bind for {$dn} to succeed: {$e->getMessage()}.", + ); + } finally { + try { + $client->unbind(); + } catch (Throwable) { + // The connection may already be gone after a failed bind. + } + } + } + private function waitForReplica( string $dn, float $timeoutSeconds = 15.0, diff --git a/tests/resources/seed/sync-seed.ldif b/tests/resources/seed/sync-seed.ldif index 2948326f..3607f3a1 100644 --- a/tests/resources/seed/sync-seed.ldif +++ b/tests/resources/seed/sync-seed.ldif @@ -29,3 +29,9 @@ dn: cn=carol,ou=people,dc=foo,dc=bar objectClass: inetOrgPerson cn: carol sn: Carpenter + +dn: cn=lockme,ou=people,dc=foo,dc=bar +objectClass: inetOrgPerson +cn: lockme +sn: Lockme +userPassword: {SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ= diff --git a/tests/support/LdapReplicaCommand.php b/tests/support/LdapReplicaCommand.php index ee6c1b12..e92a6543 100644 --- a/tests/support/LdapReplicaCommand.php +++ b/tests/support/LdapReplicaCommand.php @@ -12,6 +12,8 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordLockoutRules; use FreeDSx\Ldap\ServerOptions; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -89,6 +91,12 @@ protected function execute( ->setTransport($transport) ->setUseSwooleRunner($runner === 'swoole') ->setStorage($this->createReplicaStorage($storageType, $runner)) + ->setPasswordPolicy(new PasswordPolicy( + lockout: new PasswordLockoutRules( + enabled: true, + maxFailure: 2, + ), + )) ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)), ); diff --git a/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php b/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php new file mode 100644 index 00000000..07101d4d --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\SharedPdoConnectionProvider; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; +use PDO; +use PHPUnit\Framework\TestCase; + +final class PdoReplicaPasswordStateStoreTest extends TestCase +{ + private const DN = 'cn=foo,dc=example,dc=com'; + + private PDO $pdo; + + private PdoStorage $storage; + + private ReplicaPasswordStateStoreInterface $subject; + + protected function setUp(): void + { + $this->pdo = new PDO('sqlite::memory:'); + PdoStorage::initialize( + $this->pdo, + new SqliteDialect(), + ); + $this->storage = new PdoStorage( + new SharedPdoConnectionProvider( + $this->pdo, + fn(): PDO => $this->pdo, + ), + new SqliteFilterTranslator(), + new SqliteDialect(), + ); + $this->subject = $this->storage->replicaPasswordStateStore(); + } + + public function test_load_is_empty_when_nothing_was_recorded(): void + { + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + + public function test_apply_persists_and_reloads_state(): void + { + $this->subject->apply( + new Dn(self::DN), + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME, + '20260520120000Z', + )), + ); + + self::assertTrue( + $this->subject + ->load(new Dn(self::DN)) + ->toUserPasswordState(new Dn(self::DN)) + ->isLocked(), + ); + } + + public function test_apply_reset_removes_the_row(): void + { + $dn = new Dn(self::DN); + $this->subject->apply( + $dn, + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_FAILURE_TIME, + '20260520120000Z', + )), + ); + + $this->subject->apply( + $dn, + OperationalChanges::of(Change::reset(PasswordPolicyOid::NAME_PWD_FAILURE_TIME)), + ); + + self::assertTrue($this->subject->load($dn)->isEmpty()); + } + + public function test_local_state_survives_a_verbatim_entry_store(): void + { + $dn = new Dn(self::DN); + $this->subject->apply( + $dn, + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME, + '20260520120000Z', + )), + ); + + $this->storage->store(new Entry( + $dn, + new Attribute('cn', 'foo'), + )); + + self::assertTrue( + $this->subject + ->load($dn) + ->toUserPasswordState($dn) + ->isLocked(), + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php index 9c9c7efe..fc2f045e 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php @@ -105,6 +105,7 @@ public function test_initialize_creates_the_baseline_schema(): void 'entry_attribute_values', 'ldap_change_journal', 'ldap_change_journal_seq', + 'ldap_replica_pwpolicy_state', ], $this->tableNames($pdo), );