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
3 changes: 3 additions & 0 deletions docs/Server/Password-Policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ recent window.

A locked bind returns `INVALID_CREDENTIALS` with the `accountLocked` error.

In a replicated setup, lockout can apply across the whole cluster so failures against a replica count too. See
[Password Policy](Replication.md#password-policy) under Replication.

### Failed Bind Delay

When both `pwdMinDelay` and `pwdMaxDelay` are set to positive values, the server delays the response to a failed
Expand Down
28 changes: 24 additions & 4 deletions docs/Server/Replication.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,27 @@ Because the two are separate, configure and review both. A replica does not inhe

### Password Policy

A replica enforces the password-policy state it receives from the provider (lockout, expiry, grace) but records none of
its own, so bind failures against a replica are not counted toward lockout there. Account lockout is not a real-time
defense on a replica; put replicas behind connection rate limiting and/or deploy them in already isolated or trusted
areas.
With a password policy enabled, a replica applies the provider's lockout, expiry, and grace state, and bind failures
against the replica count toward the account's lockout across the whole cluster. A brute-force attempt spread over
several replicas is caught the same as one against a single server.

To turn this on:

- Enable a password policy on both the provider and the replica with `setPasswordPolicy(...)`.
- On the provider, grant the replica's bind identity with `withReplicaGrants(...)`. It sets up everything a replica
identity needs, so use it in place of the sync-only grant from the Quick Start.
- Give the replica PDO storage (SQLite or MySQL). JSON-file storage is not yet supported for replica password-policy
state or forwarding.

```php
use FreeDSx\Ldap\Server\AccessControl\Subject\Subject;
use FreeDSx\Ldap\Server\AccessControl\Target\Target;

$aclRules = $myAclRules->withReplicaGrants(
Subject::dn('cn=replica,dc=example,dc=com'),
Target::subtree('dc=example,dc=com'),
);
```

Cluster-wide lockout is not instantaneous, so keep replicas behind connection rate limiting. If the provider is not a
FreeDSx server, the replica still enforces the lockout state it receives but does not contribute its own.
6 changes: 6 additions & 0 deletions src/FreeDSx/Ldap/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\SafeModifyConstraint;
use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory;
use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine;
use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory;
use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore;
use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface;
use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService;
Expand Down Expand Up @@ -343,6 +344,10 @@ private function makePasswordPolicyEngine(): PasswordPolicyEngine
return new PasswordPolicyEngine(
clock: $clock,
changeConstraints: $chain,
uniqueTimes: new UniquePolicyTimeFactory(
$clock,
$options->getChangeJournalConfig()->origin,
),
);
}

Expand Down Expand Up @@ -721,6 +726,7 @@ function (): void {
periodicTasks: $periodicTasks,
longLivedTasks: $longLivedTasks,
logger: $options->getLogger(),
gracefulStopSeconds: $options->getShutdownTimeout(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ public function toAsn1(): SequenceType
Asn1::octetString($this->dn->toString()),
Asn1::octetString($this->entryUuid),
Asn1::setOf(...array_map(
static fn(DateTimeImmutable $time): OctetStringType => Asn1::octetString(GeneralizedTime::format($time)),
static fn(DateTimeImmutable $time): OctetStringType
=> Asn1::octetString(GeneralizedTime::formatWithFraction($time)),
$this->failureTimes,
)),
);
Expand Down
11 changes: 11 additions & 0 deletions src/FreeDSx/Ldap/Schema/Definition/GeneralizedTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ public static function format(DateTimeImmutable $instant): string
->format('YmdHis') . 'Z';
}

/**
* Render with a fractional-seconds field, for multi-valued attributes that must hold unique values such as
* pwdFailureTime and pwdGraceUseTime; {@see format()} stays second-resolution for canonical single-valued stamps.
*/
public static function formatWithFraction(DateTimeImmutable $instant): string
{
return $instant
->setTimezone(new DateTimeZone('UTC'))
->format('YmdHis.u') . 'Z';
}

/**
* @param array<int|string, string> $components named capture groups
*/
Expand Down
35 changes: 35 additions & 0 deletions src/FreeDSx/Ldap/Server/AccessControl/AclRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

