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:
2026-03-25 07:26:04 +01:00
parent a0d816caaa
commit d1eeac6692
22 changed files with 2325 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Auth\PasswordResetRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Auth\ApiTokenEndpointService;
@@ -15,6 +16,7 @@ use MintyPHP\Service\Auth\AuthRepositoryFactory;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Auth\LdapAuthService;
use MintyPHP\Service\Auth\MicrosoftOidcService;
use MintyPHP\Service\Auth\PasswordResetService;
use MintyPHP\Service\Auth\RememberMeService;
@@ -38,7 +40,9 @@ final class AuthRegistrar implements ContainerRegistrar
$container->set(TenantSsoService::class, static fn (AppContainer $c): TenantSsoService => $c->get(AuthServicesFactory::class)->createTenantSsoService());
$container->set(MicrosoftOidcService::class, static fn (AppContainer $c): MicrosoftOidcService => $c->get(AuthServicesFactory::class)->createMicrosoftOidcService());
$container->set(SsoUserLinkService::class, static fn (AppContainer $c): SsoUserLinkService => $c->get(AuthServicesFactory::class)->createSsoUserLinkService());
$container->set(LdapAuthService::class, static fn (AppContainer $c): LdapAuthService => $c->get(AuthServicesFactory::class)->createLdapAuthService());
$container->set(TenantMicrosoftAuthRepository::class, static fn (AppContainer $c): TenantMicrosoftAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantMicrosoftAuthRepository());
$container->set(TenantLdapAuthRepository::class, static fn (AppContainer $c): TenantLdapAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantLdapAuthRepository());
$container->set(ApiTokenRepository::class, static fn (AppContainer $c): ApiTokenRepository => $c->get(AuthRepositoryFactory::class)->createApiTokenRepository());
$container->set(RememberTokenRepository::class, static fn (AppContainer $c): RememberTokenRepository => $c->get(AuthRepositoryFactory::class)->createRememberTokenRepository());
$container->set(PasswordResetRepository::class, static fn (AppContainer $c): PasswordResetRepository => $c->get(AuthRepositoryFactory::class)->createPasswordResetRepository());

View File

