Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions resources/schema/mysql/baseline.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions resources/schema/sqlite/baseline.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
9 changes: 7 additions & 2 deletions src/FreeDSx/Ldap/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
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<string, list<string>>|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);
}
}
11 changes: 9 additions & 2 deletions src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,14 +55,14 @@
*
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
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;

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
interface ReplicaPasswordStateStoreProviderInterface
{
/**
* The replica-local password-policy state store backed by this adapter's connection.
*/
public function replicaPasswordStateStore(): ReplicaPasswordStateStoreInterface;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,40 @@ public static function empty(): self
return new self();
}

/**
* @param array<string, list<string>> $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<string, list<string>>
*/
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.
*/
Expand Down
1 change: 1 addition & 0 deletions tests/integration/Sync/SyncReplNativeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 63 additions & 0 deletions tests/integration/Sync/SyncReplReplicaTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions tests/resources/seed/sync-seed.ldif
Original file line number Diff line number Diff line change
Expand Up @@ -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=
8 changes: 8 additions & 0 deletions tests/support/LdapReplicaCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
);
Expand Down
Loading
Loading