namespace FreeDSx\Ldap\Server\AccessControl;

use FreeDSx\Ldap\Control\Control;
use FreeDSx\Ldap\Operation\OperationType;
use FreeDSx\Ldap\Operation\Request\ExtendedRequest;
use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeRule;
use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule;
use FreeDSx\Ldap\Server\AccessControl\Rule\Effect;
Expand All @@ -22,6 +24,7 @@
use FreeDSx\Ldap\Server\AccessControl\Subject\Subject;
use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface;
use FreeDSx\Ldap\Server\AccessControl\Target\AnyTargetMatcher;
use FreeDSx\Ldap\Server\AccessControl\Target\TargetMatcherInterface;

/**
* The rule sets and defaults that configure {@see RuleBasedAccessControl}.
Expand Down Expand Up @@ -133,6 +136,38 @@ public function withExtendedOperationRules(ExtendedOperationRule ...$extendedOps
);
}

/**
* Grant a replica's bind identity the two privileged capabilities it needs: the content-sync control over $target,
* and the password-policy forward extended operation.
*/
public function withReplicaGrants(
SubjectMatcherInterface $replica,
TargetMatcherInterface $target = new AnyTargetMatcher(),
): self {
return new self(
$this->operations,
$this->attributes,
[
...$this->controls,
ControlRule::allow(
$replica,
$target,
Control::OID_SYNC_REQUEST,
),
],
[
...$this->extendedOps,
ExtendedOperationRule::allow(
$replica,
ExtendedRequest::OID_PPOLICY_STATE_FORWARD,
),
],
$this->defaultOperationEffect,
$this->defaultControlEffect,
$this->defaultExtendedOpEffect,
);
}

public function withDefaultOperationEffect(Effect $effect): self
{
return new self(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,19 @@ public function rollBack(PDO $pdo): void
$pdo->exec('ROLLBACK');
}

/**
* Upserts an entry in place via ON CONFLICT rather than INSERT OR REPLACE, because REPLACE deletes the row first and
* fires ON DELETE CASCADE, which would wipe child rows such as the replica password-policy state.
*/
public function queryUpsert(): string
{
return <<<SQL
INSERT OR REPLACE
INTO entries (lc_dn, dn, lc_parent_dn, attributes)
INSERT INTO entries (lc_dn, dn, lc_parent_dn, attributes)
VALUES (?, ?, ?, ?)
ON CONFLICT(lc_dn) DO UPDATE SET
dn = excluded.dn,
lc_parent_dn = excluded.lc_parent_dn,
attributes = excluded.attributes
SQL;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ public function discardIfSuperseded(
});
}

public function discard(Dn $dn): void
{
$this->transactor
->pdo()
->prepare('DELETE FROM ' . self::TABLE . ' WHERE lc_dn = ?')
->execute([$this->key($dn)]);
}