@@ -0,0 +1,105 @@
<?php
namespace MintyPHP\Repository\Tenant;
use MintyPHP\DB;
/** Persists LDAP authentication settings per tenant, including encrypted bind credentials. */
class TenantLdapAuthRepository implements TenantLdapAuthRepositoryInterface
{
private const COLUMNS = 'id, tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created, modified';
private function unwrap($row): ?array
{
if (!$row || !is_array($row)) {
return null;
}
if (isset($row['tenant_auth_ldap']) && is_array($row['tenant_auth_ldap'])) {
return $row['tenant_auth_ldap'];
}
return $row;
}
public function findByTenantId(int $tenantId): ?array
{
$row = DB::selectOne(
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id = ? limit 1',
(string) $tenantId
);
return $this->unwrap($row);
}
public function listByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$item = $this->unwrap($row);
if (!is_array($item)) {
continue;
}
$tenantId = (int) ($item['tenant_id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$result[$tenantId] = $item;
}
return $result;
}
public function upsertByTenantId(int $tenantId, array $data): bool
{
$autoRememberMode = $data['auto_remember_mode'] ?? null;
$autoRememberModeParam = $autoRememberMode !== null ? (string) (int) $autoRememberMode : null;
$attributeMap = $data['attribute_map'] ?? '{"first_name":"givenName","last_name":"sn","email":"mail","phone":"telephoneNumber","mobile":"mobile"}';
if (is_array($attributeMap)) {
$attributeMap = json_encode($attributeMap, JSON_THROW_ON_ERROR);
}
$result = DB::update(
'insert into tenant_auth_ldap (tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW()) ' .
'on duplicate key update enabled = values(enabled), enforce_ldap_login = values(enforce_ldap_login), host = values(host), port = values(port), encryption_mode = values(encryption_mode), verify_tls_certificate = values(verify_tls_certificate), base_dn = values(base_dn), bind_dn_enc = values(bind_dn_enc), bind_password_enc = values(bind_password_enc), ' .
'user_search_filter = values(user_search_filter), user_search_scope = values(user_search_scope), bind_method = values(bind_method), bind_username_format = values(bind_username_format), unique_id_attribute = values(unique_id_attribute), attribute_map = values(attribute_map), ' .
'sync_profile_on_login = values(sync_profile_on_login), sync_profile_fields = values(sync_profile_fields), allowed_domains = values(allowed_domains), auto_remember_mode = values(auto_remember_mode), network_timeout = values(network_timeout), modified = NOW()',
(string) $tenantId,
!empty($data['enabled']) ? '1' : '0',
!empty($data['enforce_ldap_login']) ? '1' : '0',
$data['host'] ?? '',
(string) (int) ($data['port'] ?? 389),
$data['encryption_mode'] ?? 'starttls',
!empty($data['verify_tls_certificate']) ? '1' : '0',
$data['base_dn'] ?? '',
$data['bind_dn_enc'] ?? null,
$data['bind_password_enc'] ?? null,
$data['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))',
$data['user_search_scope'] ?? 'sub',
$data['bind_method'] ?? 'search_then_bind',
$data['bind_username_format'] ?? null,
$data['unique_id_attribute'] ?? 'objectGUID',
$attributeMap,
!empty($data['sync_profile_on_login']) ? '1' : '0',
$data['sync_profile_fields'] ?? null,
$data['allowed_domains'] ?? null,
$autoRememberModeParam,
(string) (int) ($data['network_timeout'] ?? 5)
);
return $result !== false;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace MintyPHP\Repository\Tenant;
/** Contract for storing and retrieving LDAP authentication configuration per tenant. */
interface TenantLdapAuthRepositoryInterface
{
public function findByTenantId(int $tenantId): ?array;
public function listByTenantIds(array $tenantIds): array;
public function upsertByTenantId(int $tenantId, array $data): bool;
}

View File

@@ -10,6 +10,8 @@ use MintyPHP\Repository\Auth\PasswordResetRepository;
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepository;
@@ -25,6 +27,7 @@ class AuthRepositoryFactory
private ?EmailVerificationRepository $emailVerificationRepository = null;
private ?TenantRepository $tenantRepository = null;
private ?TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository = null;
private ?TenantLdapAuthRepository $tenantLdapAuthRepository = null;
private ?UserExternalIdentityRepository $userExternalIdentityRepository = null;
public function createApiTokenRepository(): ApiTokenRepositoryInterface
@@ -57,6 +60,11 @@ class AuthRepositoryFactory
return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository();
}
public function createTenantLdapAuthRepository(): TenantLdapAuthRepositoryInterface
{
return $this->tenantLdapAuthRepository ??= new TenantLdapAuthRepository();
}
public function createUserExternalIdentityRepository(): UserExternalIdentityRepositoryInterface
{
return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository();

View File

@@ -15,6 +15,7 @@ use MintyPHP\Service\User\UserServicesFactory;
class AuthServicesFactory
{
private ?TenantSsoService $tenantSsoService = null;
private ?LdapAuthService $ldapAuthService = null;
private ?MicrosoftOidcStateStoreService $microsoftOidcStateStoreService = null;
private ?MicrosoftOidcService $microsoftOidcService = null;
private ?ApiTokenService $apiTokenService = null;
@@ -125,11 +126,19 @@ class AuthServicesFactory
return $this->tenantSsoService ??= new TenantSsoService(
$this->authGatewayFactory->createAuthTenantGateway(),
$this->authRepositoryFactory->createTenantMicrosoftAuthRepository(),
$this->authRepositoryFactory->createTenantLdapAuthRepository(),
$this->authGatewayFactory->createAuthSettingsGateway(),
$this->authGatewayFactory->createAuthCryptoGateway()
);
}
public function createLdapAuthService(): LdapAuthService
{
return $this->ldapAuthService ??= new LdapAuthService(
new LdapConnectionGateway()
);
}
public function createMicrosoftOidcStateStore(): MicrosoftOidcStateStoreService
{
return $this->microsoftOidcStateStoreService ??= new MicrosoftOidcStateStoreService($this->sessionStore);

View File

@@ -0,0 +1,294 @@
<?php
namespace MintyPHP\Service\Auth;
/**
* Handles LDAP authentication: connect, bind, search, and attribute retrieval.
*
* Supports two bind methods:
* - search_then_bind: service account binds first, searches for user DN, then re-binds as user
* - direct_bind: constructs user DN from a format string and binds directly
*
* All user-supplied values are escaped via ldap_escape() to prevent LDAP injection.
*/
class LdapAuthService
{
public function __construct(
private readonly LdapConnectionGateway $ldap
) {
}
/**
* Test connectivity and service-account bind.
*
* @param array $config Decrypted LDAP config (host, port, encryption_mode, etc.)
* @return array{ok: bool, error?: string}
*/
public function testConnection(array $config): array
{
try {
$conn = $this->createConnection($config);
} catch (\RuntimeException $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$bindDn = trim((string) ($config['bind_dn'] ?? ''));
$bindPassword = (string) ($config['bind_password'] ?? '');
if ($bindDn !== '' && $bindPassword !== '') {
if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) {
$error = $this->ldap->error($conn);
$this->ldap->unbind($conn);
return ['ok' => false, 'error' => 'Service account bind failed: ' . $error];
}
}
$this->ldap->unbind($conn);
return ['ok' => true];
}
/**
* Authenticate a user against the LDAP directory.
*
* @param array $config Decrypted LDAP config
* @param string $username Username (sAMAccountName, uid, or email depending on config)
* @param string $password User's plaintext password (never logged)
* @return array{ok: bool, attributes?: array, unique_id?: string, dn?: string, error?: string}
*/
public function authenticate(array $config, string $username, string $password): array
{
if (trim($username) === '' || $password === '') {
return ['ok' => false, 'error' => 'credentials_empty'];
}
try {
$conn = $this->createConnection($config);
} catch (\RuntimeException $e) {
return ['ok' => false, 'error' => 'connection_failed'];
}
$bindMethod = $config['bind_method'] ?? 'search_then_bind';
if ($bindMethod === 'direct_bind') {
return $this->authenticateDirectBind($conn, $config, $username, $password);
}
return $this->authenticateSearchThenBind($conn, $config, $username, $password);
}
private function authenticateSearchThenBind(\LDAP\Connection $conn, array $config, string $username, string $password): array
{
$bindDn = trim((string) ($config['bind_dn'] ?? ''));
$bindPassword = (string) ($config['bind_password'] ?? '');
if ($bindDn !== '') {
if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) {
$this->ldap->unbind($conn);
return ['ok' => false, 'error' => 'service_bind_failed'];
}
}
$userEntry = $this->searchUser($conn, $config, $username);
if ($userEntry === null) {
$this->ldap->unbind($conn);
return ['ok' => false, 'error' => 'user_not_found'];
}
$userDn = $userEntry['dn'];
if (!$this->ldap->bind($conn, $userDn, $password)) {
$this->ldap->unbind($conn);
return ['ok' => false, 'error' => 'invalid_credentials'];
}
$attributeMap = $this->parseAttributeMap($config);
$uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
$attributes = $this->readAttributes($userEntry, $attributeMap);
$uniqueId = $this->extractUniqueId($userEntry, $uniqueIdAttr);
$this->ldap->unbind($conn);
return [
'ok' => true,
'attributes' => $attributes,
'unique_id' => $uniqueId,
'dn' => $userDn,
];
}
private function authenticateDirectBind(\LDAP\Connection $conn, array $config, string $username, string $password): array
{
$format = $config['bind_username_format'] ?? '%s';
$escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_DN);
$userDn = str_replace('%s', $escapedUsername, $format);
if (!$this->ldap->bind($conn, $userDn, $password)) {
$this->ldap->unbind($conn);
return ['ok' => false, 'error' => 'invalid_credentials'];
}
$userEntry = $this->searchUser($conn, $config, $username);
$attributeMap = $this->parseAttributeMap($config);
$uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
$attributes = $userEntry !== null ? $this->readAttributes($userEntry, $attributeMap) : [];
$uniqueId = $userEntry !== null ? $this->extractUniqueId($userEntry, $uniqueIdAttr) : '';
$this->ldap->unbind($conn);
return [
'ok' => true,
'attributes' => $attributes,
'unique_id' => $uniqueId,
'dn' => $userDn,
];
}
private function searchUser(\LDAP\Connection $conn, array $config, string $username): ?array
{
$baseDn = $config['base_dn'] ?? '';
$filterTemplate = $config['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))';
$searchScope = ($config['user_search_scope'] ?? 'sub') === 'one' ? 1 : 0;
$escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_FILTER);
$filter = str_replace('%s', $escapedUsername, $filterTemplate);
$attributeMap = $this->parseAttributeMap($config);
$uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
$requestAttrs = array_values($attributeMap);
$requestAttrs[] = $uniqueIdAttr;
$requestAttrs = array_unique(array_filter($requestAttrs));
$result = $this->ldap->search($conn, $baseDn, $filter, $requestAttrs, $searchScope);
if ($result === false) {
return null;
}
$entries = $this->ldap->getEntries($conn, $result);
if ($entries === false || ($entries['count'] ?? 0) < 1) {
return null;
}
return $entries[0];
}
private function readAttributes(array $entry, array $attributeMap): array
{
$result = [];
foreach ($attributeMap as $appField => $ldapAttr) {
$ldapAttrLower = strtolower($ldapAttr);
$value = null;
if (isset($entry[$ldapAttrLower]) && is_array($entry[$ldapAttrLower]) && ($entry[$ldapAttrLower]['count'] ?? 0) > 0) {
$value = $entry[$ldapAttrLower][0];
}
$result[$appField] = $value;
}
return $result;
}
private function extractUniqueId(array $entry, string $attribute): string
{
$attrLower = strtolower($attribute);
if (!isset($entry[$attrLower]) || !is_array($entry[$attrLower]) || ($entry[$attrLower]['count'] ?? 0) < 1) {
return '';
}
$value = $entry[$attrLower][0];
if ($attrLower === 'objectguid' && strlen($value) === 16) {
return self::binaryGuidToUuid($value);
}
return (string) $value;
}
/**
* Convert a 16-byte binary Active Directory objectGUID to UUID string format.
*/
public static function binaryGuidToUuid(string $binary): string
{
if (strlen($binary) !== 16) {
return bin2hex($binary);
}
$parts = unpack('Va/vb/vc/C2d/C6e', $binary);
if ($parts === false) {
return bin2hex($binary);
}
return sprintf(
'%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x',
$parts['a'],
$parts['b'],
$parts['c'],
$parts['d1'],
$parts['d2'],
$parts['e1'],
$parts['e2'],
$parts['e3'],
$parts['e4'],
$parts['e5'],
$parts['e6']
);
}
private function parseAttributeMap(array $config): array
{
$raw = $config['attribute_map'] ?? '{}';
if (is_string($raw)) {
$map = json_decode($raw, true);
if (!is_array($map)) {
$map = [];
}
} elseif (is_array($raw)) {
$map = $raw;
} else {
$map = [];
}
return $map;
}
/**
* @throws \RuntimeException on connection failure
*/
private function createConnection(array $config): \LDAP\Connection
{
$host = trim((string) ($config['host'] ?? ''));
$port = (int) ($config['port'] ?? 389);
$encryptionMode = $config['encryption_mode'] ?? 'starttls';
$verifyTls = (bool) ($config['verify_tls_certificate'] ?? true);
$networkTimeout = (int) ($config['network_timeout'] ?? 5);
if ($host === '') {
throw new \RuntimeException('LDAP host is not configured');
}
if ($encryptionMode === 'ldaps') {
$uri = 'ldaps://' . $host . ':' . $port;
} else {
$uri = 'ldap://' . $host . ':' . $port;
}
$conn = $this->ldap->connect($uri);
if ($conn === false) {
throw new \RuntimeException('ldap_connect failed for ' . $host);
}
$this->ldap->setOption($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
$this->ldap->setOption($conn, LDAP_OPT_REFERRALS, 0);
$this->ldap->setOption($conn, LDAP_OPT_NETWORK_TIMEOUT, $networkTimeout);
if (!$verifyTls) {
$this->ldap->setOption($conn, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
if ($encryptionMode === 'starttls') {
if (!$this->ldap->startTls($conn)) {
throw new \RuntimeException('STARTTLS negotiation failed');
}
}
return $conn;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace MintyPHP\Service\Auth;
/**
* Thin wrapper around PHP ldap_* functions for testability.
*
* Every public method maps 1:1 to a native ldap_* call.
* In tests, this gateway is replaced with a mock.
*/
class LdapConnectionGateway
{
/** @return \LDAP\Connection|false */
public function connect(string $uri): \LDAP\Connection|false
{
return ldap_connect($uri);
}
public function setOption(\LDAP\Connection $conn, int $option, mixed $value): bool
{
return ldap_set_option($conn, $option, $value);
}
public function startTls(\LDAP\Connection $conn): bool
{
return ldap_start_tls($conn);
}
public function bind(\LDAP\Connection $conn, ?string $dn = null, ?string $password = null): bool
{
return @ldap_bind($conn, $dn, $password);
}
/** @return \LDAP\Result|false */
public function search(\LDAP\Connection $conn, string $baseDn, string $filter, array $attributes = [], int $scope = 0): \LDAP\Result|false
{
if ($scope === 1) {
return @ldap_list($conn, $baseDn, $filter, $attributes);
}
return @ldap_search($conn, $baseDn, $filter, $attributes);
}
public function getEntries(\LDAP\Connection $conn, \LDAP\Result $result): array|false
{
return ldap_get_entries($conn, $result);
}
public function escape(string $value, string $ignore = '', int $flags = 0): string
{
return ldap_escape($value, $ignore, $flags);
}
public function errno(\LDAP\Connection $conn): int
{
return ldap_errno($conn);
}
public function error(\LDAP\Connection $conn): string
{
return ldap_error($conn);
}
public function unbind(\LDAP\Connection $conn): bool
{
return @ldap_unbind($conn);
}
}

View File

@@ -211,6 +211,168 @@ class SsoUserLinkService
$this->avatarService->saveBinary($userUuid, $binary, $avatarMime);
}
/**
* Link or provision a user from LDAP authentication result.
*
* @param array $tenant Tenant record
* @param array $ldapResult Result from LdapAuthService::authenticate()
* @param array $ldapConfig Effective LDAP provider config
* @return array{ok: bool, user_id?: int, created?: bool, error?: string}
*/
public function linkOrProvisionLdapUser(array $tenant, array $ldapResult, array $ldapConfig): array
{
$tenantId = (int) ($tenant['id'] ?? 0);
$uniqueId = trim((string) ($ldapResult['unique_id'] ?? ''));
$dn = trim((string) ($ldapResult['dn'] ?? ''));
$attributes = $ldapResult['attributes'] ?? [];
$host = trim((string) ($ldapConfig['host'] ?? ''));
$email = strtolower(trim((string) ($attributes['email'] ?? '')));
if ($tenantId <= 0 || $host === '') {
return ['ok' => false, 'error' => 'identity_invalid'];
}
$providerKey = $this->tenantSsoService->ldapProviderKey();
$tid = $host;
$oid = $uniqueId !== '' ? $uniqueId : $dn;
$issuer = $host;
$subject = $dn;
if ($oid === '') {
return ['ok' => false, 'error' => 'identity_invalid'];
}
$identity = $this->externalIdentityGateway->findByProviderTidOid($providerKey, $tid, $oid);
if ($identity) {
$userId = (int) ($identity['user_id'] ?? 0);
$user = $this->userReadRepository->find($userId);
if (!$user || empty($user['active'])) {
return ['ok' => false, 'error' => 'user_inactive'];
}
$tenantIds = array_values(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId)));
if (!in_array($tenantId, $tenantIds, true)) {
return ['ok' => false, 'error' => 'tenant_not_assigned'];
}
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
return ['ok' => true, 'user_id' => $userId, 'created' => false];
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$user = $this->userReadRepository->findByEmail($email);
if ($user) {
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0 || empty($user['active'])) {
return ['ok' => false, 'error' => 'user_inactive'];
}
$this->ensureTenantAssignment($userId, $tenantId);
$this->createIdentityLinkForProvider($providerKey, $userId, $tid, $oid, $issuer, $subject, $email);
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
return ['ok' => true, 'user_id' => $userId, 'created' => false];
}
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'email_missing'];
}
$firstName = trim((string) ($attributes['first_name'] ?? ''));
$lastName = trim((string) ($attributes['last_name'] ?? ''));
if ($firstName === '') {
$firstName = ucfirst(explode('@', $email, 2)[0]);
}
$createdId = $this->userWriteRepository->create([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'password' => $this->randomPassword(),
'locale' => null,
'totp_secret' => '',
'theme' => $this->resolveInitialTheme($this->settingsGateway->getAppTheme()),
'primary_tenant_id' => $tenantId,
'current_tenant_id' => $tenantId,
'active' => 1,
'created_by' => null,
'active_changed_at' => gmdate('Y-m-d H:i:s'),
'active_changed_by' => null,
]);
if (!$createdId) {
return ['ok' => false, 'error' => 'user_create_failed'];
}
$userId = (int) $createdId;
$this->userWriteRepository->setEmailVerified($userId);
$this->userAssignmentService->syncTenants($userId, [$tenantId]);
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$this->userAssignmentService->syncRoles($userId, [$defaultRoleId]);
}
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]);
}
$this->createIdentityLinkForProvider($providerKey, $userId, $tid, $oid, $issuer, $subject, $email);
$this->syncProfileFromLdap($userId, $attributes, $ldapConfig);
return ['ok' => true, 'user_id' => $userId, 'created' => true];
}
private function syncProfileFromLdap(int $userId, array $attributes, array $ldapConfig): void
{
if ($userId <= 0 || empty($ldapConfig['sync_profile_on_login'])) {
return;
}
$syncFields = $this->tenantSsoService->normalizeLdapProfileSyncFields($ldapConfig['sync_profile_fields'] ?? []);
if (!$syncFields) {
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
}
if (!$syncFields) {
return;
}
$updates = [];
foreach ($syncFields as $field) {
$value = trim((string) ($attributes[$field] ?? ''));
if ($value === '') {
continue;
}
$updates[$field] = $value;
}
if ($updates) {
$this->userWriteRepository->updateProfileFieldsFromSso($userId, $updates);
}
}
private function createIdentityLinkForProvider(
string $providerKey,
int $userId,
string $tid,
string $oid,
string $issuer,
string $subject,
string $email
): void {
try {
$this->externalIdentityGateway->createLink([
'user_id' => $userId,
'provider' => $providerKey,
'oid' => $oid,
'tid' => $tid,
'issuer' => $issuer,
'subject' => $subject,
'email_at_link_time' => $email,
]);
} catch (\Throwable) {
// Ignore duplicate-link race conditions.
}
}
private function ensureTenantAssignment(int $userId, int $tenantId): void
{
$tenantIds = array_values(array_unique(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId))));

