refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
278
core/Service/Auth/ApiTokenEndpointService.php
Normal file
278
core/Service/Auth/ApiTokenEndpointService.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
|
||||
class ApiTokenEndpointService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiTokenRepository $apiTokenRepository,
|
||||
private readonly TenantRepository $tenantRepository,
|
||||
private readonly TenantScopeService $authScopeGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function normalizeLimit(mixed $rawLimit, int $default = 50, int $min = 1, int $max = 100): int
|
||||
{
|
||||
$limit = (int) $rawLimit;
|
||||
if ($limit < $min) {
|
||||
return $default;
|
||||
}
|
||||
if ($limit > $max) {
|
||||
return $max;
|
||||
}
|
||||
return $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listSelfTokens(int $userId, int $limit, ?int $scopedTenantId, ?int $currentTokenId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = $this->apiTokenRepository->listByUserId($userId, $limit);
|
||||
$tenantUuidById = $this->buildTenantUuidByIdMap($tokens);
|
||||
$rows = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
||||
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'uuid' => (string) ($token['uuid'] ?? ''),
|
||||
'name' => (string) ($token['name'] ?? ''),
|
||||
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
|
||||
'tenant_uuid' => $tokenTenantId !== null ? ($tenantUuidById[$tokenTenantId] ?? null) : null,
|
||||
'last_used_at' => $token['last_used_at'] ?? null,
|
||||
'last_ip' => (string) ($token['last_ip'] ?? ''),
|
||||
'expires_at' => $token['expires_at'] ?? null,
|
||||
'revoked_at' => $token['revoked_at'] ?? null,
|
||||
'created' => $token['created'] ?? null,
|
||||
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findSelfToken(string $tokenUuid, int $userId, ?int $scopedTenantId, ?int $currentTokenId): ?array
|
||||
{
|
||||
$tokenUuid = trim($tokenUuid);
|
||||
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $this->apiTokenRepository->findByUuidForUser($tokenUuid, $userId);
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
||||
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => (string) ($token['uuid'] ?? ''),
|
||||
'name' => (string) ($token['name'] ?? ''),
|
||||
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
|
||||
'tenant_uuid' => $this->resolveTenantUuidById($tokenTenantId),
|
||||
'last_used_at' => $token['last_used_at'] ?? null,
|
||||
'last_ip' => (string) ($token['last_ip'] ?? ''),
|
||||
'expires_at' => $token['expires_at'] ?? null,
|
||||
'revoked_at' => $token['revoked_at'] ?? null,
|
||||
'created' => $token['created'] ?? null,
|
||||
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function revokeSelfToken(string $tokenUuid, int $userId): bool
|
||||
{
|
||||
$tokenUuid = trim($tokenUuid);
|
||||
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->apiTokenRepository->revokeByUuidForUser($tokenUuid, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, tenant_id?: int|null, tenant_uuid?: string|null, status?: int, error?: string, field?: string}
|
||||
*/
|
||||
public function resolveSelfCreateTenant(int $userId, ?int $scopedTenantId, string $tenantUuidInput): array
|
||||
{
|
||||
$tenantUuidInput = trim($tenantUuidInput);
|
||||
$tenantId = null;
|
||||
$tenantUuid = null;
|
||||
|
||||
if ($tenantUuidInput !== '') {
|
||||
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
||||
if ($tenantId <= 0 || $tenantUuid === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 422,
|
||||
'error' => 'invalid_tenant_uuid',
|
||||
'field' => 'tenant_uuid',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($scopedTenantId !== null && $scopedTenantId > 0) {
|
||||
$scopedTenantUuid = $this->resolveTenantUuidById($scopedTenantId);
|
||||
if ($scopedTenantUuid === null) {
|
||||
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
|
||||
}
|
||||
if ($tenantId !== null && $tenantId !== $scopedTenantId) {
|
||||
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
|
||||
}
|
||||
|
||||
$tenantId = $scopedTenantId;
|
||||
$tenantUuid = $scopedTenantUuid;
|
||||
}
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$tenantIds = $this->authScopeGateway->getUserTenantIds($userId);
|
||||
if (!in_array($tenantId, $tenantIds, true)) {
|
||||
return ['ok' => false, 'status' => 403, 'error' => 'tenant_not_assigned'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_uuid' => $tenantUuid,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, expires_at?: string|null, error?: string}
|
||||
*/
|
||||
public function parseExpiresAt(string $expiresInput): array
|
||||
{
|
||||
$expiresInput = trim($expiresInput);
|
||||
if ($expiresInput === '') {
|
||||
return ['ok' => true, 'expires_at' => null];
|
||||
}
|
||||
|
||||
try {
|
||||
$expiresDate = new \DateTimeImmutable($expiresInput);
|
||||
$expiresDateUtc = $expiresDate->setTimezone(new \DateTimeZone('UTC'));
|
||||
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
if ($expiresDateUtc <= $nowUtc) {
|
||||
return ['ok' => false, 'error' => 'must_be_in_future'];
|
||||
}
|
||||
return ['ok' => true, 'expires_at' => $expiresDateUtc->format('Y-m-d H:i:s')];
|
||||
} catch (\Throwable $exception) {
|
||||
return ['ok' => false, 'error' => 'invalid_datetime'];
|
||||
}
|
||||
}
|
||||
|
||||
public function capExpiryToParent(?string $requestedExpiresAt, ?string $parentExpiresAt): ?string
|
||||
{
|
||||
$parentExpiresAt = trim((string) ($parentExpiresAt ?? ''));
|
||||
if ($parentExpiresAt === '') {
|
||||
return $requestedExpiresAt;
|
||||
}
|
||||
|
||||
try {
|
||||
$parentExpiry = new \DateTimeImmutable($parentExpiresAt, new \DateTimeZone('UTC'));
|
||||
if ($requestedExpiresAt === null || trim($requestedExpiresAt) === '') {
|
||||
return $parentExpiresAt;
|
||||
}
|
||||
|
||||
$requestedExpiry = new \DateTimeImmutable($requestedExpiresAt, new \DateTimeZone('UTC'));
|
||||
if ($requestedExpiry > $parentExpiry) {
|
||||
return $parentExpiresAt;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
return $requestedExpiresAt;
|
||||
}
|
||||
|
||||
return $requestedExpiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, tenant_id?: int|null, error?: string, status?: int}
|
||||
*/
|
||||
public function resolveLoginTenant(int $userId, string $tenantUuidInput): array
|
||||
{
|
||||
$tenantUuidInput = trim($tenantUuidInput);
|
||||
if ($tenantUuidInput === '') {
|
||||
return ['ok' => true, 'tenant_id' => null];
|
||||
}
|
||||
|
||||
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_tenant_uuid', 'status' => 422];
|
||||
}
|
||||
|
||||
$userTenantIds = $this->authScopeGateway->getUserTenantIds($userId);
|
||||
if (!in_array($tenantId, $userTenantIds, true)) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_assigned', 'status' => 403];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tenant_id' => $tenantId];
|
||||
}
|
||||
|
||||
public function resolveTenantUuidById(?int $tenantId): ?string
|
||||
{
|
||||
$tenantId = (int) ($tenantId ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->tenantRepository->find($tenantId);
|
||||
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
||||
|
||||
return $tenantUuid !== '' ? $tenantUuid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tokens
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function buildTenantUuidByIdMap(array $tokens): array
|
||||
{
|
||||
$tenantIds = [];
|
||||
foreach ($tokens as $token) {
|
||||
$tenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
||||
if ($tenantId !== null) {
|
||||
$tenantIds[] = $tenantId;
|
||||
}
|
||||
}
|
||||
$tenantIds = array_values(array_unique($tenantIds));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($this->tenantRepository->listByIds($tenantIds) as $tenant) {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
||||
if ($tenantId > 0 && $tenantUuid !== '') {
|
||||
$map[$tenantId] = $tenantUuid;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function normalizeTenantId(mixed $tenantId): ?int
|
||||
{
|
||||
$tenantId = (int) ($tenantId ?? 0);
|
||||
return $tenantId > 0 ? $tenantId : null;
|
||||
}
|
||||
}
|
||||
312
core/Service/Auth/ApiTokenService.php
Normal file
312
core/Service/Auth/ApiTokenService.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
|
||||
class ApiTokenService
|
||||
{
|
||||
private const MAX_TOKENS_PER_USER = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly ApiTokenRepositoryInterface $apiTokenRepository,
|
||||
private readonly AuthSettingsGateway $settingsGateway,
|
||||
private readonly TenantScopeService $scopeGateway,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
) {
|
||||
}
|
||||
|
||||
private function isTokenExpired(?string $expiresAt): bool
|
||||
{
|
||||
$expiresAt = trim((string) ($expiresAt ?? ''));
|
||||
if ($expiresAt === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
|
||||
return $expiry <= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
} catch (\Exception $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token for a user.
|
||||
*
|
||||
* Returns ['ok' => true, 'token' => 'selector:plaintext', 'id' => int] on success.
|
||||
* The plain-text token is shown ONCE and never stored.
|
||||
*/
|
||||
public function create(
|
||||
int $userId,
|
||||
string $name,
|
||||
?int $tenantId = null,
|
||||
?string $expiresAt = null,
|
||||
?int $createdBy = null
|
||||
): array {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'name_required'];
|
||||
}
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_user'];
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || !((int) ($user['active'] ?? 0))) {
|
||||
return ['ok' => false, 'error' => 'user_inactive'];
|
||||
}
|
||||
|
||||
$activeCount = $this->apiTokenRepository->countActiveForUser($userId);
|
||||
if ($activeCount >= self::MAX_TOKENS_PER_USER) {
|
||||
return ['ok' => false, 'error' => 'max_tokens_reached'];
|
||||
}
|
||||
|
||||
if ($tenantId !== null && $tenantId > 0) {
|
||||
$userTenantIds = $this->scopeGateway->getUserTenantIds($userId);
|
||||
if (!in_array($tenantId, $userTenantIds, true)) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_assigned'];
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedExpiresAt = $this->resolveExpiresAt($expiresAt);
|
||||
if ($resolvedExpiresAt === null) {
|
||||
return ['ok' => false, 'error' => 'invalid_expires_at'];
|
||||
}
|
||||
|
||||
// Selector is public (used for lookup); verifier is the secret (only its hash is stored).
|
||||
$selector = bin2hex(random_bytes(12));
|
||||
$plainToken = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $plainToken);
|
||||
|
||||
$id = $this->apiTokenRepository->create(
|
||||
$userId,
|
||||
$name,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
($tenantId !== null && $tenantId > 0) ? $tenantId : null,
|
||||
$resolvedExpiresAt,
|
||||
$createdBy
|
||||
);
|
||||
|
||||
if (!$id) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$createdToken = $this->apiTokenRepository->find($id);
|
||||
if (!$createdToken) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$fullToken = $selector . ':' . $plainToken;
|
||||
return [
|
||||
'ok' => true,
|
||||
'token' => $fullToken,
|
||||
'id' => $id,
|
||||
'uuid' => (string) ($createdToken['uuid'] ?? ''),
|
||||
'selector' => $selector,
|
||||
'expires_at' => $resolvedExpiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array
|
||||
{
|
||||
if ($userId <= 0 || $currentTokenId <= 0) {
|
||||
return ['ok' => false, 'error' => 'current_token_not_found'];
|
||||
}
|
||||
|
||||
$currentToken = $this->apiTokenRepository->find($currentTokenId);
|
||||
if (!$currentToken || (int) ($currentToken['user_id'] ?? 0) !== $userId) {
|
||||
return ['ok' => false, 'error' => 'current_token_not_found'];
|
||||
}
|
||||
|
||||
if (!empty($currentToken['revoked_at']) || $this->isTokenExpired($currentToken['expires_at'] ?? null)) {
|
||||
return ['ok' => false, 'error' => 'current_token_invalid'];
|
||||
}
|
||||
|
||||
$activeCountExcludingCurrent = $this->apiTokenRepository->countActiveForUserExcludingId($userId, $currentTokenId);
|
||||
if ($activeCountExcludingCurrent >= self::MAX_TOKENS_PER_USER) {
|
||||
return ['ok' => false, 'error' => 'max_tokens_reached'];
|
||||
}
|
||||
|
||||
$name = trim((string) ($currentToken['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = 'API token';
|
||||
}
|
||||
|
||||
$tenantId = isset($currentToken['tenant_id']) ? (int) ($currentToken['tenant_id']) : 0;
|
||||
if ($tenantId <= 0) {
|
||||
$tenantId = null;
|
||||
}
|
||||
|
||||
// Wrap revoke+create in a transaction so rotation is atomic — no gap where neither token is valid.
|
||||
$transactionStarted = false;
|
||||
try {
|
||||
$this->databaseSessionRepository->beginTransaction();
|
||||
$transactionStarted = true;
|
||||
|
||||
if (!$this->apiTokenRepository->revoke($currentTokenId)) {
|
||||
$this->databaseSessionRepository->rollbackTransaction();
|
||||
return ['ok' => false, 'error' => 'rotate_failed'];
|
||||
}
|
||||
|
||||
$created = $this->create($userId, $name, $tenantId, $expiresAt, $userId);
|
||||
if (!($created['ok'] ?? false)) {
|
||||
$this->databaseSessionRepository->rollbackTransaction();
|
||||
$error = (string) ($created['error'] ?? 'rotate_failed');
|
||||
if ($error === 'invalid_expires_at') {
|
||||
return ['ok' => false, 'error' => 'invalid_expires_at'];
|
||||
}
|
||||
if ($error === 'max_tokens_reached') {
|
||||
return ['ok' => false, 'error' => 'max_tokens_reached'];
|
||||
}
|
||||
return ['ok' => false, 'error' => 'rotate_failed'];
|
||||
}
|
||||
|
||||
$this->databaseSessionRepository->commitTransaction();
|
||||
$transactionStarted = false;
|
||||
$created['tenant_id'] = $tenantId;
|
||||
return $created;
|
||||
} catch (\Throwable $exception) {
|
||||
if ($transactionStarted) {
|
||||
try {
|
||||
$this->databaseSessionRepository->rollbackTransaction();
|
||||
} catch (\Throwable $rollbackException) {
|
||||
// Ignore rollback error and return rotate_failed below.
|
||||
}
|
||||
}
|
||||
return ['ok' => false, 'error' => 'rotate_failed'];
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveExpiresAt(?string $expiresAt): ?string
|
||||
{
|
||||
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$maxTtlDays = $this->settingsGateway->getApiTokenMaxTtlDays();
|
||||
$maxExpiryUtc = $nowUtc->modify('+' . $maxTtlDays . ' days');
|
||||
$raw = trim((string) ($expiresAt ?? ''));
|
||||
|
||||
if ($raw === '') {
|
||||
$defaultDays = $this->settingsGateway->getApiTokenDefaultTtlDays();
|
||||
$defaultExpiry = $nowUtc->modify('+' . $defaultDays . ' days');
|
||||
if ($defaultExpiry > $maxExpiryUtc) {
|
||||
$defaultExpiry = $maxExpiryUtc;
|
||||
}
|
||||
return $defaultExpiry->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = new \DateTimeImmutable($raw);
|
||||
} catch (\Exception $exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiryUtc = $parsed->setTimezone(new \DateTimeZone('UTC'));
|
||||
if ($expiryUtc <= $nowUtc) {
|
||||
return null;
|
||||
}
|
||||
if ($expiryUtc > $maxExpiryUtc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $expiryUtc->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bearer token string.
|
||||
*
|
||||
* @return array{user: array, token_record: array, tenant_id: ?int}|null
|
||||
*/
|
||||
public function validate(string $bearerToken): ?array
|
||||
{
|
||||
if ($bearerToken === '' || strpos($bearerToken, ':') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$selector, $plainToken] = explode(':', $bearerToken, 2);
|
||||
$selector = trim($selector);
|
||||
$plainToken = trim($plainToken);
|
||||
if ($selector === '' || $plainToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->apiTokenRepository->findBySelector($selector);
|
||||
if (!$record) {
|
||||
// Perform a dummy hash to prevent timing-based selector enumeration.
|
||||
$_ = hash('sha256', $plainToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!empty($record['revoked_at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isTokenExpired($record['expires_at'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$storedHash = (string) ($record['token_hash'] ?? '');
|
||||
if ($storedHash === '' || !hash_equals($storedHash, hash('sha256', $plainToken))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = (int) ($record['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || !((int) ($user['active'] ?? 0))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip = $this->requestRuntime->ip();
|
||||
$this->apiTokenRepository->updateLastUsed((int) $record['id'], $ip);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'token_record' => $record,
|
||||
'tenant_id' => !empty($record['tenant_id']) ? (int) $record['tenant_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific token by ID.
|
||||
*/
|
||||
public function revoke(int $tokenId): array
|
||||
{
|
||||
$token = $this->apiTokenRepository->find($tokenId);
|
||||
if (!$token) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
$this->apiTokenRepository->revoke($tokenId);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all active tokens for a user.
|
||||
*/
|
||||
public function revokeAllForUser(int $userId, ?int $tenantId = null): array
|
||||
{
|
||||
$count = $this->apiTokenRepository->revokeAllForUser($userId, $tenantId);
|
||||
return ['ok' => true, 'count' => $count];
|
||||
}
|
||||
|
||||
public function revokeAllByAdmin(): int
|
||||
{
|
||||
return $this->apiTokenRepository->revokeAllActiveByAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* List tokens for a user (for admin UI display -- never exposes hashes).
|
||||
*/
|
||||
public function listForUser(int $userId): array
|
||||
{
|
||||
return $this->apiTokenRepository->listByUserId($userId);
|
||||
}
|
||||
}
|
||||
23
core/Service/Auth/AuthCryptoGateway.php
Normal file
23
core/Service/Auth/AuthCryptoGateway.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Support\Crypto;
|
||||
|
||||
class AuthCryptoGateway
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return Crypto::isConfigured();
|
||||
}
|
||||
|
||||
public function encryptString(string $value): string
|
||||
{
|
||||
return Crypto::encryptString($value);
|
||||
}
|
||||
|
||||
public function decryptString(string $value): string
|
||||
{
|
||||
return Crypto::decryptString($value);
|
||||
}
|
||||
}
|
||||
27
core/Service/Auth/AuthExternalIdentityGateway.php
Normal file
27
core/Service/Auth/AuthExternalIdentityGateway.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\User\UserExternalIdentityRepositoryInterface;
|
||||
|
||||
class AuthExternalIdentityGateway
|
||||
{
|
||||
public function __construct(private readonly UserExternalIdentityRepositoryInterface $userExternalIdentityRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function findByProviderTidOid(string $provider, string $tid, string $oid): ?array
|
||||
{
|
||||
return $this->userExternalIdentityRepository->findByProviderTidOid($provider, $tid, $oid);
|
||||
}
|
||||
|
||||
public function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array
|
||||
{
|
||||
return $this->userExternalIdentityRepository->findByProviderIssuerSubject($provider, $issuer, $subject);
|
||||
}
|
||||
|
||||
public function createLink(array $data): bool
|
||||
{
|
||||
return (bool) $this->userExternalIdentityRepository->createLink($data);
|
||||
}
|
||||
}
|
||||
100
core/Service/Auth/AuthGatewayFactory.php
Normal file
100
core/Service/Auth/AuthGatewayFactory.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
|
||||
class AuthGatewayFactory
|
||||
{
|
||||
private ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null;
|
||||
private ?SettingsApiPolicyGateway $settingsApiPolicyGateway = null;
|
||||
private ?SettingsDefaultsGateway $settingsDefaultsGateway = null;
|
||||
private ?SettingsAppGateway $settingsAppGateway = null;
|
||||
private ?SettingsLoginPersistenceGateway $settingsLoginPersistenceGateway = null;
|
||||
private ?PermissionService $permissionService = null;
|
||||
private ?AuthSettingsGateway $authSettingsGateway = null;
|
||||
private ?AuthTenantGateway $authTenantGateway = null;
|
||||
private ?AuthCryptoGateway $authCryptoGateway = null;
|
||||
private ?AuthExternalIdentityGateway $authExternalIdentityGateway = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly AuthRepositoryFactory $authRepositoryFactory,
|
||||
private readonly AccessServicesFactory $accessServicesFactory,
|
||||
private readonly SettingServicesFactory $settingServicesFactory,
|
||||
private readonly TenantScopeService $tenantScopeService
|
||||
) {
|
||||
}
|
||||
|
||||
public function createAuthSettingsGateway(): AuthSettingsGateway
|
||||
{
|
||||
return $this->authSettingsGateway ??= new AuthSettingsGateway(
|
||||
$this->createSettingsMicrosoftGateway(),
|
||||
$this->createSettingsApiPolicyGateway(),
|
||||
$this->createSettingsDefaultsGateway(),
|
||||
$this->createSettingsAppGateway(),
|
||||
$this->createSettingsLoginPersistenceGateway()
|
||||
);
|
||||
}
|
||||
|
||||
public function getTenantScopeService(): TenantScopeService
|
||||
{
|
||||
return $this->tenantScopeService;
|
||||
}
|
||||
|
||||
public function createPermissionService(): PermissionService
|
||||
{
|
||||
return $this->permissionService ??= $this->accessServicesFactory->createPermissionService();
|
||||
}
|
||||
|
||||
public function createAuthTenantGateway(): AuthTenantGateway
|
||||
{
|
||||
return $this->authTenantGateway ??= new AuthTenantGateway(
|
||||
$this->authRepositoryFactory->createTenantRepository()
|
||||
);
|
||||
}
|
||||
|
||||
public function createAuthCryptoGateway(): AuthCryptoGateway
|
||||
{
|
||||
return $this->authCryptoGateway ??= new AuthCryptoGateway();
|
||||
}
|
||||
|
||||
public function createAuthExternalIdentityGateway(): AuthExternalIdentityGateway
|
||||
{
|
||||
return $this->authExternalIdentityGateway ??= new AuthExternalIdentityGateway(
|
||||
$this->authRepositoryFactory->createUserExternalIdentityRepository()
|
||||
);
|
||||
}
|
||||
|
||||
private function createSettingsMicrosoftGateway(): SettingsMicrosoftGateway
|
||||
{
|
||||
return $this->settingsMicrosoftGateway ??= $this->settingServicesFactory->createSettingsMicrosoftGateway();
|
||||
}
|
||||
|
||||
private function createSettingsApiPolicyGateway(): SettingsApiPolicyGateway
|
||||
{
|
||||
return $this->settingsApiPolicyGateway ??= $this->settingServicesFactory->createSettingsApiPolicyGateway();
|
||||
}
|
||||
|
||||
private function createSettingsDefaultsGateway(): SettingsDefaultsGateway
|
||||
{
|
||||
return $this->settingsDefaultsGateway ??= $this->settingServicesFactory->createSettingsDefaultsGateway();
|
||||
}
|
||||
|
||||
private function createSettingsAppGateway(): SettingsAppGateway
|
||||
{
|
||||
return $this->settingsAppGateway ??= $this->settingServicesFactory->createSettingsAppGateway();
|
||||
}
|
||||
|
||||
private function createSettingsLoginPersistenceGateway(): SettingsLoginPersistenceGateway
|
||||
{
|
||||
return $this->settingsLoginPersistenceGateway ??= $this->settingServicesFactory->createSettingsLoginPersistenceGateway();
|
||||
}
|
||||
}
|
||||
72
core/Service/Auth/AuthRepositoryFactory.php
Normal file
72
core/Service/Auth/AuthRepositoryFactory.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\Auth\EmailVerificationRepository;
|
||||
use MintyPHP\Repository\Auth\EmailVerificationRepositoryInterface;
|
||||
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;
|
||||
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserExternalIdentityRepository;
|
||||
use MintyPHP\Repository\User\UserExternalIdentityRepositoryInterface;
|
||||
|
||||
class AuthRepositoryFactory
|
||||
{
|
||||
private ?ApiTokenRepository $apiTokenRepository = null;
|
||||
private ?RememberTokenRepository $rememberTokenRepository = null;
|
||||
private ?PasswordResetRepository $passwordResetRepository = null;
|
||||
private ?EmailVerificationRepository $emailVerificationRepository = null;
|
||||
private ?TenantRepository $tenantRepository = null;
|
||||
private ?TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository = null;
|
||||
private ?TenantLdapAuthRepository $tenantLdapAuthRepository = null;
|
||||
private ?UserExternalIdentityRepository $userExternalIdentityRepository = null;
|
||||
|
||||
public function createApiTokenRepository(): ApiTokenRepositoryInterface
|
||||
{
|
||||
return $this->apiTokenRepository ??= new ApiTokenRepository();
|
||||
}
|
||||
|
||||
public function createRememberTokenRepository(): RememberTokenRepositoryInterface
|
||||
{
|
||||
return $this->rememberTokenRepository ??= new RememberTokenRepository();
|
||||
}
|
||||
|
||||
public function createPasswordResetRepository(): PasswordResetRepositoryInterface
|
||||
{
|
||||
return $this->passwordResetRepository ??= new PasswordResetRepository();
|
||||
}
|
||||
|
||||
public function createEmailVerificationRepository(): EmailVerificationRepositoryInterface
|
||||
{
|
||||
return $this->emailVerificationRepository ??= new EmailVerificationRepository();
|
||||
}
|
||||
|
||||
public function createTenantRepository(): TenantRepositoryInterface
|
||||
{
|
||||
return $this->tenantRepository ??= new TenantRepository();
|
||||
}
|
||||
|
||||
public function createTenantMicrosoftAuthRepository(): TenantMicrosoftAuthRepositoryInterface
|
||||
{
|
||||
return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository();
|
||||
}
|
||||
|
||||
public function createTenantLdapAuthRepository(): TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
return $this->tenantLdapAuthRepository ??= new TenantLdapAuthRepository();
|
||||
}
|
||||
|
||||
public function createUserExternalIdentityRepository(): UserExternalIdentityRepositoryInterface
|
||||
{
|
||||
return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository();
|
||||
}
|
||||
}
|
||||
440
core/Service/Auth/AuthService.php
Normal file
440
core/Service/Auth/AuthService.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\User\UserAccountService;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
|
||||
/**
|
||||
* Orchestrates authentication flows: login, registration, logout, and session refresh.
|
||||
*
|
||||
* Login is a cascading validation — each step must pass before the next is attempted:
|
||||
* 1. Email verification status (unverified → redirect to verification)
|
||||
* 2. Credential check via Auth::login (email + password)
|
||||
* 3. Account active flag
|
||||
* 4. Local password login allowed (at least one tenant permits it)
|
||||
* 5. Permissions loaded + tenant context hydrated into session
|
||||
* 6. At least one active tenant assigned
|
||||
*
|
||||
* Every outcome (success or failure at any step) is recorded as an audit event.
|
||||
* On SSO-initiated login (loginUserById), steps 1-4 are skipped because the IdP
|
||||
* already authenticated the user; steps 5-6 still apply.
|
||||
*/
|
||||
class AuthService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly UserAccountService $userAccountService,
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly RememberMeService $rememberMeService,
|
||||
private readonly EmailVerificationService $emailVerificationService,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly TenantSsoService $tenantSsoService,
|
||||
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
||||
private readonly AuditRecorderInterface $systemAuditService,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly ?ModuleEventDispatcher $eventDispatcher = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function login(string $email, string $password): array
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
// Check if email is verified before attempting login
|
||||
$existingUser = $this->userReadRepository->findByEmail($email);
|
||||
if ($existingUser && empty($existingUser['email_verified_at'])) {
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'email_not_verified',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Please verify your email first',
|
||||
'flash_key' => 'login_not_verified',
|
||||
'needs_verification' => true,
|
||||
'email' => $email,
|
||||
];
|
||||
}
|
||||
|
||||
$user = Auth::login($email, $password);
|
||||
|
||||
if (!$user) {
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'invalid_credentials',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Email/password not valid',
|
||||
'flash_key' => 'login_invalid',
|
||||
];
|
||||
}
|
||||
|
||||
$sessionUser = $this->sessionUser();
|
||||
if (!($sessionUser['active'] ?? 1)) {
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'account_inactive',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Account is inactive',
|
||||
'flash_key' => 'login_inactive',
|
||||
];
|
||||
}
|
||||
|
||||
$userId = (int) ($sessionUser['id'] ?? 0);
|
||||
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'password_login_disabled',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Password login is disabled for all assigned tenants',
|
||||
'flash_key' => 'login_password_disabled_all_tenants',
|
||||
];
|
||||
}
|
||||
if ($userId > 0) {
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if ($this->noActiveTenant()) {
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'no_active_tenant',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'No active tenant assigned',
|
||||
'flash_key' => 'login_no_active_tenant',
|
||||
];
|
||||
}
|
||||
$this->userWriteRepository->updateLastLogin($userId, 'local');
|
||||
}
|
||||
|
||||
$this->recordAuthEvent('auth.login.success', 'success', [
|
||||
'actor_user_id' => $userId > 0 ? $userId : null,
|
||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||
]);
|
||||
|
||||
$this->eventDispatcher?->dispatch('user.login', [
|
||||
'user_id' => $userId,
|
||||
'provider' => 'local',
|
||||
]);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Returns true if at least ONE assigned tenant permits local login.
|
||||
// A user is only blocked if ALL tenants enforce Microsoft-only login.
|
||||
private function isLocalPasswordLoginAllowedForUser(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
if (!$availableTenants) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($availableTenants as $tenant) {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($this->tenantSsoService->isLocalPasswordLoginAllowed($tenantId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function canLoginToTenant(int $userId, int $tenantId): bool
|
||||
{
|
||||
if ($userId <= 0 || $tenantId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->userTenantContextService->getAvailableTenants($userId) as $tenant) {
|
||||
if ((int) ($tenant['id'] ?? 0) === $tenantId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'error' => 'user_not_found'];
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user) {
|
||||
return ['ok' => false, 'error' => 'user_not_found'];
|
||||
}
|
||||
if (!((int) ($user['active'] ?? 1) === 1)) {
|
||||
return ['ok' => false, 'error' => 'user_inactive'];
|
||||
}
|
||||
|
||||
Session::regenerate();
|
||||
$this->sessionStore->set('user', $user);
|
||||
|
||||
if ($preferredTenantId !== null && $preferredTenantId > 0) {
|
||||
$setTenant = $this->userTenantContextService->setCurrentTenant($userId, $preferredTenantId);
|
||||
if ($setTenant['ok'] ?? false) {
|
||||
$sessionUser = $this->sessionUser();
|
||||
$sessionUser['current_tenant_id'] = $preferredTenantId;
|
||||
$this->sessionStore->set('user', $sessionUser);
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if ($this->noActiveTenant()) {
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'login_no_active_tenant',
|
||||
'actor_user_id' => $userId,
|
||||
]);
|
||||
return ['ok' => false, 'error' => 'login_no_active_tenant'];
|
||||
}
|
||||
|
||||
$this->userWriteRepository->updateLastLogin($userId, $loginProvider);
|
||||
|
||||
$this->recordAuthEvent('auth.login.success', 'success', [
|
||||
'actor_user_id' => $userId,
|
||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||
'metadata' => ['provider' => $loginProvider],
|
||||
]);
|
||||
|
||||
$this->eventDispatcher?->dispatch('user.login', [
|
||||
'user_id' => $userId,
|
||||
'provider' => $loginProvider,
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public function loginAndRedirect(
|
||||
string $email,
|
||||
string $password,
|
||||
string $successTarget = 'admin',
|
||||
string $failTarget = 'login',
|
||||
bool $remember = false
|
||||
): void {
|
||||
$result = $this->login($email, $password);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
// Check if user needs to verify email
|
||||
if (!empty($result['needs_verification'])) {
|
||||
$this->sessionStore->set('email_verification_email', $result['email'] ?? $email);
|
||||
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
|
||||
Router::redirect('verify-email');
|
||||
}
|
||||
|
||||
$message = $result['message'] ?? 'Email/password not valid';
|
||||
$key = $result['flash_key'] ?? 'login_invalid';
|
||||
Flash::error($message, $failTarget, $key);
|
||||
Router::redirect($failTarget);
|
||||
}
|
||||
|
||||
if ($remember) {
|
||||
$userId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$this->rememberMeService->rememberUser($userId);
|
||||
}
|
||||
}
|
||||
Router::redirect($successTarget);
|
||||
}
|
||||
|
||||
public function register(array $data): array
|
||||
{
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
$result = $this->userAccountService->register($data);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get the created user to send verification email
|
||||
$createdUser = $this->userReadRepository->findByEmail($email);
|
||||
if ($createdUser && isset($createdUser['id'])) {
|
||||
$userId = (int) $createdUser['id'];
|
||||
$this->emailVerificationService->sendVerification($userId);
|
||||
}
|
||||
|
||||
// Don't login - user needs to verify email first
|
||||
return ['ok' => true, 'email' => $email, 'needs_verification' => true];
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
$actorTenantId = $this->currentTenantIdFromSession();
|
||||
|
||||
$this->eventDispatcher?->dispatch('user.logout', [
|
||||
'user_id' => $actorUserId,
|
||||
]);
|
||||
|
||||
$this->rememberMeService->forgetCurrentUser();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.logout', 'success', [
|
||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutDueToSessionTimeout(): void
|
||||
{
|
||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
$actorTenantId = $this->currentTenantIdFromSession();
|
||||
$this->sessionStore->remove('session_started_at');
|
||||
$this->sessionStore->remove('session_last_activity');
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.session.timeout', 'success', [
|
||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutAndRedirect(string $target = 'login'): void
|
||||
{
|
||||
$this->logout();
|
||||
Router::redirect($target);
|
||||
}
|
||||
|
||||
private function recordAuthEvent(string $eventType, string $outcome, array $context = []): void
|
||||
{
|
||||
$this->systemAuditService->record($eventType, $outcome, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validates the session against the current DB state.
|
||||
*
|
||||
* Uses an authz_version counter: the DB version is bumped whenever permissions or
|
||||
* tenant assignments change. A mismatch with the session version triggers a full
|
||||
* reload of user data, permissions, and tenant context. This is the mechanism that
|
||||
* makes permission changes take effect without requiring re-login.
|
||||
*/
|
||||
public function refreshSessionAuthState(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
'reason' => 'user_missing',
|
||||
];
|
||||
}
|
||||
|
||||
$snapshot = $this->userReadRepository->findAuthzSnapshot($userId);
|
||||
if (!$snapshot) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
'reason' => 'user_not_found',
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) ($snapshot['active'] ?? 0) !== 1) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
'reason' => 'inactive',
|
||||
];
|
||||
}
|
||||
|
||||
// authz_version is bumped in DB whenever permissions/tenants change.
|
||||
// A mismatch means the session is stale and needs to be refreshed.
|
||||
$sessionVersion = (int) ($this->sessionUser()['authz_version'] ?? 0);
|
||||
$dbVersion = max(1, (int) ($snapshot['authz_version'] ?? 1));
|
||||
$refreshed = false;
|
||||
|
||||
if ($sessionVersion !== $dbVersion) {
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || (int) ($user['active'] ?? 0) !== 1) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
'reason' => 'inactive',
|
||||
];
|
||||
}
|
||||
|
||||
$this->sessionStore->set('user', $user);
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
$refreshed = true;
|
||||
}
|
||||
|
||||
if ($this->noActiveTenant()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
'reason' => 'no_active_tenant',
|
||||
'refreshed' => $refreshed,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'logout_required' => false,
|
||||
'refreshed' => $refreshed,
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTenantDataIntoSession(int $userId): void
|
||||
{
|
||||
$this->authSessionTenantContextService->hydrateForUser($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionUser(): array
|
||||
{
|
||||
$user = $this->sessionStore->get('user', []);
|
||||
return is_array($user) ? $user : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionCurrentTenant(): array
|
||||
{
|
||||
$tenant = $this->sessionStore->get('current_tenant', []);
|
||||
return is_array($tenant) ? $tenant : [];
|
||||
}
|
||||
|
||||
private function currentTenantIdFromSession(): int
|
||||
{
|
||||
return (int) ($this->sessionCurrentTenant()['id'] ?? 0);
|
||||
}
|
||||
|
||||
private function noActiveTenant(): bool
|
||||
{
|
||||
return (bool) $this->sessionStore->get('no_active_tenant', false);
|
||||
}
|
||||
|
||||
private function logoutWithModuleCleanup(): void
|
||||
{
|
||||
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||
Auth::logout();
|
||||
}
|
||||
}
|
||||
179
core/Service/Auth/AuthServicesFactory.php
Normal file
179
core/Service/Auth/AuthServicesFactory.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
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;
|
||||
private ?PasswordResetService $passwordResetService = null;
|
||||
private ?EmailVerificationService $emailVerificationService = null;
|
||||
private ?RememberMeService $rememberMeService = null;
|
||||
private ?AuthService $authService = null;
|
||||
private ?SsoUserLinkService $ssoUserLinkService = null;
|
||||
private ?AuthSessionTenantContextService $authSessionTenantContextService = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserServicesFactory $userServicesFactory,
|
||||
private readonly AuditRecorderInterface $auditRecorder,
|
||||
private readonly MailServicesFactory $mailServicesFactory,
|
||||
private readonly AuthRepositoryFactory $authRepositoryFactory,
|
||||
private readonly AuthGatewayFactory $authGatewayFactory,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime,
|
||||
private readonly AppContainer $appContainer
|
||||
) {
|
||||
}
|
||||
|
||||
public function createApiTokenService(): ApiTokenService
|
||||
{
|
||||
return $this->apiTokenService ??= new ApiTokenService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->authRepositoryFactory->createApiTokenRepository(),
|
||||
$this->authGatewayFactory->createAuthSettingsGateway(),
|
||||
$this->authGatewayFactory->getTenantScopeService(),
|
||||
$this->databaseSessionRepository,
|
||||
$this->requestRuntime
|
||||
);
|
||||
}
|
||||
|
||||
public function createPasswordResetService(): PasswordResetService
|
||||
{
|
||||
return $this->passwordResetService ??= new PasswordResetService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->authRepositoryFactory->createPasswordResetRepository(),
|
||||
$this->userServicesFactory->createUserPasswordService(),
|
||||
$this->createRememberMeService(),
|
||||
$this->mailServicesFactory->createMailService()
|
||||
);
|
||||
}
|
||||
|
||||
public function createEmailVerificationService(): EmailVerificationService
|
||||
{
|
||||
return $this->emailVerificationService ??= new EmailVerificationService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->authRepositoryFactory->createEmailVerificationRepository(),
|
||||
$this->mailServicesFactory->createMailService()
|
||||
);
|
||||
}
|
||||
|
||||
public function createRememberMeService(): RememberMeService
|
||||
{
|
||||
return $this->rememberMeService ??= new RememberMeService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->authRepositoryFactory->createRememberTokenRepository(),
|
||||
$this->authGatewayFactory->createPermissionService(),
|
||||
$this->createAuthSessionTenantContextService(),
|
||||
$this->sessionStore,
|
||||
$this->cookieStore,
|
||||
$this->requestRuntime,
|
||||
$this->authGatewayFactory->createAuthSettingsGateway(),
|
||||
new RememberMeRateLimiter()
|
||||
);
|
||||
}
|
||||
|
||||
public function createAuthService(): AuthService
|
||||
{
|
||||
return $this->authService ??= new AuthService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->userServicesFactory->createUserAccountService(),
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->createRememberMeService(),
|
||||
$this->createEmailVerificationService(),
|
||||
$this->authGatewayFactory->createPermissionService(),
|
||||
$this->createTenantSsoService(),
|
||||
$this->createAuthSessionTenantContextService(),
|
||||
$this->auditRecorder,
|
||||
$this->sessionStore,
|
||||
$this->resolveEventDispatcher()
|
||||
);
|
||||
}
|
||||
|
||||
public function createSsoUserLinkService(): SsoUserLinkService
|
||||
{
|
||||
return $this->ssoUserLinkService ??= new SsoUserLinkService(
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->userServicesFactory->createUserAssignmentService(),
|
||||
$this->userServicesFactory->createUserTenantRepository(),
|
||||
$this->authGatewayFactory->createAuthSettingsGateway(),
|
||||
$this->createTenantSsoService(),
|
||||
$this->userServicesFactory->createUserAvatarService(),
|
||||
$this->authGatewayFactory->createAuthExternalIdentityGateway()
|
||||
);
|
||||
}
|
||||
|
||||
public function createTenantSsoService(): TenantSsoService
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public function createMicrosoftOidcService(): MicrosoftOidcService
|
||||
{
|
||||
return $this->microsoftOidcService ??= new MicrosoftOidcService(
|
||||
$this->createTenantSsoService(),
|
||||
$this->authGatewayFactory->createAuthTenantGateway(),
|
||||
$this->createOidcHttpGateway(),
|
||||
$this->createMicrosoftOidcStateStore()
|
||||
);
|
||||
}
|
||||
|
||||
private function createOidcHttpGateway(): OidcHttpGateway
|
||||
{
|
||||
return new OidcHttpGateway();
|
||||
}
|
||||
|
||||
private function resolveEventDispatcher(): ?ModuleEventDispatcher
|
||||
{
|
||||
if ($this->appContainer->has(ModuleEventDispatcher::class)) {
|
||||
$dispatcher = $this->appContainer->get(ModuleEventDispatcher::class);
|
||||
return $dispatcher instanceof ModuleEventDispatcher ? $dispatcher : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function createAuthSessionTenantContextService(): AuthSessionTenantContextService
|
||||
{
|
||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->sessionStore,
|
||||
$this->appContainer
|
||||
);
|
||||
}
|
||||
}
|
||||
85
core/Service/Auth/AuthSessionTenantContextService.php
Normal file
85
core/Service/Auth/AuthSessionTenantContextService.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\App\Module\ModuleClassResolver;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
class AuthSessionTenantContextService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly AppContainer $container
|
||||
) {
|
||||
}
|
||||
|
||||
public function hydrateForUser(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$this->sessionStore->set('available_tenants', $availableTenants);
|
||||
|
||||
if (!$availableTenants) {
|
||||
$this->sessionStore->set('no_active_tenant', true);
|
||||
$this->sessionStore->remove('current_tenant');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sessionStore->set('no_active_tenant', false);
|
||||
|
||||
// If the DB has no preferred tenant stored, fall back to the first available one.
|
||||
$currentTenant = $this->userTenantContextService->getCurrentTenant($userId);
|
||||
if (!$currentTenant) {
|
||||
$currentTenant = $availableTenants[0];
|
||||
}
|
||||
|
||||
if ($currentTenant) {
|
||||
$this->sessionStore->set('current_tenant', $currentTenant);
|
||||
}
|
||||
|
||||
$user = $this->sessionStore->get('user', []);
|
||||
$userRecord = is_array($user) ? $user : [];
|
||||
foreach ($this->moduleSessionProviders() as $provider) {
|
||||
$provider->populate($userRecord, $this->container);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearModuleSessionData(): void
|
||||
{
|
||||
foreach ($this->moduleSessionProviders() as $provider) {
|
||||
$provider->clear($this->container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SessionProvider>
|
||||
*/
|
||||
private function moduleSessionProviders(): array
|
||||
{
|
||||
$providers = [];
|
||||
try {
|
||||
$registry = $this->container->get(ModuleRegistry::class);
|
||||
if (!$registry instanceof ModuleRegistry) {
|
||||
return [];
|
||||
}
|
||||
foreach ($registry->getSessionProviders() as $providerClass) {
|
||||
$provider = ModuleClassResolver::resolve($this->container, $providerClass);
|
||||
if ($provider instanceof SessionProvider) {
|
||||
$providers[] = $provider;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
}
|
||||
81
core/Service/Auth/AuthSettingsGateway.php
Normal file
81
core/Service/Auth/AuthSettingsGateway.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
|
||||
class AuthSettingsGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsMicrosoftGateway $settingsMicrosoftGateway,
|
||||
private readonly SettingsApiPolicyGateway $settingsApiPolicyGateway,
|
||||
private readonly SettingsDefaultsGateway $settingsDefaultsGateway,
|
||||
private readonly SettingsAppGateway $settingsAppGateway,
|
||||
private readonly SettingsLoginPersistenceGateway $settingsLoginPersistenceGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMicrosoftSharedClientId(): string
|
||||
{
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftSharedClientId();
|
||||
}
|
||||
|
||||
public function getMicrosoftSharedClientSecret(): string
|
||||
{
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftSharedClientSecret();
|
||||
}
|
||||
|
||||
public function getMicrosoftAuthority(): string
|
||||
{
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftAuthority();
|
||||
}
|
||||
|
||||
public function getApiTokenMaxTtlDays(): int
|
||||
{
|
||||
return $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays();
|
||||
}
|
||||
|
||||
public function getApiTokenDefaultTtlDays(): int
|
||||
{
|
||||
return $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays();
|
||||
}
|
||||
|
||||
public function getAppTheme(): string
|
||||
{
|
||||
return (string) $this->settingsAppGateway->getAppTheme();
|
||||
}
|
||||
|
||||
public function defaultTheme(): string
|
||||
{
|
||||
return $this->settingsAppGateway->resolveDefaultTheme();
|
||||
}
|
||||
|
||||
public function normalizeTheme(?string $theme): string
|
||||
{
|
||||
return $this->settingsAppGateway->normalizeTheme($theme);
|
||||
}
|
||||
|
||||
public function getDefaultRoleId(): int
|
||||
{
|
||||
return (int) $this->settingsDefaultsGateway->getDefaultRoleId();
|
||||
}
|
||||
|
||||
public function getDefaultDepartmentId(): int
|
||||
{
|
||||
return (int) $this->settingsDefaultsGateway->getDefaultDepartmentId();
|
||||
}
|
||||
|
||||
public function isMicrosoftAutoRememberDefault(): bool
|
||||
{
|
||||
return $this->settingsLoginPersistenceGateway->isMicrosoftAutoRememberDefault();
|
||||
}
|
||||
|
||||
public function getRememberTokenLifetimeDays(): int
|
||||
{
|
||||
return $this->settingsLoginPersistenceGateway->getRememberTokenLifetimeDays();
|
||||
}
|
||||
}
|
||||
27
core/Service/Auth/AuthTenantGateway.php
Normal file
27
core/Service/Auth/AuthTenantGateway.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
|
||||
|
||||
class AuthTenantGateway
|
||||
{
|
||||
public function __construct(private readonly TenantRepositoryInterface $tenantRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(int $tenantId): ?array
|
||||
{
|
||||
return $this->tenantRepository->find($tenantId);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return $this->tenantRepository->findByUuid($uuid);
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
return $this->tenantRepository->list();
|
||||
}
|
||||
}
|
||||
156
core/Service/Auth/EmailVerificationService.php
Normal file
156
core/Service/Auth/EmailVerificationService.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\Auth\EmailVerificationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
|
||||
class EmailVerificationService
|
||||
{
|
||||
private const CODE_LENGTH = 6;
|
||||
private const EXPIRY_MINUTES = 30;
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly EmailVerificationRepositoryInterface $emailVerificationRepository,
|
||||
private readonly MailService $mailService
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendVerification(int $userId, ?string $locale = null): array
|
||||
{
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'user_not_found'];
|
||||
}
|
||||
|
||||
$email = (string) ($user['email'] ?? '');
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
$this->emailVerificationRepository->invalidateForUser($userId);
|
||||
|
||||
$code = $this->generateCode();
|
||||
$codeHash = password_hash($code, PASSWORD_DEFAULT);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
|
||||
|
||||
$verificationId = $this->emailVerificationRepository->create($userId, $codeHash, $expiresAt);
|
||||
if (!$verificationId) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
|
||||
$isGerman = strpos((string) $locale, 'de') === 0;
|
||||
$greeting = $isGerman ? 'Hallo' : 'Hello';
|
||||
if ($name !== '') {
|
||||
$greeting .= ' ' . $name;
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('verify-email', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Email verification code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
'greeting' => $greeting,
|
||||
];
|
||||
$this->mailService->sendTemplate('email_verification', $vars, $email, $subject, $locale);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function verifyCode(string $email, string $code): array
|
||||
{
|
||||
$email = trim($email);
|
||||
$code = trim($code);
|
||||
if ($email === '' || $code === '') {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
|
||||
// Check if already verified
|
||||
if (!empty($user['email_verified_at'])) {
|
||||
return ['ok' => false, 'error' => 'already_verified'];
|
||||
}
|
||||
|
||||
$verification = $this->emailVerificationRepository->findActiveByUserId($userId);
|
||||
if (!$verification || !isset($verification['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$attempts = (int) ($verification['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
return ['ok' => false, 'error' => 'too_many_attempts'];
|
||||
}
|
||||
|
||||
$hash = (string) ($verification['code_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($code, $hash)) {
|
||||
$this->emailVerificationRepository->incrementAttempts((int) $verification['id']);
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
// Mark verification as used
|
||||
$this->emailVerificationRepository->markUsed((int) $verification['id']);
|
||||
|
||||
// Mark user email as verified
|
||||
$this->userWriteRepository->setEmailVerified($userId);
|
||||
|
||||
return ['ok' => true, 'user_id' => $userId];
|
||||
}
|
||||
|
||||
public function resendVerification(string $email, ?string $locale = null): array
|
||||
{
|
||||
$email = trim($email);
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
// Don't reveal if user exists
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (!empty($user['email_verified_at'])) {
|
||||
return ['ok' => false, 'error' => 'already_verified'];
|
||||
}
|
||||
|
||||
return $this->sendVerification((int) $user['id'], $locale);
|
||||
}
|
||||
|
||||
public function getExpiryMinutes(): int
|
||||
{
|
||||
return self::EXPIRY_MINUTES;
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
$max = (10 ** self::CODE_LENGTH) - 1;
|
||||
$code = (string) random_int(0, $max);
|
||||
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
294
core/Service/Auth/LdapAuthService.php
Normal file
294
core/Service/Auth/LdapAuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
core/Service/Auth/LdapConnectionGateway.php
Normal file
73
core/Service/Auth/LdapConnectionGateway.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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 mixed LDAP\Result on success, false on failure.
|
||||
*/
|
||||
public function search(\LDAP\Connection $conn, string $baseDn, string $filter, array $attributes = [], int $scope = 0): mixed
|
||||
{
|
||||
if ($scope === 1) {
|
||||
return @ldap_list($conn, $baseDn, $filter, $attributes);
|
||||
}
|
||||
return @ldap_search($conn, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
public function getEntries(\LDAP\Connection $conn, mixed $result): array|false
|
||||
{
|
||||
if (!$result instanceof \LDAP\Result) {
|
||||
return 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);
|
||||
}
|
||||
}
|
||||
528
core/Service/Auth/MicrosoftOidcService.php
Normal file
528
core/Service/Auth/MicrosoftOidcService.php
Normal file
@@ -0,0 +1,528 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
class MicrosoftOidcService
|
||||
{
|
||||
// 120s tolerance on exp/nbf checks to handle minor clock drift between systems.
|
||||
private const CLOCK_SKEW = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantSsoService $tenantSsoService,
|
||||
private readonly AuthTenantGateway $tenantGateway,
|
||||
private readonly OidcHttpGateway $oidcHttpGateway,
|
||||
private readonly MicrosoftOidcStateStoreService $stateStore
|
||||
) {
|
||||
}
|
||||
|
||||
public function startAuthorization(array $tenant, array $providerConfig): array
|
||||
{
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
|
||||
$oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
|
||||
if (!($oidc['ok'] ?? false)) {
|
||||
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
|
||||
}
|
||||
$metadata = $oidc['config'] ?? [];
|
||||
|
||||
// PKCE flow: store code_verifier in session, send only the SHA-256 challenge to the AS.
|
||||
$state = self::randomString(32);
|
||||
$nonce = self::randomString(32);
|
||||
$codeVerifier = self::randomString(64);
|
||||
$codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||
$locale = I18n::$locale ?? I18n::$defaultLocale ?? 'de';
|
||||
$redirectUri = appUrl(Request::withLocale('auth/microsoft/callback', $locale));
|
||||
|
||||
$this->stateStore->prune();
|
||||
$this->stateStore->store($state, [
|
||||
'tenant_id' => $tenantId,
|
||||
'nonce' => $nonce,
|
||||
'code_verifier' => $codeVerifier,
|
||||
'locale' => $locale,
|
||||
'redirect_uri' => $redirectUri,
|
||||
]);
|
||||
|
||||
$params = [
|
||||
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => $this->buildAuthorizationScope($providerConfig),
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$authorizeEndpoint = (string) ($metadata['authorization_endpoint'] ?? '');
|
||||
if ($authorizeEndpoint === '') {
|
||||
return ['ok' => false, 'error' => 'authorize_endpoint_missing'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'url' => $authorizeEndpoint . '?' . http_build_query($params),
|
||||
];
|
||||
}
|
||||
|
||||
public function handleCallback(string $state, string $code): array
|
||||
{
|
||||
$state = trim($state);
|
||||
$code = trim($code);
|
||||
if ($state === '' || $code === '') {
|
||||
return ['ok' => false, 'error' => 'callback_invalid'];
|
||||
}
|
||||
|
||||
$consumeResult = $this->stateStore->consume($state);
|
||||
if (!$consumeResult['ok']) {
|
||||
return ['ok' => false, 'error' => (string) ($consumeResult['error'] ?? 'state_invalid')];
|
||||
}
|
||||
|
||||
$entry = is_array($consumeResult['entry'] ?? null) ? $consumeResult['entry'] : [];
|
||||
$tenantId = (int) ($entry['tenant_id'] ?? 0);
|
||||
$tenant = $this->tenantGateway->findById($tenantId);
|
||||
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
|
||||
$configResult = $this->tenantSsoService->getEffectiveMicrosoftProviderConfig($tenant);
|
||||
if (!($configResult['ok'] ?? false)) {
|
||||
return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')];
|
||||
}
|
||||
$providerConfig = $configResult['config'] ?? [];
|
||||
|
||||
$oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
|
||||
if (!($oidc['ok'] ?? false)) {
|
||||
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
|
||||
}
|
||||
$metadata = $oidc['config'] ?? [];
|
||||
|
||||
$tokenEndpoint = (string) ($metadata['token_endpoint'] ?? '');
|
||||
if ($tokenEndpoint === '') {
|
||||
return ['ok' => false, 'error' => 'token_endpoint_missing'];
|
||||
}
|
||||
|
||||
$tokenResponse = $this->oidcHttpGateway->call('POST', $tokenEndpoint, [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
|
||||
'client_secret' => (string) ($providerConfig['client_secret'] ?? ''),
|
||||
'code' => $code,
|
||||
'redirect_uri' => (string) ($entry['redirect_uri'] ?? ''),
|
||||
'code_verifier' => (string) ($entry['code_verifier'] ?? ''),
|
||||
], [
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
if ((int) ($tokenResponse['status'] ?? 0) < 200 || (int) ($tokenResponse['status'] ?? 0) >= 300) {
|
||||
return ['ok' => false, 'error' => 'token_exchange_failed'];
|
||||
}
|
||||
|
||||
$tokenData = json_decode((string) ($tokenResponse['data'] ?? ''), true);
|
||||
if (!is_array($tokenData)) {
|
||||
return ['ok' => false, 'error' => 'token_response_invalid'];
|
||||
}
|
||||
$idToken = trim((string) ($tokenData['id_token'] ?? ''));
|
||||
if ($idToken === '') {
|
||||
return ['ok' => false, 'error' => 'id_token_missing'];
|
||||
}
|
||||
$accessToken = trim((string) ($tokenData['access_token'] ?? ''));
|
||||
|
||||
$validated = $this->validateIdToken(
|
||||
$idToken,
|
||||
$providerConfig,
|
||||
$metadata,
|
||||
(string) ($entry['nonce'] ?? '')
|
||||
);
|
||||
if (!($validated['ok'] ?? false)) {
|
||||
return ['ok' => false, 'error' => (string) ($validated['error'] ?? 'id_token_invalid')];
|
||||
}
|
||||
$claims = $validated['claims'] ?? [];
|
||||
|
||||
$email = self::extractEmailFromClaims($claims);
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_missing'];
|
||||
}
|
||||
|
||||
$allowedDomainsCsv = (string) ($providerConfig['allowed_domains'] ?? '');
|
||||
$allowedDomains = array_values(array_filter(array_map('trim', explode(',', $allowedDomainsCsv))));
|
||||
if (!$this->tenantSsoService->isEmailDomainAllowed($email, $allowedDomains)) {
|
||||
return ['ok' => false, 'error' => 'email_domain_not_allowed'];
|
||||
}
|
||||
|
||||
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
|
||||
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
|
||||
if ($syncEnabled && !$syncFields) {
|
||||
$syncFields = $this->tenantSsoService->defaultProfileSyncFields();
|
||||
}
|
||||
$graphProfile = [];
|
||||
if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') {
|
||||
$graph = $this->fetchMicrosoftGraphProfile($accessToken);
|
||||
if (!empty($graph['ok'])) {
|
||||
$graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : [];
|
||||
}
|
||||
}
|
||||
$graphAvatar = [];
|
||||
if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') {
|
||||
$avatar = $this->fetchMicrosoftGraphAvatar($accessToken);
|
||||
if (!empty($avatar['ok'])) {
|
||||
$graphAvatar = is_array($avatar['avatar'] ?? null) ? $avatar['avatar'] : [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'tenant' => $tenant,
|
||||
'locale' => (string) ($entry['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale ?? 'de')),
|
||||
'graph_profile' => $graphProfile,
|
||||
'graph_avatar' => $graphAvatar,
|
||||
'sync_config' => [
|
||||
'enabled' => $syncEnabled,
|
||||
'fields' => $syncFields,
|
||||
],
|
||||
'claims' => [
|
||||
'tid' => (string) ($claims['tid'] ?? ''),
|
||||
'oid' => (string) ($claims['oid'] ?? ''),
|
||||
'issuer' => (string) ($claims['iss'] ?? ''),
|
||||
'subject' => (string) ($claims['sub'] ?? ''),
|
||||
'email' => $email,
|
||||
'name' => trim((string) ($claims['name'] ?? '')),
|
||||
'given_name' => trim((string) ($claims['given_name'] ?? '')),
|
||||
'family_name' => trim((string) ($claims['family_name'] ?? '')),
|
||||
'preferred_language' => trim((string) ($claims['preferred_language'] ?? '')),
|
||||
'graph_given_name' => trim((string) ($graphProfile['given_name'] ?? '')),
|
||||
'graph_family_name' => trim((string) ($graphProfile['family_name'] ?? '')),
|
||||
'graph_display_name' => trim((string) ($graphProfile['display_name'] ?? '')),
|
||||
'graph_job_title' => trim((string) ($graphProfile['job_title'] ?? '')),
|
||||
'graph_phone' => trim((string) ($graphProfile['phone'] ?? '')),
|
||||
'graph_mobile' => trim((string) ($graphProfile['mobile'] ?? '')),
|
||||
'graph_avatar_mime' => trim((string) ($graphAvatar['mime'] ?? '')),
|
||||
'graph_avatar_data_base64' => trim((string) ($graphAvatar['data_base64'] ?? '')),
|
||||
'sync_profile_on_login' => $syncEnabled ? 1 : 0,
|
||||
'sync_profile_fields' => $syncFields,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildAuthorizationScope(array $providerConfig): string
|
||||
{
|
||||
$scopes = ['openid', 'profile', 'email'];
|
||||
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
|
||||
$syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
|
||||
if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields)) {
|
||||
$scopes[] = 'User.Read';
|
||||
}
|
||||
$scopes = array_values(array_unique($scopes));
|
||||
return implode(' ', $scopes);
|
||||
}
|
||||
|
||||
private function fetchOpenIdConfiguration(string $authority): array
|
||||
{
|
||||
$authority = trim($authority);
|
||||
if ($authority === '') {
|
||||
return ['ok' => false];
|
||||
}
|
||||
$url = rtrim($authority, '/') . '/.well-known/openid-configuration';
|
||||
$response = $this->oidcHttpGateway->call('GET', $url, '', ['Accept' => 'application/json']);
|
||||
if ((int) ($response['status'] ?? 0) < 200 || (int) ($response['status'] ?? 0) >= 300) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
$config = json_decode((string) ($response['data'] ?? ''), true);
|
||||
if (!is_array($config)) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
return ['ok' => true, 'config' => $config];
|
||||
}
|
||||
|
||||
private function validateIdToken(
|
||||
string $idToken,
|
||||
array $providerConfig,
|
||||
array $metadata,
|
||||
string $expectedNonce
|
||||
): array {
|
||||
$parts = explode('.', $idToken);
|
||||
if (count($parts) !== 3) {
|
||||
return ['ok' => false, 'error' => 'id_token_format_invalid'];
|
||||
}
|
||||
|
||||
$header = json_decode(self::base64UrlDecode($parts[0]), true);
|
||||
$claims = json_decode(self::base64UrlDecode($parts[1]), true);
|
||||
$signature = self::base64UrlDecode($parts[2]);
|
||||
if (!is_array($header) || !is_array($claims) || $signature === '') {
|
||||
return ['ok' => false, 'error' => 'id_token_parse_failed'];
|
||||
}
|
||||
|
||||
$alg = strtoupper(trim((string) ($header['alg'] ?? '')));
|
||||
if ($alg !== 'RS256') {
|
||||
return ['ok' => false, 'error' => 'id_token_alg_invalid'];
|
||||
}
|
||||
|
||||
$jwksUri = trim((string) ($metadata['jwks_uri'] ?? ''));
|
||||
if ($jwksUri === '') {
|
||||
return ['ok' => false, 'error' => 'jwks_uri_missing'];
|
||||
}
|
||||
|
||||
$jwksResponse = $this->oidcHttpGateway->call('GET', $jwksUri, '', ['Accept' => 'application/json']);
|
||||
if ((int) ($jwksResponse['status'] ?? 0) < 200 || (int) ($jwksResponse['status'] ?? 0) >= 300) {
|
||||
return ['ok' => false, 'error' => 'jwks_fetch_failed'];
|
||||
}
|
||||
$jwks = json_decode((string) ($jwksResponse['data'] ?? ''), true);
|
||||
if (!is_array($jwks) || !is_array($jwks['keys'] ?? null)) {
|
||||
return ['ok' => false, 'error' => 'jwks_invalid'];
|
||||
}
|
||||
|
||||
$kid = (string) ($header['kid'] ?? '');
|
||||
$jwk = null;
|
||||
foreach ($jwks['keys'] as $key) {
|
||||
if (!is_array($key)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($key['kid'] ?? '') === $kid) {
|
||||
$jwk = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$jwk) {
|
||||
return ['ok' => false, 'error' => 'jwk_not_found'];
|
||||
}
|
||||
|
||||
$pem = self::jwkToPem($jwk);
|
||||
if ($pem === null) {
|
||||
return ['ok' => false, 'error' => 'jwk_invalid'];
|
||||
}
|
||||
|
||||
$verified = openssl_verify(
|
||||
$parts[0] . '.' . $parts[1],
|
||||
$signature,
|
||||
$pem,
|
||||
OPENSSL_ALGO_SHA256
|
||||
);
|
||||
if ($verified !== 1) {
|
||||
return ['ok' => false, 'error' => 'id_token_signature_invalid'];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$exp = (int) ($claims['exp'] ?? 0);
|
||||
if ($exp <= 0 || $exp < ($now - self::CLOCK_SKEW)) {
|
||||
return ['ok' => false, 'error' => 'id_token_expired'];
|
||||
}
|
||||
|
||||
$nbf = (int) ($claims['nbf'] ?? 0);
|
||||
if ($nbf > 0 && $nbf > ($now + self::CLOCK_SKEW)) {
|
||||
return ['ok' => false, 'error' => 'id_token_not_yet_valid'];
|
||||
}
|
||||
|
||||
$audience = $claims['aud'] ?? '';
|
||||
$clientId = (string) ($providerConfig['client_id'] ?? '');
|
||||
$audValid = false;
|
||||
if (is_array($audience)) {
|
||||
$audValid = in_array($clientId, $audience, true);
|
||||
} else {
|
||||
$audValid = (string) $audience === $clientId;
|
||||
}
|
||||
if (!$audValid) {
|
||||
return ['ok' => false, 'error' => 'id_token_aud_invalid'];
|
||||
}
|
||||
|
||||
if ((string) ($claims['nonce'] ?? '') !== $expectedNonce) {
|
||||
return ['ok' => false, 'error' => 'id_token_nonce_invalid'];
|
||||
}
|
||||
|
||||
$tid = strtolower((string) ($claims['tid'] ?? ''));
|
||||
$expectedTid = strtolower((string) ($providerConfig['tenant_id'] ?? ''));
|
||||
if ($expectedTid === '' || $tid !== $expectedTid) {
|
||||
return ['ok' => false, 'error' => 'id_token_tid_invalid'];
|
||||
}
|
||||
|
||||
$iss = strtolower((string) ($claims['iss'] ?? ''));
|
||||
$issuerTemplate = strtolower((string) ($metadata['issuer'] ?? ''));
|
||||
if ($issuerTemplate !== '') {
|
||||
$expectedIssuer = str_replace('{tenantid}', $tid, $issuerTemplate);
|
||||
if ($iss !== $expectedIssuer) {
|
||||
return ['ok' => false, 'error' => 'id_token_iss_invalid'];
|
||||
}
|
||||
}
|
||||
|
||||
if ((string) ($claims['oid'] ?? '') === '' || (string) ($claims['sub'] ?? '') === '') {
|
||||
return ['ok' => false, 'error' => 'id_token_identity_invalid'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'claims' => $claims];
|
||||
}
|
||||
|
||||
private static function extractEmailFromClaims(array $claims): string
|
||||
{
|
||||
$email = trim((string) ($claims['email'] ?? ''));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return strtolower($email);
|
||||
}
|
||||
$upn = trim((string) ($claims['preferred_username'] ?? ''));
|
||||
if ($upn !== '' && filter_var($upn, FILTER_VALIDATE_EMAIL)) {
|
||||
return strtolower($upn);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function fetchMicrosoftGraphProfile(string $accessToken): array
|
||||
{
|
||||
$url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,displayName,jobTitle,mobilePhone,businessPhones';
|
||||
$response = $this->oidcHttpGateway->call('GET', $url, '', [
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
]);
|
||||
$status = (int) ($response['status'] ?? 0);
|
||||
if ($status < 200 || $status >= 300) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
$data = json_decode((string) ($response['data'] ?? ''), true);
|
||||
if (!is_array($data)) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
|
||||
$phone = '';
|
||||
$businessPhones = $data['businessPhones'] ?? [];
|
||||
if (is_array($businessPhones) && isset($businessPhones[0])) {
|
||||
$phone = trim((string) $businessPhones[0]);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'profile' => [
|
||||
'given_name' => trim((string) ($data['givenName'] ?? '')),
|
||||
'family_name' => trim((string) ($data['surname'] ?? '')),
|
||||
'display_name' => trim((string) ($data['displayName'] ?? '')),
|
||||
'job_title' => trim((string) ($data['jobTitle'] ?? '')),
|
||||
'mobile' => trim((string) ($data['mobilePhone'] ?? '')),
|
||||
'phone' => $phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchMicrosoftGraphAvatar(string $accessToken): array
|
||||
{
|
||||
$url = 'https://graph.microsoft.com/v1.0/me/photo/$value';
|
||||
$response = $this->oidcHttpGateway->call('GET', $url, '', [
|
||||
'Accept' => 'image/*',
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
]);
|
||||
$status = (int) ($response['status'] ?? 0);
|
||||
if ($status < 200 || $status >= 300) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
|
||||
$data = $response['data'] ?? null;
|
||||
if (!is_string($data) || $data === '') {
|
||||
return ['ok' => false];
|
||||
}
|
||||
|
||||
$contentType = self::headerValue($response, 'Content-Type');
|
||||
$contentType = strtolower(trim(explode(';', $contentType, 2)[0]));
|
||||
if (!in_array($contentType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
return ['ok' => false];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'avatar' => [
|
||||
'mime' => $contentType,
|
||||
'data_base64' => base64_encode($data),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function headerValue(array $response, string $name): string
|
||||
{
|
||||
$headers = is_array($response['headers'] ?? null) ? $response['headers'] : [];
|
||||
foreach ($headers as $key => $value) {
|
||||
if (strcasecmp((string) $key, $name) === 0) {
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function randomString(int $bytes): string
|
||||
{
|
||||
return self::base64UrlEncode(random_bytes($bytes));
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$pad = strlen($value) % 4;
|
||||
if ($pad > 0) {
|
||||
$value .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
$decoded = base64_decode($value, true);
|
||||
return is_string($decoded) ? $decoded : '';
|
||||
}
|
||||
|
||||
// Builds a PEM public key from a JWK RSA key (n, e) by constructing the ASN.1 DER structure manually.
|
||||
// PHP's openssl extension doesn't support importing JWK directly, so we encode it ourselves.
|
||||
private static function jwkToPem(array $jwk): ?string
|
||||
{
|
||||
$n = self::base64UrlDecode((string) ($jwk['n'] ?? ''));
|
||||
$e = self::base64UrlDecode((string) ($jwk['e'] ?? ''));
|
||||
if ($n === '' || $e === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modulus = self::asn1Integer($n);
|
||||
$publicExponent = self::asn1Integer($e);
|
||||
$rsaPublicKey = self::asn1Sequence($modulus . $publicExponent);
|
||||
|
||||
$algo = self::asn1Sequence(
|
||||
self::asn1ObjectIdentifier("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") .
|
||||
self::asn1Null()
|
||||
);
|
||||
$bitString = "\x03" . self::asn1Length(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey;
|
||||
$subjectPublicKeyInfo = self::asn1Sequence($algo . $bitString);
|
||||
|
||||
$pem = "-----BEGIN PUBLIC KEY-----\n";
|
||||
$pem .= chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n");
|
||||
$pem .= "-----END PUBLIC KEY-----\n";
|
||||
return $pem;
|
||||
}
|
||||
|
||||
private static function asn1Integer(string $bytes): string
|
||||
{
|
||||
if (ord($bytes[0]) > 0x7F) {
|
||||
$bytes = "\x00" . $bytes;
|
||||
}
|
||||
return "\x02" . self::asn1Length(strlen($bytes)) . $bytes;
|
||||
}
|
||||
|
||||
private static function asn1Sequence(string $bytes): string
|
||||
{
|
||||
return "\x30" . self::asn1Length(strlen($bytes)) . $bytes;
|
||||
}
|
||||
|
||||
private static function asn1ObjectIdentifier(string $bytes): string
|
||||
{
|
||||
return "\x06" . self::asn1Length(strlen($bytes)) . $bytes;
|
||||
}
|
||||
|
||||
private static function asn1Null(): string
|
||||
{
|
||||
return "\x05\x00";
|
||||
}
|
||||
|
||||
private static function asn1Length(int $length): string
|
||||
{
|
||||
if ($length < 128) {
|
||||
return chr($length);
|
||||
}
|
||||
$temp = ltrim(pack('N', $length), "\x00");
|
||||
return chr(0x80 | strlen($temp)) . $temp;
|
||||
}
|
||||
}
|
||||
99
core/Service/Auth/MicrosoftOidcStateStoreService.php
Normal file
99
core/Service/Auth/MicrosoftOidcStateStoreService.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
class MicrosoftOidcStateStoreService
|
||||
{
|
||||
private const SESSION_KEY = 'microsoft_oidc_states';
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly int $ttlSeconds = 600,
|
||||
private readonly int $maxEntries = 50
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(string $state, array $payload): void
|
||||
{
|
||||
$state = trim($state);
|
||||
if ($state === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$states = $this->states();
|
||||
$states = $this->pruneExpiredStates($states);
|
||||
|
||||
$payload['created_at'] = (int) ($payload['created_at'] ?? time());
|
||||
$states[$state] = $payload;
|
||||
|
||||
// Cap size to prevent session bloat from many unfinished authorization flows.
|
||||
if (count($states) > $this->maxEntries) {
|
||||
uasort($states, static function (array $a, array $b): int {
|
||||
return ((int) ($a['created_at'] ?? 0)) <=> ((int) ($b['created_at'] ?? 0));
|
||||
});
|
||||
while (count($states) > $this->maxEntries) {
|
||||
array_shift($states);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,error?:string,entry?:array}
|
||||
*/
|
||||
public function consume(string $state): array
|
||||
{
|
||||
$state = trim($state);
|
||||
if ($state === '') {
|
||||
return ['ok' => false, 'error' => 'state_invalid'];
|
||||
}
|
||||
|
||||
$states = $this->states();
|
||||
$entry = is_array($states[$state] ?? null) ? $states[$state] : null;
|
||||
if ($entry === null) {
|
||||
return ['ok' => false, 'error' => 'state_invalid'];
|
||||
}
|
||||
|
||||
// Remove the state before checking TTL — prevents replay even if the check below rejects it.
|
||||
unset($states[$state]);
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
|
||||
$createdAt = (int) ($entry['created_at'] ?? 0);
|
||||
if ($createdAt <= 0 || (time() - $createdAt) > $this->ttlSeconds) {
|
||||
return ['ok' => false, 'error' => 'state_expired'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'entry' => $entry];
|
||||
}
|
||||
|
||||
public function prune(): void
|
||||
{
|
||||
$this->sessionStore->set(self::SESSION_KEY, $this->pruneExpiredStates($this->states()));
|
||||
}
|
||||
|
||||
private function states(): array
|
||||
{
|
||||
$states = $this->sessionStore->get(self::SESSION_KEY, []);
|
||||
return is_array($states) ? $states : [];
|
||||
}
|
||||
|
||||
private function pruneExpiredStates(array $states): array
|
||||
{
|
||||
$now = time();
|
||||
foreach ($states as $key => $payload) {
|
||||
if (!is_array($payload)) {
|
||||
unset($states[$key]);
|
||||
continue;
|
||||
}
|
||||
$createdAt = (int) ($payload['created_at'] ?? 0);
|
||||
if ($createdAt <= 0 || ($now - $createdAt) > $this->ttlSeconds) {
|
||||
unset($states[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
}
|
||||
16
core/Service/Auth/OidcHttpGateway.php
Normal file
16
core/Service/Auth/OidcHttpGateway.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Curl;
|
||||
|
||||
class OidcHttpGateway
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function call(string $method, string $url, mixed $data = '', array $headers = []): array
|
||||
{
|
||||
return Curl::call($method, $url, $data, $headers);
|
||||
}
|
||||
}
|
||||
159
core/Service/Auth/PasswordResetService.php
Normal file
159
core/Service/Auth/PasswordResetService.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use MintyPHP\Service\User\UserPasswordService;
|
||||
|
||||
class PasswordResetService
|
||||
{
|
||||
private const CODE_LENGTH = 6;
|
||||
private const EXPIRY_MINUTES = 15;
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
|
||||
private readonly UserPasswordService $userPasswordService,
|
||||
private readonly RememberMeService $rememberMeService,
|
||||
private readonly MailService $mailService
|
||||
) {
|
||||
}
|
||||
|
||||
public function requestReset(string $email, ?string $locale = null): array
|
||||
{
|
||||
$email = trim($email);
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
// Return ok:true even when the email is unknown — prevents email enumeration.
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
$this->passwordResetRepository->invalidateForUser($userId);
|
||||
|
||||
$code = $this->generateCode();
|
||||
$codeHash = password_hash($code, PASSWORD_DEFAULT);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
|
||||
|
||||
$resetId = $this->passwordResetRepository->create($userId, $codeHash, $expiresAt);
|
||||
if (!$resetId) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
|
||||
$isGerman = strpos((string) $locale, 'de') === 0;
|
||||
$greeting = $isGerman ? 'Hallo' : 'Hello';
|
||||
if ($name !== '') {
|
||||
$greeting .= ' ' . $name;
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('password/verify', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Password reset code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
'greeting' => $greeting,
|
||||
];
|
||||
$this->mailService->sendTemplate('reset_code', $vars, $email, $subject, $locale);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function verifyCode(string $email, string $code): array
|
||||
{
|
||||
$email = trim($email);
|
||||
$code = trim($code);
|
||||
if ($email === '' || $code === '') {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$reset = $this->passwordResetRepository->findActiveByUserId((int) $user['id']);
|
||||
if (!$reset || !isset($reset['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$attempts = (int) ($reset['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
return ['ok' => false, 'error' => 'too_many_attempts'];
|
||||
}
|
||||
|
||||
$hash = (string) ($reset['code_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($code, $hash)) {
|
||||
$this->passwordResetRepository->incrementAttempts((int) $reset['id']);
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'reset_id' => (int) $reset['id'], 'user_id' => (int) $user['id']];
|
||||
}
|
||||
|
||||
public function resetPassword(int $resetId, string $password, string $password2): array
|
||||
{
|
||||
$reset = $this->passwordResetRepository->findById($resetId);
|
||||
if (!$reset || !isset($reset['id'])) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request not found')]];
|
||||
}
|
||||
|
||||
if (!empty($reset['used_at'])) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request already used')]];
|
||||
}
|
||||
|
||||
$expiresAt = (string) ($reset['expires_at'] ?? '');
|
||||
if ($expiresAt !== '') {
|
||||
try {
|
||||
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
|
||||
if ($expiry->getTimestamp() <= time()) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request expired')]];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request expired')]];
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) ($reset['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request not found')]];
|
||||
}
|
||||
|
||||
$result = $this->userPasswordService->resetPassword($userId, $password, $password2);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->passwordResetRepository->markUsed($resetId);
|
||||
$this->rememberMeService->forgetAllForUser($userId);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// random_int is cryptographically secure; str_pad preserves leading zeros (e.g. "001234").
|
||||
private function generateCode(): string
|
||||
{
|
||||
$max = (10 ** self::CODE_LENGTH) - 1;
|
||||
$code = (string) random_int(0, $max);
|
||||
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
49
core/Service/Auth/RememberMeRateLimiter.php
Normal file
49
core/Service/Auth/RememberMeRateLimiter.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Cache;
|
||||
|
||||
class RememberMeRateLimiter
|
||||
{
|
||||
private const KEY_PREFIX = 'remember_rl:';
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
private const WINDOW_SECONDS = 900; // 15 minutes
|
||||
|
||||
public function isBlocked(string $ip): bool
|
||||
{
|
||||
if ($ip === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$count = Cache::get(self::KEY_PREFIX . $ip);
|
||||
|
||||
return is_int($count) && $count >= self::MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
public function recordFailure(string $ip): void
|
||||
{
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = self::KEY_PREFIX . $ip;
|
||||
|
||||
// add() only sets if key doesn't exist — creates the window.
|
||||
// If key already exists, add() returns false and we just increment.
|
||||
if (Cache::add($key, 1, self::WINDOW_SECONDS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::increment($key);
|
||||
}
|
||||
|
||||
public function reset(string $ip): void
|
||||
{
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::delete(self::KEY_PREFIX . $ip);
|
||||
}
|
||||
}
|
||||
207
core/Service/Auth/RememberMeService.php
Normal file
207
core/Service/Auth/RememberMeService.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Session;
|
||||
|
||||
class RememberMeService
|
||||
{
|
||||
private const COOKIE_NAME = 'remember';
|
||||
private const LIFETIME_DAYS = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly RememberTokenRepositoryInterface $rememberTokenRepository,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime,
|
||||
private readonly ?AuthSettingsGateway $authSettingsGateway = null,
|
||||
private readonly ?RememberMeRateLimiter $rateLimiter = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function rememberUser(int $userId): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(12));
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds());
|
||||
$this->rememberTokenRepository->create($userId, $selector, $tokenHash, $expiresAt);
|
||||
$this->setCookie($selector, $token);
|
||||
}
|
||||
|
||||
public function autoLoginFromCookie(): bool
|
||||
{
|
||||
$sessionUser = $this->sessionStore->get('user', []);
|
||||
if (is_array($sessionUser) && !empty($sessionUser['id'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $this->cookieStore->get($this->cookieName());
|
||||
if ($value === '' || strpos($value, ':') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $this->requestRuntime->ip();
|
||||
|
||||
if ($this->rateLimiter !== null && $this->rateLimiter->isBlocked($ip)) {
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
[$selector, $token] = explode(':', $value, 2);
|
||||
$selector = trim($selector);
|
||||
$token = trim($token);
|
||||
if ($selector === '' || $token === '') {
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$record = $this->rememberTokenRepository->findBySelector($selector);
|
||||
if (!$record) {
|
||||
$this->rateLimiter?->recordFailure($ip);
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$expiresAt = (string) ($record['expires_at'] ?? '');
|
||||
if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) {
|
||||
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = (string) ($record['token_hash'] ?? '');
|
||||
if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) {
|
||||
$this->rateLimiter?->recordFailure($ip);
|
||||
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = (int) ($record['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
if (!$user || empty($user['id']) || !($user['active'] ?? 1)) {
|
||||
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
||||
$this->clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
Session::regenerate();
|
||||
$this->sessionStore->set('user', $user);
|
||||
$now = time();
|
||||
$this->sessionStore->set('session_started_at', $now);
|
||||
$this->sessionStore->set('session_last_activity', $now);
|
||||
if (!empty($user['locale'])) {
|
||||
I18n::$locale = (string) $user['locale'];
|
||||
}
|
||||
$userId = (int) $user['id'];
|
||||
if ($userId > 0) {
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->authSessionTenantContextService->hydrateForUser($userId);
|
||||
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
||||
// Enforce the same tenant policy as interactive login.
|
||||
$this->forgetCurrentUser();
|
||||
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||
Auth::logout();
|
||||
return false;
|
||||
}
|
||||
$this->userWriteRepository->updateLastLogin($userId, 'local');
|
||||
}
|
||||
|
||||
// Rotate token on each auto-login — limits the window if a cookie is stolen.
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$newHash = hash('sha256', $newToken);
|
||||
$newExpires = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds());
|
||||
$this->rememberTokenRepository->updateToken((int) $record['id'], $newHash, $newExpires);
|
||||
$this->setCookie($selector, $newToken);
|
||||
|
||||
$this->rateLimiter?->reset($ip);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function forgetCurrentUser(): void
|
||||
{
|
||||
$value = $this->cookieStore->get($this->cookieName());
|
||||
if ($value !== '' && strpos($value, ':') !== false) {
|
||||
[$selector] = explode(':', $value, 2);
|
||||
$selector = trim($selector);
|
||||
if ($selector !== '') {
|
||||
$record = $this->rememberTokenRepository->findBySelector($selector);
|
||||
if ($record && isset($record['id'])) {
|
||||
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->clearCookie();
|
||||
}
|
||||
|
||||
public function forgetAllForUser(int $userId): void
|
||||
{
|
||||
$this->rememberTokenRepository->deleteByUserId($userId);
|
||||
}
|
||||
|
||||
public function expireAllTokensByAdmin(): int
|
||||
{
|
||||
return $this->rememberTokenRepository->expireAllByAdmin();
|
||||
}
|
||||
|
||||
private function setCookie(string $selector, string $token): void
|
||||
{
|
||||
$value = $selector . ':' . $token;
|
||||
$expires = time() + $this->lifetimeSeconds();
|
||||
$secure = $this->requestRuntime->isSecure();
|
||||
|
||||
$this->cookieStore->set($this->cookieName(), $value, [
|
||||
'expires' => $expires,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
private function clearCookie(): void
|
||||
{
|
||||
$secure = $this->requestRuntime->isSecure();
|
||||
$this->cookieStore->remove($this->cookieName(), [
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
private function lifetimeSeconds(): int
|
||||
{
|
||||
if ($this->authSettingsGateway !== null) {
|
||||
return $this->authSettingsGateway->getRememberTokenLifetimeDays() * 86400;
|
||||
}
|
||||
|
||||
return self::LIFETIME_DAYS * 86400;
|
||||
}
|
||||
|
||||
private function cookieName(): string
|
||||
{
|
||||
$name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME;
|
||||
return trim($name) !== '' ? $name : self::COOKIE_NAME;
|
||||
}
|
||||
|
||||
}
|
||||
438
core/Service/Auth/SsoUserLinkService.php
Normal file
438
core/Service/Auth/SsoUserLinkService.php
Normal file
@@ -0,0 +1,438 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
979
core/Service/Auth/TenantSsoService.php
Normal file
979
core/Service/Auth/TenantSsoService.php
Normal file
@@ -0,0 +1,979 @@
|
||||
<?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'];
|
||||
}
|
||||
if ($auth['client_secret_override_enc'] !== '') {
|
||||
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, '-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user