private function loadRecord(Dn $dn): ReplicaForwardState
{
$statement = $this->transactor
Expand Down
18 changes: 13 additions & 5 deletions src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use FreeDSx\Ldap\Operation\ResultCode;
use FreeDSx\Ldap\Schema\Definition\GeneralizedTime;
use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid;
use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId;
use FreeDSx\Ldap\Server\Clock\ClockInterface;
use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeAttempt;
use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain;
Expand All @@ -32,10 +33,17 @@
*/
final readonly class PasswordPolicyEngine
{
private UniquePolicyTimeFactory $uniqueTimes;

public function __construct(
private ClockInterface $clock,
private PasswordChangeConstraintChain $changeConstraints,
) {}
?UniquePolicyTimeFactory $uniqueTimes = null,
) {
// The Container injects the configured factory; the fallback reuses this engine's clock so tests stay in sync.
$this->uniqueTimes = $uniqueTimes
?? new UniquePolicyTimeFactory($this->clock, new ReplicaId('local'));
}

/**
* Lockout check applied before the inner authenticator verifies credentials.
Expand Down Expand Up @@ -80,7 +88,7 @@ public function recordBindFailure(
$priorFailures,
$policy,
);
$retained[] = $now;
$retained[] = $this->uniqueTimes->next($retained);

$changes = [Change::replace(
PasswordPolicyOid::NAME_PWD_FAILURE_TIME,
Expand Down Expand Up @@ -294,7 +302,7 @@ private function unionTimes(
$byValue = [];

foreach ([...$current, ...$forwarded] as $time) {
$byValue[GeneralizedTime::format($time)] = $time;
$byValue[GeneralizedTime::formatWithFraction($time)] = $time;
}

return array_values($byValue);
Expand Down Expand Up @@ -596,7 +604,7 @@ private function buildSuccessChanges(
$changes[] = Change::reset(PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME);
}
if ($isExpired) {
$graceTimes = [...$state->graceUseTimes, $now];
$graceTimes = [...$state->graceUseTimes, $this->uniqueTimes->next($state->graceUseTimes)];
$changes[] = Change::replace(
PasswordPolicyOid::NAME_PWD_GRACE_USE_TIME,
...self::formatTimes($graceTimes),
Expand Down Expand Up @@ -731,7 +739,7 @@ private function secondsSinceLock(UserPasswordState $state): int
private static function formatTimes(array $instants): array
{
return array_values(array_map(
static fn(DateTimeImmutable $t): string => GeneralizedTime::format($t),
static fn(DateTimeImmutable $t): string => GeneralizedTime::formatWithFraction($t),
$instants,
));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ public function discardIfSuperseded(
}
}

public function discard(Dn $dn): void
{
unset($this->records[$this->key($dn)]);
}

private function key(Dn $dn): string
{
return $dn->normalize()->toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,9 @@ public function discardIfSuperseded(
Dn $dn,
UserPasswordState $authoritative,
): void;

/**
* Drop a subject's local state outright, used when the subject is deleted on the primary; a no-op when none exists.
*/
public function discard(Dn $dn): void;
}
104 changes: 104 additions & 0 deletions src/FreeDSx/Ldap/Server/PasswordPolicy/UniquePolicyTimeFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?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\PasswordPolicy;

use DateTimeImmutable;
use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId;
use FreeDSx\Ldap\Server\Clock\ClockInterface;

use function crc32;
use function intdiv;

/**
* Stamps pwdFailureTime / pwdGraceUseTime values that stay unique within and across replicas, as draft-behera password
* policy recommends, by packing a replica hash and an intra-second counter into the microsecond field.
*
* @internal
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
readonly class UniquePolicyTimeFactory
{
/**
* The high 4 digits of the microsecond field, so cross-replica hash collisions land at about one in ten thousand.
*/
private const BAND_MODULUS = 10000;

/**
* The low 2 digits of the microsecond field; failures stop accruing once an account locks, so it never nears 100.
*/
private const COUNTER_MODULUS = 100;

/**
* The replica's fixed slice of the microsecond field; a hash collision only under-counts by one for a shared
* subject in the same second, which is fail-safe and within the spec's "local matter" latitude.
*/
private int $replicaBand;

public function __construct(
private ClockInterface $clock,
ReplicaId $replicaId,
) {
$this->replicaBand = (int) (crc32((string) $replicaId) % self::BAND_MODULUS);
}

/**
* The current time stamped unique against $existing, which the caller must read under the same lock that persists
* the result so the counter cannot race.
*
* @param list<DateTimeImmutable> $existing
*/
public function next(array $existing): DateTimeImmutable
{
$whole = $this->clock
->now()
->setTime(
(int) $this->clock->now()->format('H'),
(int) $this->clock->now()->format('i'),
(int) $this->clock->now()->format('s'),
);

$microseconds = $this->replicaBand * self::COUNTER_MODULUS
+ $this->sameBandCount($existing, $whole) % self::COUNTER_MODULUS;

return $whole->setTime(
(int) $whole->format('H'),
(int) $whole->format('i'),
(int) $whole->format('s'),
$microseconds,
);
}

/**
* @param list<DateTimeImmutable> $existing
*/
private function sameBandCount(
array $existing,
DateTimeImmutable $whole,
): int {
$second = $whole->format('YmdHis');
$count = 0;

foreach ($existing as $time) {
if ($time->format('YmdHis') !== $second) {
continue;
}

if (intdiv((int) $time->format('u'), self::COUNTER_MODULUS) === $this->replicaBand) {
$count++;
}
}

return $count;
}
}
Loading
Loading