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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ composer.lock
composer.phar
vendor/
.phpunit.result.cache
tests/resources/cert/
# The OpenLDAP tooling dumps its generated certs here; ignore them, but keep the committed
# SASL EXTERNAL fixtures under test-cases/ (see test-cases/generate-external-certs.sh).
tests/resources/cert/*
!tests/resources/cert/test-cases/
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"require": {
"php": ">=8.2",
"freedsx/asn1": "dev-main#b57a38f",
"freedsx/socket": "dev-main#287974b",
"freedsx/sasl": "dev-main#85e1ef9",
"freedsx/socket": "dev-main#df083d9",
"freedsx/sasl": "dev-main#b886acb",
"psr/log": "^3"
},
"require-dev": {
Expand Down
43 changes: 43 additions & 0 deletions docs/Server/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ LDAP Server Configuration
* [ServerOptions:setSearchLimitRules](#setsearchlimitrules)
* [SASL Options](#sasl-options)
* [ServerOptions:setSaslMechanisms](#setsaslmechanisms)
* [ServerOptions:setExternalCredentialMapper](#setexternalcredentialmapper)

The LDAP server is configured through a `ServerOptions` object. The configuration object is passed to the server
on construction:
Expand Down Expand Up @@ -691,6 +692,7 @@ Use the constants defined on `ServerOptions` to specify mechanisms:
| Constant | Mechanism |
|-------------------------------------------|-----------------------|
| `ServerOptions::SASL_PLAIN` | `PLAIN` |
| `ServerOptions::SASL_EXTERNAL` | `EXTERNAL` |
| `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` |
| `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` |
| `ServerOptions::SASL_SCRAM_SHA_1` | `SCRAM-SHA-1` |
Expand Down Expand Up @@ -730,6 +732,47 @@ $server = new LdapServer(
**Note**: The `PLAIN` mechanism transmits credentials in cleartext. It should only be enabled when the connection is
protected by TLS (via StartTLS or `setUseSsl`).

**Note**: The `EXTERNAL` mechanism authenticates from the verified TLS client certificate rather than `getSaslIdentity()`.
It requires a TLS connection **and** client-certificate validation (`setSslValidateCert(true)` with `setSslCaCert()`),
otherwise the bind is rejected. By default, the certificate subject DN is resolved via the identity resolver chain. You
can customize the mapping with [setExternalCredentialMapper](#setexternalcredentialmapper).

See [SASL Authentication](General-Usage.md#sasl-authentication) for full usage details.

**Default**: `[]` (SASL disabled)

------------------
#### setExternalCredentialMapper

Customizes how a verified TLS client certificate is mapped to an identity for the `EXTERNAL` mechanism. The
mapper returns an `AuthzId` (a `dn:`/`u:` identity) that is resolved through the identity resolver chain, or `null` to
reject the certificate.

The default (`SubjectDnCredentialMapper`) reconstructs the certificate subject DN (X.509 order reversed to LDAP order)
and resolves it as a DN. Provide a custom mapper to instead map a SAN/UPN, rewrite the DN, or gate on the issuer:

```php
use FreeDSx\Ldap\Protocol\Authorization\AuthzId;
use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface;
use FreeDSx\Socket\Tls\Certificate;

$mapper = new class implements ExternalCredentialMapperInterface {
public function map(Certificate $certificate): ?AuthzId
{
// Resolve the entry by the certificate's subjectAltName via an attribute search.
$san = $certificate->getSubjectAltName();

return $san === null
? null
: AuthzId::fromUsername($san);
}
};

$options->setSaslMechanisms(ServerOptions::SASL_EXTERNAL)
->setExternalCredentialMapper($mapper);
```

A client may also send an authorization identity (`dn:`/`u:`) in the EXTERNAL credentials to act as a different
identity. This is only allowed when the certificate identity holds the proxied-authorization grant (RFC 4370).

**Default**: `null` (the certificate subject DN mapper)
34 changes: 34 additions & 0 deletions src/FreeDSx/Ldap/ClientOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ final class ClientOptions

private ?string $sslCaCert = null;

private ?string $sslCert = null;

private ?string $sslCertKey = null;

private ?string $sslPeerName = null;

private int $timeoutConnect = 3;
Expand Down Expand Up @@ -209,6 +213,36 @@ public function setSslCaCert(?string $sslCaCert): self
return $this;
}

public function getSslCert(): ?string
{
return $this->sslCert;
}

/**
* The client certificate to present for mutual TLS (e.g. for a SASL EXTERNAL bind).
*/
public function setSslCert(?string $sslCert): self
{
$this->sslCert = $sslCert;

return $this;
}

public function getSslCertKey(): ?string
{
return $this->sslCertKey;
}

/**
* The private key for the client certificate (omit when the key is bundled in the certificate file).
*/
public function setSslCertKey(?string $sslCertKey): self
{
$this->sslCertKey = $sslCertKey;

return $this;
}

public function getSslPeerName(): ?string
{
return $this->sslPeerName;
Expand Down
2 changes: 2 additions & 0 deletions src/FreeDSx/Ldap/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ private function makeSocketPool(): SocketPool
->setSslValidateCert($clientOptions->isSslValidateCert())
->setSslAllowSelfSigned($clientOptions->isSslAllowSelfSigned())
->setSslCaCert($clientOptions->getSslCaCert())
->setSslCert($clientOptions->getSslCert())
->setSslCertKey($clientOptions->getSslCertKey())
->setSslPeerName($clientOptions->getSslPeerName())
->setTimeoutConnect($clientOptions->getTimeoutConnect())
->setTimeoutRead($clientOptions->getTimeoutRead());
Expand Down
98 changes: 98 additions & 0 deletions src/FreeDSx/Ldap/Protocol/Authorization/AuthzId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?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\Protocol\Authorization;

use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Exception\InvalidArgumentException;

use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;

/**
* An authorization identity in SASL/RFC 4513 form: a typed value plus its "dn:" /"u:" / anonymous form.
*
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
final readonly class AuthzId
{
private function __construct(
private AuthzIdType $type,
private string $value = '',
) {}

public function __toString(): string
{
return $this->toString();
}

/**
* Parse the "dn:" / "u:" form; an empty string is the anonymous identity.
*
* @throws InvalidArgumentException on an unrecognized (non-dn/u) form
*/
public static function fromString(string $authzId): self
{
return match (true) {
$authzId === '' => new self(AuthzIdType::Anonymous),
str_starts_with($authzId, AuthzIdType::Dn->value) => new self(
AuthzIdType::Dn,
substr($authzId, strlen(AuthzIdType::Dn->value)),
),
str_starts_with($authzId, AuthzIdType::Username->value) => new self(
AuthzIdType::Username,
substr($authzId, strlen(AuthzIdType::Username->value)),
),
default => throw new InvalidArgumentException(sprintf(
'The authorization identity "%s" must use the "dn:" or "u:" form.',
$authzId,
)),
};
}

public static function fromDn(Dn $dn): self
{
return new self(
AuthzIdType::Dn,
$dn->toString(),
);
}

public static function fromUsername(string $username): self
{
return new self(
AuthzIdType::Username,
$username,
);
}

public function isType(AuthzIdType $type): bool
{
return $this->type === $type;
}

/**
* The identity value without its form prefix (the DN string, or the username; empty when anonymous).
*/
public function getValue(): string
{
return $this->value;
}

public function toString(): string
{
return $this->type->value . $this->value;
}
}
Loading
Loading