Resolve all non-false-positive PHPStan 2 findings, reducing the baseline from 486 to 382 entries (only unused-public false positives remain). Categories fixed: - Remove 39 redundant type checks (is_string, is_array, is_object, method_exists) - Remove 12 no-op array_values() on already-sequential lists - Simplify 13 null-coalesce on guaranteed offsets - Remove 8 always-true instanceof checks - Replace 12 redundant test assertions (assertTrue(true) → addToAssertionCount) - Simplify 6 unnecessary nullsafe operators (?-> → ->) - Fix 6 always-true !== '' comparisons - Fix remaining: unset.offset, return.unusedType, staticMethod narrowing 53 files changed across core/, modules/, pages/, and tests/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
978 lines
40 KiB
PHP
978 lines
40 KiB
PHP
<?php
|
|
|
|
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',
|
|
'last_name' => 'Sync last name',
|
|
'display_name' => 'Sync display name',
|
|
'job_title' => 'Sync job title',
|
|
'phone' => 'Sync phone',
|
|
'mobile' => 'Sync mobile',
|
|
'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
|
|
) {
|
|
}
|
|
|
|
public function microsoftProviderKey(): string
|
|
{
|
|
return self::PROVIDER_MICROSOFT;
|
|
}
|
|
|
|
public function ldapProviderKey(): string
|
|
{
|
|
return self::PROVIDER_LDAP;
|
|
}
|
|
|
|
public function findTenantByLoginSlug(string $slug): ?array
|
|
{
|
|
$slug = trim(strtolower($slug));
|
|
if ($slug === '') {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/^[a-f0-9-]{36}$/', $slug)) {
|
|
$tenant = $this->tenantGateway->findByUuid($slug);
|
|
if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') {
|
|
return $tenant;
|
|
}
|
|
}
|
|
|
|
$matches = [];
|
|
foreach ($this->tenantGateway->list() as $tenant) {
|
|
if ((string) ($tenant['status'] ?? 'active') !== 'active') {
|
|
continue;
|
|
}
|
|
if ($this->slugify((string) ($tenant['description'] ?? '')) === $slug) {
|
|
$matches[] = $tenant;
|
|
}
|
|
}
|
|
|
|
// Reject ambiguous slugs — if two tenants have the same name-slug, neither matches.
|
|
if (count($matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
return $matches[0];
|
|
}
|
|
|
|
public function tenantLoginSlug(array $tenant): string
|
|
{
|
|
$slug = $this->slugify((string) ($tenant['description'] ?? ''));
|
|
if ($slug !== '') {
|
|
return $slug;
|
|
}
|
|
|
|
return strtolower((string) ($tenant['uuid'] ?? ''));
|
|
}
|
|
|
|
public function getTenantMicrosoftAuth(int $tenantId): array
|
|
{
|
|
$row = $this->tenantMicrosoftAuthRepository->findByTenantId($tenantId) ?? [];
|
|
$syncProfileOnLogin = !empty($row['sync_profile_on_login']);
|
|
$syncFields = $this->normalizeProfileSyncFields($row['sync_profile_fields'] ?? []);
|
|
if ($syncProfileOnLogin && !$syncFields) {
|
|
$syncFields = $this->defaultProfileSyncFields();
|
|
}
|
|
|
|
return [
|
|
'enabled' => !empty($row['enabled']),
|
|
'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']),
|
|
'sync_profile_on_login' => $syncProfileOnLogin,
|
|
'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields),
|
|
'sync_profile_fields_list' => $syncFields,
|
|
'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))),
|
|
'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')),
|
|
'use_shared_app' => !array_key_exists('use_shared_app', $row) || !empty($row['use_shared_app']),
|
|
'client_id_override' => trim((string) ($row['client_id_override'] ?? '')),
|
|
'client_secret_override_enc' => trim((string) ($row['client_secret_override_enc'] ?? '')),
|
|
'auto_remember_mode' => array_key_exists('auto_remember_mode', $row) && $row['auto_remember_mode'] !== null
|
|
? (int) $row['auto_remember_mode']
|
|
: null,
|
|
];
|
|
}
|
|
|
|
public function buildMicrosoftUiState(int $tenantId, array $input = []): array
|
|
{
|
|
$existing = $this->getTenantMicrosoftAuth($tenantId);
|
|
$state = $this->normalizeMicrosoftInputState($input, $existing);
|
|
$configState = $this->evaluateMicrosoftConfigState($state);
|
|
|
|
return [
|
|
'enabled' => $state['enabled'],
|
|
'config_complete' => !$state['enabled'] || $configState['complete'],
|
|
'config_error_code' => (string) ($configState['error_code'] ?? ''),
|
|
'config_error_label' => $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')),
|
|
'password_mode' => !$state['enabled']
|
|
? 'local_only'
|
|
: ($state['enforce_microsoft_login'] ? 'microsoft_only' : 'local_and_microsoft'),
|
|
'credential_source' => $state['use_shared_app'] ? 'shared' : 'override',
|
|
'sync_needs_graph' => $state['sync_profile_on_login']
|
|
&& $this->profileSyncFieldsRequireGraph($state['sync_profile_fields']),
|
|
];
|
|
}
|
|
|
|
public function isLocalPasswordLoginAllowed(int $tenantId): bool
|
|
{
|
|
$microsoftAuth = $this->getTenantMicrosoftAuth($tenantId);
|
|
if ($microsoftAuth['enabled'] && $microsoftAuth['enforce_microsoft_login']) {
|
|
return false;
|
|
}
|
|
|
|
$ldapAuth = $this->getTenantLdapAuth($tenantId);
|
|
if ($ldapAuth['enabled'] && $ldapAuth['enforce_ldap_login']) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Discover tenants that accept SSO login for the given email domain.
|
|
* Only tenants with explicitly configured (non-empty) allowed_domains are returned.
|
|
* Returns SSO-only methods (local is always false since the user has no local account).
|
|
*
|
|
* @return list<array{id: int, methods: array}> Tenant candidates with SSO methods
|
|
*/
|
|
public function discoverTenantsByEmailDomain(string $email): array
|
|
{
|
|
$email = strtolower(trim($email));
|
|
$parts = explode('@', $email);
|
|
if (count($parts) !== 2 || $parts[1] === '') {
|
|
return [];
|
|
}
|
|
$domain = $parts[1];
|
|
|
|
$microsoftMatches = $this->tenantMicrosoftAuthRepository->findEnabledWithExplicitDomain($domain);
|
|
$ldapMatches = $this->tenantLdapAuthRepository->findEnabledWithExplicitDomain($domain);
|
|
|
|
$tenantIds = [];
|
|
foreach ($microsoftMatches as $match) {
|
|
$tenantIds[(int) $match['tenant_id']] = true;
|
|
}
|
|
foreach ($ldapMatches as $match) {
|
|
$tenantIds[(int) $match['tenant_id']] = true;
|
|
}
|
|
|
|
if (!$tenantIds) {
|
|
return [];
|
|
}
|
|
|
|
$candidates = [];
|
|
foreach (array_keys($tenantIds) as $tenantId) {
|
|
$tenant = $this->tenantGateway->findById($tenantId);
|
|
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
|
|
continue;
|
|
}
|
|
|
|
$methods = $this->resolveTenantLoginMethods($tenantId);
|
|
|
|
// Discovery candidates never offer local password (user has no local account)
|
|
$methods['local'] = false;
|
|
|
|
// Skip tenants where no SSO method is actually functional
|
|
if (empty($methods['microsoft']) && empty($methods['ldap'])) {
|
|
continue;
|
|
}
|
|
|
|
$candidates[] = [
|
|
'id' => $tenantId,
|
|
'tenant' => $tenant,
|
|
'methods' => $methods,
|
|
];
|
|
}
|
|
|
|
return $candidates;
|
|
}
|
|
|
|
public function resolveTenantLoginMethods(int $tenantId): array
|
|
{
|
|
$tenant = $this->tenantGateway->findById($tenantId);
|
|
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
|
|
return [
|
|
'local' => false,
|
|
'microsoft' => false,
|
|
'microsoft_reason' => 'tenant_not_found',
|
|
'ldap' => false,
|
|
'ldap_reason' => 'tenant_not_found',
|
|
];
|
|
}
|
|
|
|
$microsoftResult = $this->getEffectiveMicrosoftProviderConfig($tenant);
|
|
$ldapResult = $this->getEffectiveLdapProviderConfig($tenant);
|
|
|
|
return [
|
|
'local' => $this->isLocalPasswordLoginAllowed($tenantId),
|
|
'microsoft' => !empty($microsoftResult['ok']),
|
|
'microsoft_reason' => $microsoftResult['ok'] ?? false
|
|
? ''
|
|
: (string) ($microsoftResult['error'] ?? 'sso_disabled'),
|
|
'ldap' => !empty($ldapResult['ok']),
|
|
'ldap_reason' => $ldapResult['ok'] ?? false
|
|
? ''
|
|
: (string) ($ldapResult['error'] ?? 'ldap_disabled'),
|
|
];
|
|
}
|
|
|
|
public function getEffectiveMicrosoftProviderConfig(array $tenant): array
|
|
{
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
if ($tenantId <= 0) {
|
|
return ['ok' => false, 'error' => 'tenant_not_found'];
|
|
}
|
|
|
|
$auth = $this->getTenantMicrosoftAuth($tenantId);
|
|
if (!$auth['enabled']) {
|
|
return ['ok' => false, 'error' => 'sso_disabled'];
|
|
}
|
|
|
|
$entraTenantId = $auth['entra_tenant_id'];
|
|
if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) {
|
|
return ['ok' => false, 'error' => 'tenant_id_invalid'];
|
|
}
|
|
|
|
$useSharedApp = $auth['use_shared_app'];
|
|
$clientId = '';
|
|
$clientSecret = '';
|
|
|
|
if ($useSharedApp) {
|
|
$clientId = $this->settingsGateway->getMicrosoftSharedClientId();
|
|
$clientSecret = $this->settingsGateway->getMicrosoftSharedClientSecret();
|
|
if (trim($clientId) === '' || trim($clientSecret) === '') {
|
|
return ['ok' => false, 'error' => 'shared_credentials_missing'];
|
|
}
|
|
} else {
|
|
$clientId = (string) $auth['client_id_override'];
|
|
if (trim($clientId) === '') {
|
|
return ['ok' => false, 'error' => 'client_id_override_required'];
|
|
}
|
|
if ($auth['client_secret_override_enc'] === '') {
|
|
return ['ok' => false, 'error' => 'client_secret_override_required'];
|
|
}
|
|
try {
|
|
$clientSecret = $this->cryptoGateway->decryptString($auth['client_secret_override_enc']);
|
|
} catch (\Throwable $exception) {
|
|
return ['ok' => false, 'error' => 'secret_invalid'];
|
|
}
|
|
if (trim($clientSecret) === '') {
|
|
return ['ok' => false, 'error' => 'client_secret_override_required'];
|
|
}
|
|
}
|
|
|
|
$clientId = trim($clientId);
|
|
$clientSecret = trim($clientSecret);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'config' => [
|
|
'provider' => self::PROVIDER_MICROSOFT,
|
|
'tenant_id' => $entraTenantId,
|
|
'client_id' => $clientId,
|
|
'client_secret' => $clientSecret,
|
|
'allowed_domains' => $auth['allowed_domains'],
|
|
'enforce_microsoft_login' => $auth['enforce_microsoft_login'],
|
|
'sync_profile_on_login' => $auth['sync_profile_on_login'],
|
|
'sync_profile_fields' => $auth['sync_profile_fields_list'],
|
|
'sync_profile_needs_graph' => $auth['sync_profile_on_login']
|
|
&& $this->profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']),
|
|
'authority' => $this->settingsGateway->getMicrosoftAuthority(),
|
|
'use_shared_app' => $useSharedApp,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function saveTenantMicrosoftAuth(int $tenantId, array $input): array
|
|
{
|
|
$tenant = $this->tenantGateway->findById($tenantId);
|
|
if (!$tenant) {
|
|
return ['ok' => false, 'errors' => [t('Tenant not found')]];
|
|
}
|
|
|
|
$enabled = !empty($input['microsoft_enabled']);
|
|
$enforce = !empty($input['enforce_microsoft_login']);
|
|
$entraTenantId = strtolower(trim((string) ($input['entra_tenant_id'] ?? '')));
|
|
$allowedDomains = $this->normalizeDomains((string) ($input['allowed_domains'] ?? ''));
|
|
$syncProfileOnLogin = !empty($input['sync_profile_on_login']);
|
|
$syncProfileFields = $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []);
|
|
if ($syncProfileOnLogin && !$syncProfileFields) {
|
|
$syncProfileFields = $this->defaultProfileSyncFields();
|
|
}
|
|
$syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields);
|
|
$useSharedApp = !empty($input['use_shared_app']);
|
|
$clientIdOverride = trim((string) ($input['client_id_override'] ?? ''));
|
|
$clientSecretOverride = trim((string) ($input['client_secret_override'] ?? ''));
|
|
$clearSecret = !empty($input['clear_client_secret_override']);
|
|
|
|
$errors = [];
|
|
|
|
$existing = $this->getTenantMicrosoftAuth($tenantId);
|
|
$clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? '');
|
|
if ($clearSecret) {
|
|
$clientSecretOverrideEnc = '';
|
|
} elseif ($clientSecretOverride !== '') {
|
|
if (!$this->cryptoGateway->isConfigured()) {
|
|
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
|
|
} else {
|
|
try {
|
|
$clientSecretOverrideEnc = $this->cryptoGateway->encryptString($clientSecretOverride);
|
|
} catch (\Throwable $exception) {
|
|
$errors[] = t('Client secret could not be encrypted');
|
|
}
|
|
}
|
|
}
|
|
|
|
$stateForValidation = [
|
|
'enabled' => $enabled,
|
|
'enforce_microsoft_login' => $enforce,
|
|
'sync_profile_on_login' => $syncProfileOnLogin,
|
|
'sync_profile_fields' => $syncProfileFields,
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'use_shared_app' => $useSharedApp,
|
|
'client_id_override' => $clientIdOverride,
|
|
'client_secret_override_enc' => $clientSecretOverrideEnc,
|
|
];
|
|
$configState = $this->evaluateMicrosoftConfigState($stateForValidation);
|
|
if ($enabled && !($configState['complete'] ?? false)) {
|
|
$label = $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? ''));
|
|
if ($label !== '') {
|
|
$errors[] = $label;
|
|
}
|
|
}
|
|
if ($enabled && $enforce && !($configState['complete'] ?? false)) {
|
|
$errors[] = t('Microsoft-only login requires a complete configuration');
|
|
}
|
|
|
|
if ($errors) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$autoRememberMode = $this->normalizeAutoRememberMode($input['microsoft_auto_remember_mode'] ?? null);
|
|
|
|
$saved = $this->tenantMicrosoftAuthRepository->upsertByTenantId($tenantId, [
|
|
'enabled' => $enabled,
|
|
'enforce_microsoft_login' => $enabled ? $enforce : false,
|
|
'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false,
|
|
'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null,
|
|
'entra_tenant_id' => $enabled ? $entraTenantId : null,
|
|
'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains,
|
|
'use_shared_app' => $useSharedApp,
|
|
'client_id_override' => $useSharedApp ? null : ($clientIdOverride !== '' ? $clientIdOverride : null),
|
|
'client_secret_override_enc' => $useSharedApp ? null : ($clientSecretOverrideEnc !== '' ? $clientSecretOverrideEnc : null),
|
|
'auto_remember_mode' => $autoRememberMode,
|
|
]);
|
|
|
|
if (!$saved) {
|
|
return ['ok' => false, 'errors' => [t('Tenant SSO settings could not be saved')]];
|
|
}
|
|
|
|
return ['ok' => true];
|
|
}
|
|
|
|
// Empty allowedDomains list means all email domains are permitted.
|
|
public function isEmailDomainAllowed(string $email, array $allowedDomains): bool
|
|
{
|
|
$allowedDomains = array_values(array_filter(array_map(
|
|
static fn ($domain): string => strtolower(trim((string) $domain)),
|
|
$allowedDomains
|
|
), static fn (string $domain): bool => $domain !== ''));
|
|
|
|
if (!$allowedDomains) {
|
|
return true;
|
|
}
|
|
|
|
$parts = explode('@', strtolower(trim($email)));
|
|
if (count($parts) !== 2) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($parts[1], $allowedDomains, true);
|
|
}
|
|
|
|
public function profileSyncFieldOptions(): array
|
|
{
|
|
return self::PROFILE_SYNC_FIELD_OPTIONS;
|
|
}
|
|
|
|
public function defaultProfileSyncFields(): array
|
|
{
|
|
return self::DEFAULT_PROFILE_SYNC_FIELDS;
|
|
}
|
|
|
|
public function normalizeProfileSyncFields($raw): array
|
|
{
|
|
if (is_string($raw)) {
|
|
$raw = preg_split('/[\s,;]+/', $raw) ?: [];
|
|
} elseif (!is_array($raw)) {
|
|
return [];
|
|
}
|
|
|
|
$allowed = array_fill_keys(array_keys(self::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 profileSyncFieldsCsv(array $fields): string
|
|
{
|
|
$fields = $this->normalizeProfileSyncFields($fields);
|
|
return implode(',', $fields);
|
|
}
|
|
|
|
public function profileSyncFieldsRequireGraph(array $fields): bool
|
|
{
|
|
$fields = $this->normalizeProfileSyncFields($fields);
|
|
return in_array('phone', $fields, true)
|
|
|| in_array('mobile', $fields, true)
|
|
|| in_array('avatar', $fields, true)
|
|
|| in_array('job_title', $fields, true)
|
|
|| in_array('display_name', $fields, true);
|
|
}
|
|
|
|
public function shouldAutoRememberOnMicrosoftLogin(int $tenantId): bool
|
|
{
|
|
$auth = $this->getTenantMicrosoftAuth($tenantId);
|
|
$mode = $auth['auto_remember_mode'] ?? null;
|
|
if ($mode === 1) {
|
|
return true;
|
|
}
|
|
if ($mode === 0) {
|
|
return false;
|
|
}
|
|
|
|
return $this->settingsGateway->isMicrosoftAutoRememberDefault();
|
|
}
|
|
|
|
private function normalizeAutoRememberMode(mixed $value): ?int
|
|
{
|
|
if ($value === null || $value === '' || $value === 'inherit') {
|
|
return null;
|
|
}
|
|
$intVal = (int) $value;
|
|
return in_array($intVal, [0, 1], true) ? $intVal : null;
|
|
}
|
|
|
|
private function normalizeDomains(string $raw): string
|
|
{
|
|
$parts = preg_split('/[,\n;\r]+/', $raw) ?: [];
|
|
$domains = [];
|
|
foreach ($parts as $part) {
|
|
$domain = strtolower(trim($part));
|
|
$domain = preg_replace('/^@+/', '', $domain);
|
|
if ($domain === '') {
|
|
continue;
|
|
}
|
|
if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $domain)) {
|
|
continue;
|
|
}
|
|
$domains[] = $domain;
|
|
}
|
|
$domains = array_values(array_unique($domains));
|
|
return implode(',', $domains);
|
|
}
|
|
|
|
private function normalizeMicrosoftInputState(array $input, array $existing): array
|
|
{
|
|
$enabled = array_key_exists('microsoft_enabled', $input)
|
|
? !empty($input['microsoft_enabled'])
|
|
: !empty($existing['enabled']);
|
|
$enforce = array_key_exists('enforce_microsoft_login', $input)
|
|
? !empty($input['enforce_microsoft_login'])
|
|
: !empty($existing['enforce_microsoft_login']);
|
|
$syncProfileOnLogin = array_key_exists('sync_profile_on_login', $input)
|
|
? !empty($input['sync_profile_on_login'])
|
|
: !empty($existing['sync_profile_on_login']);
|
|
$syncProfileFields = array_key_exists('sync_profile_fields', $input)
|
|
? $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? [])
|
|
: $this->normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []);
|
|
if ($syncProfileOnLogin && !$syncProfileFields) {
|
|
$syncProfileFields = $this->defaultProfileSyncFields();
|
|
}
|
|
|
|
$entraTenantId = array_key_exists('entra_tenant_id', $input)
|
|
? strtolower(trim((string) ($input['entra_tenant_id'] ?? '')))
|
|
: strtolower(trim((string) ($existing['entra_tenant_id'] ?? '')));
|
|
$useSharedApp = array_key_exists('use_shared_app', $input)
|
|
? !empty($input['use_shared_app'])
|
|
: !empty($existing['use_shared_app']);
|
|
$clientIdOverride = array_key_exists('client_id_override', $input)
|
|
? trim((string) ($input['client_id_override'] ?? ''))
|
|
: trim((string) ($existing['client_id_override'] ?? ''));
|
|
|
|
$clientSecretOverrideEnc = trim((string) ($existing['client_secret_override_enc'] ?? ''));
|
|
if (array_key_exists('clear_client_secret_override', $input) && !empty($input['clear_client_secret_override'])) {
|
|
$clientSecretOverrideEnc = '';
|
|
} elseif (trim((string) ($input['client_secret_override'] ?? '')) !== '') {
|
|
// For UI status, a newly typed secret is considered present before persisting.
|
|
$clientSecretOverrideEnc = '__pending_secret__';
|
|
}
|
|
|
|
return [
|
|
'enabled' => $enabled,
|
|
'enforce_microsoft_login' => $enforce,
|
|
'sync_profile_on_login' => $syncProfileOnLogin,
|
|
'sync_profile_fields' => $syncProfileFields,
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'use_shared_app' => $useSharedApp,
|
|
'client_id_override' => $clientIdOverride,
|
|
'client_secret_override_enc' => $clientSecretOverrideEnc,
|
|
];
|
|
}
|
|
|
|
private function evaluateMicrosoftConfigState(array $state): array
|
|
{
|
|
if (empty($state['enabled'])) {
|
|
return ['complete' => true, 'error_code' => ''];
|
|
}
|
|
|
|
$entraTenantId = strtolower(trim((string) ($state['entra_tenant_id'] ?? '')));
|
|
if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) {
|
|
return ['complete' => false, 'error_code' => 'tenant_id_invalid'];
|
|
}
|
|
|
|
if (!empty($state['use_shared_app'])) {
|
|
$sharedClientId = trim($this->settingsGateway->getMicrosoftSharedClientId());
|
|
$sharedClientSecret = trim($this->settingsGateway->getMicrosoftSharedClientSecret());
|
|
if ($sharedClientId === '' || $sharedClientSecret === '') {
|
|
return ['complete' => false, 'error_code' => 'shared_credentials_missing'];
|
|
}
|
|
return ['complete' => true, 'error_code' => ''];
|
|
}
|
|
|
|
$clientIdOverride = trim((string) ($state['client_id_override'] ?? ''));
|
|
if ($clientIdOverride === '') {
|
|
return ['complete' => false, 'error_code' => 'client_id_override_required'];
|
|
}
|
|
|
|
$secretEnc = trim((string) ($state['client_secret_override_enc'] ?? ''));
|
|
if ($secretEnc === '') {
|
|
return ['complete' => false, 'error_code' => 'client_secret_override_required'];
|
|
}
|
|
if ($secretEnc !== '__pending_secret__') {
|
|
try {
|
|
$secret = trim($this->cryptoGateway->decryptString($secretEnc));
|
|
} catch (\Throwable $exception) {
|
|
return ['complete' => false, 'error_code' => 'secret_invalid'];
|
|
}
|
|
if ($secret === '') {
|
|
return ['complete' => false, 'error_code' => 'client_secret_override_required'];
|
|
}
|
|
}
|
|
|
|
return ['complete' => true, 'error_code' => ''];
|
|
}
|
|
|
|
private function microsoftConfigErrorLabel(string $errorCode): string
|
|
{
|
|
return match ($errorCode) {
|
|
'tenant_id_invalid' => t('Entra tenant ID is invalid'),
|
|
'shared_credentials_missing' => t('Shared credentials missing'),
|
|
'client_id_override_required' => t('Client ID override is required'),
|
|
'client_secret_override_required' => t('Client secret override is required'),
|
|
'secret_invalid' => t('Client secret override is invalid'),
|
|
'client_credentials_missing' => t('Client credentials are missing'),
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
// ── 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);
|
|
try {
|
|
$attributeMapJson = json_encode($attributeMap, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException) {
|
|
return ['ok' => false, 'errors' => [t('LDAP attribute map could not be encoded')]];
|
|
}
|
|
|
|
$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));
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
|
return trim($value, '-');
|
|
}
|
|
}
|