Files
breadcrumb-the-shire/lib/Service/Auth/SsoUserLinkService.php
fs 4e359fe659 feat(auth): sync display name and job title from Microsoft Graph, fix tenant SSO form handling
Fetches displayName/jobTitle from Graph API and syncs them via SSO profile sync. Also fixes LDAP enabled state in tenant form, adds LDAP save on tenant create, and hardens LDAP port validation precedence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:13:21 +02:00

439 lines
17 KiB
PHP

<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService;
class SsoUserLinkService
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserWriteRepositoryInterface $userWriteRepository,
private readonly UserAssignmentService $userAssignmentService,
private readonly UserTenantRepositoryInterface $userTenantRepository,
private readonly AuthSettingsGateway $settingsGateway,
private readonly TenantSsoService $tenantSsoService,
private readonly UserAvatarService $avatarService,
private readonly AuthExternalIdentityGateway $externalIdentityGateway
) {
}
public function linkOrProvisionMicrosoftUser(array $tenant, array $claims): array
{
$tenantId = (int) ($tenant['id'] ?? 0);
$tid = trim((string) ($claims['tid'] ?? ''));
$oid = trim((string) ($claims['oid'] ?? ''));
$issuer = trim((string) ($claims['issuer'] ?? ''));
$subject = trim((string) ($claims['subject'] ?? ''));
$email = strtolower(trim((string) ($claims['email'] ?? '')));
if ($tenantId <= 0 || $tid === '' || $oid === '' || $issuer === '' || $subject === '') {
return ['ok' => false, 'error' => 'identity_invalid'];
}
$providerKey = $this->tenantSsoService->microsoftProviderKey();
// Look up by OID first (stable Microsoft object ID), fall back to issuer+subject
// to handle tenants that migrated from an older OIDC setup without OID.
$identity = $this->externalIdentityGateway->findByProviderTidOid(
$providerKey,
$tid,
$oid
);
if (!$identity) {
$identity = $this->externalIdentityGateway->findByProviderIssuerSubject(
$providerKey,
$issuer,
$subject
);
}
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->syncProfileFromMicrosoft($userId, $claims);
return ['ok' => true, 'user_id' => $userId, 'created' => false];
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'email_missing'];
}
$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->createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email);
$this->syncProfileFromMicrosoft($userId, $claims);
return ['ok' => true, 'user_id' => $userId, 'created' => false];
}
$firstName = trim((string) ($claims['given_name'] ?? ''));
$lastName = trim((string) ($claims['family_name'] ?? ''));
$fullName = trim((string) ($claims['name'] ?? ''));
if ($firstName === '' && $lastName === '' && $fullName !== '') {
$parts = preg_split('/\s+/', $fullName) ?: [];
$firstName = (string) ($parts[0] ?? '');
$lastName = implode(' ', array_slice($parts, 1));
}
if ($firstName === '') {
$firstName = ucfirst(explode('@', $email, 2)[0]);
}
$createdId = $this->userWriteRepository->create([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'password' => $this->randomPassword(),
'locale' => $this->normalizeLocale((string) ($claims['preferred_language'] ?? '')),
'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->createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email);
$this->syncProfileFromMicrosoft($userId, $claims);
return ['ok' => true, 'user_id' => $userId, 'created' => true];
}
private function syncProfileFromMicrosoft(int $userId, array $claims): void
{
if ($userId <= 0 || empty($claims['sync_profile_on_login'])) {
return;
}
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($claims['sync_profile_fields'] ?? []);
if (!$syncFields) {
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
}
if (!$syncFields) {
return;
}
$syncAvatar = in_array('avatar', $syncFields, true);
$mappedValues = [
'first_name' => trim((string) ($claims['given_name'] ?? '')) !== ''
? trim((string) ($claims['given_name'] ?? ''))
: trim((string) ($claims['graph_given_name'] ?? '')),
'last_name' => trim((string) ($claims['family_name'] ?? '')) !== ''
? trim((string) ($claims['family_name'] ?? ''))
: trim((string) ($claims['graph_family_name'] ?? '')),
'display_name' => trim((string) ($claims['graph_display_name'] ?? '')),
'job_title' => trim((string) ($claims['graph_job_title'] ?? '')),
'phone' => trim((string) ($claims['graph_phone'] ?? '')),
'mobile' => trim((string) ($claims['graph_mobile'] ?? '')),
];
$updates = [];
foreach ($syncFields as $field) {
if ($field === 'avatar') {
continue;
}
$value = trim((string) ($mappedValues[$field] ?? ''));
if ($value === '') {
continue;
}
$updates[$field] = $value;
}
if (!$updates) {
if ($syncAvatar) {
$this->syncAvatarFromMicrosoft($userId, $claims);
}
return;
}
$this->userWriteRepository->updateProfileFieldsFromSso($userId, $updates);
if ($syncAvatar) {
$this->syncAvatarFromMicrosoft($userId, $claims);
}
}
private function syncAvatarFromMicrosoft(int $userId, array $claims): void
{
$avatarBase64 = trim((string) ($claims['graph_avatar_data_base64'] ?? ''));
if ($avatarBase64 === '') {
return;
}
$avatarMime = strtolower(trim((string) ($claims['graph_avatar_mime'] ?? '')));
if (!in_array($avatarMime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
return;
}
$binary = base64_decode($avatarBase64, true);
if (!is_string($binary) || $binary === '') {
return;
}
$user = $this->userReadRepository->find($userId);
if (!$user) {
return;
}
$userUuid = (string) ($user['uuid'] ?? '');
if (!$this->avatarService->isValidUuid($userUuid)) {
return;
}
// Keep avatar current with Microsoft profile photo on each login.
$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))));
if (!in_array($tenantId, $tenantIds, true)) {
$tenantIds[] = $tenantId;
$this->userAssignmentService->syncTenants($userId, $tenantIds);
}
}
private function createIdentityLink(
int $userId,
string $tid,
string $oid,
string $issuer,
string $subject,
string $email
): void {
try {
$providerKey = $this->tenantSsoService->microsoftProviderKey();
$this->externalIdentityGateway->createLink([
'user_id' => $userId,
'provider' => $providerKey,
'oid' => $oid,
'tid' => $tid,
'issuer' => $issuer,
'subject' => $subject,
'email_at_link_time' => $email,
]);
} catch (\Throwable $exception) {
// Ignore duplicate-link race conditions.
}
}
private function normalizeLocale(string $locale): ?string
{
$locale = strtolower(trim($locale));
if ($locale === '') {
return null;
}
if (str_contains($locale, '-')) {
$locale = explode('-', $locale, 2)[0];
}
$allowed = defined('APP_LOCALES') ? APP_LOCALES : [];
if ($allowed && !in_array($locale, $allowed, true)) {
return null;
}
return $locale;
}
// SSO users never log in with a password, but the DB column is NOT NULL.
// The suffix ensures the hash satisfies any strength validators.
private function randomPassword(): string
{
return 'sso-' . bin2hex(random_bytes(24)) . '-A1!';
}
private function resolveInitialTheme(?string $theme): string
{
return $this->settingsGateway->normalizeTheme($theme);
}
}