add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
485
lib/Service/Auth/TenantSsoService.php
Normal file
485
lib/Service/Auth/TenantSsoService.php
Normal file
@@ -0,0 +1,485 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Settings\SettingService;
|
||||
use MintyPHP\Support\Crypto;
|
||||
|
||||
class TenantSsoService
|
||||
{
|
||||
public const PROVIDER_MICROSOFT = 'microsoft';
|
||||
private const PROFILE_SYNC_FIELD_OPTIONS = [
|
||||
'first_name' => 'Sync first name',
|
||||
'last_name' => 'Sync last name',
|
||||
'phone' => 'Sync phone',
|
||||
'mobile' => 'Sync mobile',
|
||||
'avatar' => 'Sync avatar image',
|
||||
];
|
||||
private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name'];
|
||||
|
||||
public static function findTenantByLoginSlug(string $slug): ?array
|
||||
{
|
||||
$slug = trim(strtolower($slug));
|
||||
if ($slug === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-f0-9-]{36}$/', $slug)) {
|
||||
$tenant = TenantRepository::findByUuid($slug);
|
||||
if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
foreach (TenantRepository::list() as $tenant) {
|
||||
if ((string) ($tenant['status'] ?? 'active') !== 'active') {
|
||||
continue;
|
||||
}
|
||||
if (self::slugify((string) ($tenant['description'] ?? '')) === $slug) {
|
||||
$matches[] = $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
public static function tenantLoginSlug(array $tenant): string
|
||||
{
|
||||
$slug = self::slugify((string) ($tenant['description'] ?? ''));
|
||||
if ($slug !== '') {
|
||||
return $slug;
|
||||
}
|
||||
return strtolower((string) ($tenant['uuid'] ?? ''));
|
||||
}
|
||||
|
||||
public static function getTenantMicrosoftAuth(int $tenantId): array
|
||||
{
|
||||
$row = TenantMicrosoftAuthRepository::findByTenantId($tenantId) ?? [];
|
||||
$syncProfileOnLogin = !empty($row['sync_profile_on_login']);
|
||||
$syncFields = self::normalizeProfileSyncFields($row['sync_profile_fields'] ?? []);
|
||||
if ($syncProfileOnLogin && !$syncFields) {
|
||||
$syncFields = self::defaultProfileSyncFields();
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($row['enabled']),
|
||||
'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']),
|
||||
'sync_profile_on_login' => $syncProfileOnLogin,
|
||||
'sync_profile_fields' => self::profileSyncFieldsCsv($syncFields),
|
||||
'sync_profile_fields_list' => $syncFields,
|
||||
'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))),
|
||||
'allowed_domains' => self::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'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildMicrosoftUiState(int $tenantId, array $input = []): array
|
||||
{
|
||||
$existing = self::getTenantMicrosoftAuth($tenantId);
|
||||
$state = self::normalizeMicrosoftInputState($input, $existing);
|
||||
$configState = self::evaluateMicrosoftConfigState($state);
|
||||
|
||||
return [
|
||||
'enabled' => $state['enabled'],
|
||||
'config_complete' => !$state['enabled'] || $configState['complete'],
|
||||
'config_error_code' => (string) ($configState['error_code'] ?? ''),
|
||||
'config_error_label' => self::microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')),
|
||||
'password_mode' => $state['enabled'] && $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']
|
||||
&& self::profileSyncFieldsRequireGraph($state['sync_profile_fields']),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isLocalPasswordLoginAllowed(int $tenantId): bool
|
||||
{
|
||||
$auth = self::getTenantMicrosoftAuth($tenantId);
|
||||
if (!$auth['enabled']) {
|
||||
return true;
|
||||
}
|
||||
return !$auth['enforce_microsoft_login'];
|
||||
}
|
||||
|
||||
public static function resolveTenantLoginMethods(int $tenantId): array
|
||||
{
|
||||
$tenant = TenantRepository::find($tenantId);
|
||||
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
|
||||
return [
|
||||
'local' => false,
|
||||
'microsoft' => false,
|
||||
'microsoft_reason' => 'tenant_not_found',
|
||||
];
|
||||
}
|
||||
|
||||
$configResult = self::getEffectiveMicrosoftProviderConfig($tenant);
|
||||
return [
|
||||
'local' => self::isLocalPasswordLoginAllowed($tenantId),
|
||||
'microsoft' => !empty($configResult['ok']),
|
||||
'microsoft_reason' => $configResult['ok'] ?? false
|
||||
? ''
|
||||
: (string) ($configResult['error'] ?? 'sso_disabled'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEffectiveMicrosoftProviderConfig(array $tenant): array
|
||||
{
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
|
||||
$auth = self::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 = (string) (SettingService::getMicrosoftSharedClientId() ?? '');
|
||||
$clientSecret = (string) (SettingService::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'];
|
||||
}
|
||||
if ($auth['client_secret_override_enc'] !== '') {
|
||||
try {
|
||||
$clientSecret = Crypto::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);
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
return ['ok' => false, 'error' => 'client_credentials_missing'];
|
||||
}
|
||||
|
||||
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']
|
||||
&& self::profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']),
|
||||
'authority' => SettingService::getMicrosoftAuthority(),
|
||||
'use_shared_app' => $useSharedApp,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function saveTenantMicrosoftAuth(int $tenantId, array $input): array
|
||||
{
|
||||
$tenant = TenantRepository::find($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 = self::normalizeDomains((string) ($input['allowed_domains'] ?? ''));
|
||||
$syncProfileOnLogin = !empty($input['sync_profile_on_login']);
|
||||
$syncProfileFields = self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? []);
|
||||
if ($syncProfileOnLogin && !$syncProfileFields) {
|
||||
$syncProfileFields = self::defaultProfileSyncFields();
|
||||
}
|
||||
$syncProfileFieldsCsv = self::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 = self::getTenantMicrosoftAuth($tenantId);
|
||||
$clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? '');
|
||||
if ($clearSecret) {
|
||||
$clientSecretOverrideEnc = '';
|
||||
} elseif ($clientSecretOverride !== '') {
|
||||
if (!Crypto::isConfigured()) {
|
||||
$errors[] = t('APP_CRYPTO_KEY is missing or invalid');
|
||||
} else {
|
||||
try {
|
||||
$clientSecretOverrideEnc = Crypto::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 = self::evaluateMicrosoftConfigState($stateForValidation);
|
||||
if ($enabled && !($configState['complete'] ?? false)) {
|
||||
$label = self::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];
|
||||
}
|
||||
|
||||
$saved = 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),
|
||||
]);
|
||||
|
||||
if (!$saved) {
|
||||
return ['ok' => false, 'errors' => [t('Tenant SSO settings could not be saved')]];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static 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 static function profileSyncFieldOptions(): array
|
||||
{
|
||||
return self::PROFILE_SYNC_FIELD_OPTIONS;
|
||||
}
|
||||
|
||||
public static function defaultProfileSyncFields(): array
|
||||
{
|
||||
return self::DEFAULT_PROFILE_SYNC_FIELDS;
|
||||
}
|
||||
|
||||
public static 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 static function profileSyncFieldsCsv(array $fields): string
|
||||
{
|
||||
$fields = self::normalizeProfileSyncFields($fields);
|
||||
return implode(',', $fields);
|
||||
}
|
||||
|
||||
public static function profileSyncFieldsRequireGraph(array $fields): bool
|
||||
{
|
||||
$fields = self::normalizeProfileSyncFields($fields);
|
||||
return in_array('phone', $fields, true)
|
||||
|| in_array('mobile', $fields, true)
|
||||
|| in_array('avatar', $fields, true);
|
||||
}
|
||||
|
||||
private static 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 static 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)
|
||||
? self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? [])
|
||||
: self::normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []);
|
||||
if ($syncProfileOnLogin && !$syncProfileFields) {
|
||||
$syncProfileFields = self::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 static 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((string) (SettingService::getMicrosoftSharedClientId() ?? ''));
|
||||
$sharedClientSecret = trim((string) (SettingService::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((string) Crypto::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 static 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 => '',
|
||||
};
|
||||
}
|
||||
|
||||
private static function slugify(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
||||
return trim($value, '-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user