View File

@@ -2,11 +2,13 @@
namespace MintyPHP\Service\Auth;
use MintyPHP\Repository\Tenant\TenantLdapAuthRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepositoryInterface;
class TenantSsoService
{
public const PROVIDER_MICROSOFT = 'microsoft';
public const PROVIDER_LDAP = 'ldap';
private const PROFILE_SYNC_FIELD_OPTIONS = [
'first_name' => 'Sync first name',
@@ -16,11 +18,24 @@ class TenantSsoService
'avatar' => 'Sync avatar image',
];
private const LDAP_PROFILE_SYNC_FIELD_OPTIONS = [
'first_name' => 'Sync first name',
'last_name' => 'Sync last name',
'phone' => 'Sync phone',
'mobile' => 'Sync mobile',
'email' => 'Sync email',
];
private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name'];
private const VALID_LDAP_ENCRYPTION_MODES = ['none', 'starttls', 'ldaps'];
private const VALID_LDAP_BIND_METHODS = ['search_then_bind', 'direct_bind'];
private const VALID_LDAP_SEARCH_SCOPES = ['sub', 'one'];
public function __construct(
private readonly AuthTenantGateway $tenantGateway,
private readonly TenantMicrosoftAuthRepositoryInterface $tenantMicrosoftAuthRepository,
private readonly TenantLdapAuthRepositoryInterface $tenantLdapAuthRepository,
private readonly AuthSettingsGateway $settingsGateway,
private readonly AuthCryptoGateway $cryptoGateway
) {
@@ -31,6 +46,11 @@ class TenantSsoService
return self::PROVIDER_MICROSOFT;
}
public function ldapProviderKey(): string
{
return self::PROVIDER_LDAP;
}
public function findTenantByLoginSlug(string $slug): ?array
{
$slug = trim(strtolower($slug));
@@ -121,12 +141,17 @@ class TenantSsoService
public function isLocalPasswordLoginAllowed(int $tenantId): bool
{
$auth = $this->getTenantMicrosoftAuth($tenantId);
if (!$auth['enabled']) {
return true;
$microsoftAuth = $this->getTenantMicrosoftAuth($tenantId);
if ($microsoftAuth['enabled'] && $microsoftAuth['enforce_microsoft_login']) {
return false;
}
return !$auth['enforce_microsoft_login'];
$ldapAuth = $this->getTenantLdapAuth($tenantId);
if ($ldapAuth['enabled'] && $ldapAuth['enforce_ldap_login']) {
return false;
}
return true;
}
public function resolveTenantLoginMethods(int $tenantId): array
@@ -137,17 +162,24 @@ class TenantSsoService
'local' => false,
'microsoft' => false,
'microsoft_reason' => 'tenant_not_found',
'ldap' => false,
'ldap_reason' => 'tenant_not_found',
];
}
$configResult = $this->getEffectiveMicrosoftProviderConfig($tenant);
$microsoftResult = $this->getEffectiveMicrosoftProviderConfig($tenant);
$ldapResult = $this->getEffectiveLdapProviderConfig($tenant);
return [
'local' => $this->isLocalPasswordLoginAllowed($tenantId),
'microsoft' => !empty($configResult['ok']),
'microsoft_reason' => $configResult['ok'] ?? false
'microsoft' => !empty($microsoftResult['ok']),
'microsoft_reason' => $microsoftResult['ok'] ?? false
? ''
: (string) ($configResult['error'] ?? 'sso_disabled'),
: (string) ($microsoftResult['error'] ?? 'sso_disabled'),
'ldap' => !empty($ldapResult['ok']),
'ldap_reason' => $ldapResult['ok'] ?? false
? ''
: (string) ($ldapResult['error'] ?? 'ldap_disabled'),
];
}
@@ -518,6 +550,357 @@ class TenantSsoService
};
}
// ── LDAP provider methods ─────────────────────────────────────────
public function getTenantLdapAuth(int $tenantId): array
{
$row = $this->tenantLdapAuthRepository->findByTenantId($tenantId) ?? [];
$syncProfileOnLogin = !empty($row['sync_profile_on_login']);
$syncFields = $this->normalizeLdapProfileSyncFields($row['sync_profile_fields'] ?? []);
if ($syncProfileOnLogin && !$syncFields) {
$syncFields = self::DEFAULT_PROFILE_SYNC_FIELDS;
}
return [
'enabled' => !empty($row['enabled']),
'enforce_ldap_login' => !empty($row['enforce_ldap_login']),
'host' => trim((string) ($row['host'] ?? '')),
'port' => (int) ($row['port'] ?? 389),
'encryption_mode' => $this->normalizeLdapEnum($row['encryption_mode'] ?? 'starttls', self::VALID_LDAP_ENCRYPTION_MODES, 'starttls'),
'verify_tls_certificate' => !array_key_exists('verify_tls_certificate', $row) || !empty($row['verify_tls_certificate']),
'base_dn' => trim((string) ($row['base_dn'] ?? '')),
'bind_dn_enc' => trim((string) ($row['bind_dn_enc'] ?? '')),
'bind_password_enc' => trim((string) ($row['bind_password_enc'] ?? '')),
'user_search_filter' => trim((string) ($row['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))')),
'user_search_scope' => $this->normalizeLdapEnum($row['user_search_scope'] ?? 'sub', self::VALID_LDAP_SEARCH_SCOPES, 'sub'),
'bind_method' => $this->normalizeLdapEnum($row['bind_method'] ?? 'search_then_bind', self::VALID_LDAP_BIND_METHODS, 'search_then_bind'),
'bind_username_format' => trim((string) ($row['bind_username_format'] ?? '')),
'unique_id_attribute' => trim((string) ($row['unique_id_attribute'] ?? 'objectGUID')),
'attribute_map' => $this->normalizeLdapAttributeMap($row['attribute_map'] ?? null),
'sync_profile_on_login' => $syncProfileOnLogin,
'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields),
'sync_profile_fields_list' => $syncFields,
'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')),
'auto_remember_mode' => array_key_exists('auto_remember_mode', $row) && $row['auto_remember_mode'] !== null
? (int) $row['auto_remember_mode']
: null,
'network_timeout' => (int) ($row['network_timeout'] ?? 5),
];
}
public function getEffectiveLdapProviderConfig(array $tenant): array
{
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
$auth = $this->getTenantLdapAuth($tenantId);
if (!$auth['enabled']) {
return ['ok' => false, 'error' => 'ldap_disabled'];
}
if ($auth['host'] === '') {
return ['ok' => false, 'error' => 'ldap_host_missing'];
}
if ($auth['base_dn'] === '') {
return ['ok' => false, 'error' => 'ldap_base_dn_missing'];
}
$bindDn = '';
$bindPassword = '';
if ($auth['bind_dn_enc'] !== '') {
try {
$bindDn = $this->cryptoGateway->decryptString($auth['bind_dn_enc']);
} catch (\Throwable) {
return ['ok' => false, 'error' => 'ldap_bind_dn_decrypt_failed'];
}
}
if ($auth['bind_password_enc'] !== '') {
try {
$bindPassword = $this->cryptoGateway->decryptString($auth['bind_password_enc']);
} catch (\Throwable) {
return ['ok' => false, 'error' => 'ldap_bind_password_decrypt_failed'];
}
}
return [
'ok' => true,
'config' => [
'provider' => self::PROVIDER_LDAP,
'host' => $auth['host'],
'port' => $auth['port'],
'encryption_mode' => $auth['encryption_mode'],
'verify_tls_certificate' => $auth['verify_tls_certificate'],
'base_dn' => $auth['base_dn'],
'bind_dn' => $bindDn,
'bind_password' => $bindPassword,
'user_search_filter' => $auth['user_search_filter'],
'user_search_scope' => $auth['user_search_scope'],
'bind_method' => $auth['bind_method'],
'bind_username_format' => $auth['bind_username_format'],
'unique_id_attribute' => $auth['unique_id_attribute'],
'attribute_map' => $auth['attribute_map'],
'sync_profile_on_login' => $auth['sync_profile_on_login'],
'sync_profile_fields' => $auth['sync_profile_fields_list'],
'allowed_domains' => $auth['allowed_domains'],
'enforce_ldap_login' => $auth['enforce_ldap_login'],
'network_timeout' => $auth['network_timeout'],
],
];
}
public function buildLdapUiState(int $tenantId, array $input = []): array
{
$existing = $this->getTenantLdapAuth($tenantId);
$enabled = array_key_exists('ldap_enabled', $input)
? !empty($input['ldap_enabled'])
: $existing['enabled'];
$enforce = array_key_exists('enforce_ldap_login', $input)
? !empty($input['enforce_ldap_login'])
: $existing['enforce_ldap_login'];
$host = array_key_exists('ldap_host', $input)
? trim((string) ($input['ldap_host'] ?? ''))
: $existing['host'];
$baseDn = array_key_exists('ldap_base_dn', $input)
? trim((string) ($input['ldap_base_dn'] ?? ''))
: $existing['base_dn'];
$configComplete = !$enabled || ($host !== '' && $baseDn !== '');
$configErrorCode = '';
if ($enabled && $host === '') {
$configErrorCode = 'ldap_host_missing';
} elseif ($enabled && $baseDn === '') {
$configErrorCode = 'ldap_base_dn_missing';
}
return [
'enabled' => $enabled,
'config_complete' => $configComplete,
'config_error_code' => $configErrorCode,
'config_error_label' => $this->ldapConfigErrorLabel($configErrorCode),
'password_mode' => !$enabled
? 'local_only'
: ($enforce ? 'ldap_only' : 'local_and_ldap'),
];
}
public function saveTenantLdapAuth(int $tenantId, array $input): array
{
$tenant = $this->tenantGateway->findById($tenantId);
if (!$tenant) {
return ['ok' => false, 'errors' => [t('Tenant not found')]];
}
$enabled = !empty($input['ldap_enabled']);
$enforce = !empty($input['enforce_ldap_login']);
$host = trim((string) ($input['ldap_host'] ?? ''));
$port = (int) ($input['ldap_port'] ?? 389);
$encryptionMode = $this->normalizeLdapEnum($input['ldap_encryption_mode'] ?? 'starttls', self::VALID_LDAP_ENCRYPTION_MODES, 'starttls');
$verifyTlsCertificate = !empty($input['ldap_verify_tls_certificate']);
$baseDn = trim((string) ($input['ldap_base_dn'] ?? ''));
$bindDn = trim((string) ($input['ldap_bind_dn'] ?? ''));
$bindPassword = trim((string) ($input['ldap_bind_password'] ?? ''));
$clearBindPassword = !empty($input['clear_ldap_bind_password']);
$userSearchFilter = trim((string) ($input['ldap_user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))'));
$userSearchScope = $this->normalizeLdapEnum($input['ldap_user_search_scope'] ?? 'sub', self::VALID_LDAP_SEARCH_SCOPES, 'sub');
$bindMethod = $this->normalizeLdapEnum($input['ldap_bind_method'] ?? 'search_then_bind', self::VALID_LDAP_BIND_METHODS, 'search_then_bind');
$bindUsernameFormat = trim((string) ($input['ldap_bind_username_format'] ?? ''));
$uniqueIdAttribute = trim((string) ($input['ldap_unique_id_attribute'] ?? 'objectGUID'));
$attributeMap = $this->normalizeLdapAttributeMap($input['ldap_attribute_map'] ?? null);
$syncProfileOnLogin = !empty($input['ldap_sync_profile_on_login']);
$syncProfileFields = $this->normalizeLdapProfileSyncFields($input['ldap_sync_profile_fields'] ?? []);
if ($syncProfileOnLogin && !$syncProfileFields) {
$syncProfileFields = self::DEFAULT_PROFILE_SYNC_FIELDS;
}
$syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields);
$allowedDomains = $this->normalizeDomains((string) ($input['ldap_allowed_domains'] ?? ''));
$networkTimeout = max(1, min(30, (int) ($input['ldap_network_timeout'] ?? 5)));
$errors = [];
if ($enabled && $host === '') {
$errors[] = t('LDAP host is required');
}
if ($enabled && $baseDn === '') {
$errors[] = t('LDAP base DN is required');
}
if ($enabled && $port < 1 || $port > 65535) {
$errors[] = t('LDAP port must be between 1 and 65535');
}
if ($enabled && !str_contains($userSearchFilter, '%s')) {
$errors[] = t('Search filter must contain %s placeholder');
}
if ($enabled && $enforce && ($host === '' || $baseDn === '')) {
$errors[] = t('LDAP-only login requires a complete configuration');
}
if ($bindMethod === 'direct_bind' && $bindUsernameFormat === '') {
$errors[] = t('Direct bind method requires a username format');
}
$existing = $this->getTenantLdapAuth($tenantId);
$bindDnEnc = (string) ($existing['bind_dn_enc'] ?? '');
if ($bindDn !== '') {
if (!$this->cryptoGateway->isConfigured()) {
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
} else {
try {
$bindDnEnc = $this->cryptoGateway->encryptString($bindDn);
} catch (\Throwable) {
$errors[] = t('Bind DN could not be encrypted');
}
}
}
$bindPasswordEnc = (string) ($existing['bind_password_enc'] ?? '');
if ($clearBindPassword) {
$bindPasswordEnc = '';
} elseif ($bindPassword !== '') {
if (!$this->cryptoGateway->isConfigured()) {
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
} else {
try {
$bindPasswordEnc = $this->cryptoGateway->encryptString($bindPassword);
} catch (\Throwable) {
$errors[] = t('Bind password could not be encrypted');
}
}
}
if ($errors) {
return ['ok' => false, 'errors' => $errors];
}
$autoRememberMode = $this->normalizeAutoRememberMode($input['ldap_auto_remember_mode'] ?? null);
$attributeMapJson = json_encode($attributeMap, JSON_THROW_ON_ERROR);
$saved = $this->tenantLdapAuthRepository->upsertByTenantId($tenantId, [
'enabled' => $enabled,
'enforce_ldap_login' => $enabled ? $enforce : false,
'host' => $host,
'port' => $port,
'encryption_mode' => $encryptionMode,
'verify_tls_certificate' => $verifyTlsCertificate,
'base_dn' => $baseDn,
'bind_dn_enc' => $bindDnEnc !== '' ? $bindDnEnc : null,
'bind_password_enc' => $bindPasswordEnc !== '' ? $bindPasswordEnc : null,
'user_search_filter' => $userSearchFilter,
'user_search_scope' => $userSearchScope,
'bind_method' => $bindMethod,
'bind_username_format' => $bindUsernameFormat !== '' ? $bindUsernameFormat : null,
'unique_id_attribute' => $uniqueIdAttribute,
'attribute_map' => $attributeMapJson,
'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false,
'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null,
'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains,
'auto_remember_mode' => $autoRememberMode,
'network_timeout' => $networkTimeout,
]);
if (!$saved) {
return ['ok' => false, 'errors' => [t('Tenant LDAP settings could not be saved')]];
}
return ['ok' => true];
}
public function shouldAutoRememberOnLdapLogin(int $tenantId): bool
{
$auth = $this->getTenantLdapAuth($tenantId);
$mode = $auth['auto_remember_mode'] ?? null;
if ($mode === 1) {
return true;
}
if ($mode === 0) {
return false;
}
return false;
}
public function ldapProfileSyncFieldOptions(): array
{
return self::LDAP_PROFILE_SYNC_FIELD_OPTIONS;
}
public function normalizeLdapProfileSyncFields($raw): array
{
if (is_string($raw)) {
$raw = preg_split('/[\s,;]+/', $raw) ?: [];
} elseif (!is_array($raw)) {
return [];
}
$allowed = array_fill_keys(array_keys(self::LDAP_PROFILE_SYNC_FIELD_OPTIONS), true);
$normalized = [];
foreach ($raw as $item) {
$key = strtolower(trim((string) $item));
if ($key === '' || !isset($allowed[$key])) {
continue;
}
$normalized[$key] = true;
}
return array_keys($normalized);
}
public function normalizeLdapAttributeMap(mixed $raw): array
{
$default = [
'first_name' => 'givenName',
'last_name' => 'sn',
'email' => 'mail',
'phone' => 'telephoneNumber',
'mobile' => 'mobile',
];
if ($raw === null || $raw === '') {
return $default;
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return $default;
}
$raw = $decoded;
}
if (!is_array($raw)) {
return $default;
}
$result = [];
foreach ($raw as $key => $value) {
$key = trim((string) $key);
$value = trim((string) $value);
if ($key !== '' && $value !== '') {
$result[$key] = $value;
}
}
return $result ?: $default;
}
private function ldapConfigErrorLabel(string $errorCode): string
{
return match ($errorCode) {
'ldap_host_missing' => t('LDAP host is required'),
'ldap_base_dn_missing' => t('LDAP base DN is required'),
'ldap_bind_dn_decrypt_failed' => t('Bind DN could not be decrypted'),
'ldap_bind_password_decrypt_failed' => t('Bind password could not be decrypted'),
default => '',
};
}
private function normalizeLdapEnum(string $value, array $valid, string $default): string
{
$value = strtolower(trim($value));
return in_array($value, $valid, true) ? $value : $default;
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));