Files
breadcrumb-the-shire/pages/auth/login().php

444 lines
24 KiB
PHP
Raw Normal View History

2026-02-04 23:31:53 +01:00
<?php
use MintyPHP\Http\IntendedUrlService;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
2026-02-04 23:31:53 +01:00
use MintyPHP\Router;
2026-02-23 12:58:19 +01:00
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Security\SecurityServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
2026-02-04 23:31:53 +01:00
$requestRuntime = app(RequestRuntimeInterface::class);
$sessionStore = app(SessionStoreInterface::class);
$intendedUrlService = app(IntendedUrlService::class);
// Store return_to from query parameter (set by session timeout or auth guard redirect)
// or from hidden form field (preserved through multi-stage login POST flow).
$returnTo = trim((string) (requestInput()->queryAll()['return_to'] ?? ''));
if ($returnTo === '') {
$returnTo = trim((string) (requestInput()->bodyAll()['return_to'] ?? ''));
}
if ($returnTo !== '') {
$intendedUrlService->store($returnTo, $sessionStore);
}
// Expose sanitized return_to for hidden form fields so it survives across
// POST stages even if the query string or session is lost.
$returnToSanitized = $intendedUrlService->sanitize($returnTo !== '' ? $returnTo : ($intendedUrlService->retrieve($sessionStore) ?? ''));
$session = $sessionStore->all();
if (!empty($session['user']['id'])) {
2026-03-05 11:17:42 +01:00
if (Flash::has()) {
Flash::keep();
}
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
2026-03-05 11:17:42 +01:00
return;
2026-02-04 23:31:53 +01:00
}
2026-03-04 15:56:58 +01:00
$tenantHint = trim((string) (requestInput()->queryAll()['tenant'] ?? requestInput()->bodyAll()['tenant_hint'] ?? ''));
$stage = 'resolve_email';
$email = '';
$tenantCandidates = [];
$tenantCandidateById = [];
$selectedTenantId = 0;
$selectedTenant = null;
$selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => '', 'ldap' => false, 'ldap_reason' => ''];
2026-03-04 15:56:58 +01:00
$errorBag = formErrors();
$setLoginWarning = static function (string $message) use ($errorBag): void {
2026-03-05 11:17:42 +01:00
$errorBag->addGlobal($message);
};
$setRateLimitWarning = static function (int $retryAfterSeconds) use ($setLoginWarning): void {
2026-03-05 11:17:42 +01:00
$retryAfterSeconds = max(1, $retryAfterSeconds);
http_response_code(429);
header('Retry-After: ' . $retryAfterSeconds);
$setLoginWarning(t('Too many login attempts. Please wait and try again.'));
};
2026-03-04 15:56:58 +01:00
$rateLimiterService = (app(SecurityServicesFactory::class))->createRateLimiterService();
$loginResolveScope = 'login.resolve.ip';
$loginResolveLimit = 25;
$loginResolveWindow = 600;
$loginResolveBlock = 300;
$loginPasswordScope = 'login.password.email_ip';
$loginPasswordLimit = 5;
$loginPasswordWindow = 900;
$loginPasswordBlock = 900;
2026-03-04 15:56:58 +01:00
$authServicesFactory = app(AuthServicesFactory::class);
2026-02-23 12:58:19 +01:00
$authService = $authServicesFactory->createAuthService();
$rememberMeService = $authServicesFactory->createRememberMeService();
$tenantSsoService = $authServicesFactory->createTenantSsoService();
2026-03-04 15:56:58 +01:00
$tenantAvatarService = (app(TenantServicesFactory::class))->createTenantAvatarService();
$userServicesFactory = app(UserServicesFactory::class);
2026-02-23 12:58:19 +01:00
$userReadRepository = $userServicesFactory->createUserReadRepository();
$userTenantContextService = $userServicesFactory->createUserTenantContextService();
2026-02-23 12:58:19 +01:00
$resolveLoginCandidates = static function (string $inputEmail) use ($userReadRepository, $userTenantContextService, $tenantSsoService, $tenantAvatarService): array {
2026-03-05 11:17:42 +01:00
$emailValue = strtolower(trim($inputEmail));
if ($emailValue === '' || !filter_var($emailValue, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false];
}
2026-03-05 11:17:42 +01:00
$user = $userReadRepository->findByEmail($emailValue);
if (!$user || (int) ($user['active'] ?? 0) !== 1) {
return ['ok' => false];
}
2026-03-05 11:17:42 +01:00
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
return ['ok' => false];
}
2026-03-05 11:17:42 +01:00
$tenants = $userTenantContextService->getAssignedActiveTenants($userId);
if (!$tenants) {
return ['ok' => false];
}
2026-03-05 11:17:42 +01:00
$candidates = [];
$candidateById = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantSlug = $tenantSsoService->tenantLoginSlug($tenant);
if ($tenantSlug === '') {
continue;
}
$methods = $tenantSsoService->resolveTenantLoginMethods($tenantId);
$tenantUuid = (string) ($tenant['uuid'] ?? '');
$hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid);
$candidate = [
'id' => $tenantId,
'uuid' => $tenantUuid,
'description' => (string) ($tenant['description'] ?? ''),
'slug' => $tenantSlug,
'has_avatar' => $hasAvatar,
'avatar_url' => $hasAvatar
? lurl('auth/tenant-avatar-file?uuid=' . rawurlencode($tenantUuid) . '&size=256')
: '',
'methods' => $methods,
];
$candidates[] = $candidate;
$candidateById[$tenantId] = $candidate;
}
2026-03-05 11:17:42 +01:00
if (!$candidates) {
return ['ok' => false];
}
2026-03-05 11:17:42 +01:00
return [
'ok' => true,
'email' => $emailValue,
'user' => $user,
'candidates' => $candidates,
'candidate_by_id' => $candidateById,
];
};
$resolveSelectedTenantId = static function (array $candidateByIdValue, int $requestedTenantIdValue, string $tenantHintValue): int {
2026-03-05 11:17:42 +01:00
if ($requestedTenantIdValue > 0 && isset($candidateByIdValue[$requestedTenantIdValue])) {
return $requestedTenantIdValue;
}
2026-03-05 11:17:42 +01:00
$hint = trim(strtolower($tenantHintValue));
if ($hint !== '') {
foreach ($candidateByIdValue as $candidate) {
$candidateSlug = strtolower((string) ($candidate['slug'] ?? ''));
if ($candidateSlug !== '' && $candidateSlug === $hint) {
return (int) ($candidate['id'] ?? 0);
}
}
}
2026-03-05 11:17:42 +01:00
$first = reset($candidateByIdValue);
if (!is_array($first)) {
return 0;
}
return (int) ($first['id'] ?? 0);
};
2026-03-04 15:56:58 +01:00
if (requestInput()->method() === 'POST') {
2026-03-05 11:17:42 +01:00
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
2026-03-04 15:56:58 +01:00
$stage = 'resolve_email';
2026-03-05 11:17:42 +01:00
} else {
$postedStage = trim((string) (requestInput()->bodyAll()['stage'] ?? 'resolve_email'));
$email = trim((string) (requestInput()->bodyAll()['email'] ?? ''));
$clientIp = trim((string) ($requestRuntime->ip()));
2026-03-05 11:17:42 +01:00
if ($clientIp === '') {
$clientIp = 'unknown';
2026-03-04 15:56:58 +01:00
}
2026-03-05 11:17:42 +01:00
if ($postedStage === 'reset') {
$email = '';
2026-03-04 15:56:58 +01:00
$stage = 'resolve_email';
2026-03-05 11:17:42 +01:00
} else {
if ($postedStage === 'resolve_email') {
$resolveLimitResult = $rateLimiterService->isBlocked($loginResolveScope, $clientIp);
if (!($resolveLimitResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($resolveLimitResult['retry_after'] ?? 60));
return;
}
}
2026-03-05 11:17:42 +01:00
$resolved = $resolveLoginCandidates($email);
if (!$resolved['ok']) {
$stage = 'resolve_email';
$setLoginWarning(t('We could not continue with these login details'));
if ($postedStage === 'resolve_email') {
$resolveFailureResult = $rateLimiterService->registerFailure(
$loginResolveScope,
$clientIp,
$loginResolveLimit,
$loginResolveWindow,
$loginResolveBlock
);
if (!($resolveFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($resolveFailureResult['retry_after'] ?? $loginResolveBlock));
}
}
} else {
$email = (string) $resolved['email'];
$tenantCandidates = $resolved['candidates'];
$tenantCandidateById = $resolved['candidate_by_id'];
$postedTenantId = (int) (requestInput()->bodyAll()['tenant_id'] ?? 0);
2026-03-04 15:56:58 +01:00
if (in_array($postedStage, ['choose_tenant', 'login_password', 'login_ldap'], true)
2026-03-05 11:17:42 +01:00
&& ($postedTenantId <= 0 || !isset($tenantCandidateById[$postedTenantId]))) {
$stage = 'choose_tenant';
$setLoginWarning(t('We could not continue with these login details'));
$selectedTenantId = $resolveSelectedTenantId(
$tenantCandidateById,
0,
trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint))
);
$selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null;
$selectedTenantMethods = is_array($selectedTenant['methods'] ?? null)
? $selectedTenant['methods']
: $selectedTenantMethods;
} else {
2026-03-05 11:17:42 +01:00
$selectedTenantId = $resolveSelectedTenantId(
$tenantCandidateById,
$postedTenantId,
trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint))
);
2026-03-05 11:17:42 +01:00
$selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null;
if (!is_array($selectedTenant)) {
$stage = 'resolve_email';
$setLoginWarning(t('We could not continue with these login details'));
2026-03-04 15:56:58 +01:00
} else {
2026-03-05 11:17:42 +01:00
$selectedTenantMethods = $selectedTenant['methods'];
$selectedTenantSlug = trim((string) $selectedTenant['slug']);
$isMicrosoftOnlyTenant = empty($selectedTenantMethods['local'])
&& !empty($selectedTenantMethods['microsoft'])
&& empty($selectedTenantMethods['ldap'])
2026-03-05 11:17:42 +01:00
&& $selectedTenantSlug !== '';
if (
$postedStage === 'resolve_email'
&& count($tenantCandidates) === 1
&& $isMicrosoftOnlyTenant
) {
Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug));
return;
}
if (
$postedStage === 'choose_tenant'
&& $isMicrosoftOnlyTenant
) {
Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug));
return;
}
if ($postedStage === 'resolve_email') {
$stage = count($tenantCandidates) === 1 ? 'methods' : 'choose_tenant';
} elseif ($postedStage === 'choose_tenant') {
$stage = 'methods';
} elseif ($postedStage === 'login_ldap') {
$stage = 'methods';
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;
$passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey);
if (!($passwordLimitResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60));
return;
}
if (empty($selectedTenantMethods['ldap'])) {
$setLoginWarning(t('LDAP login is not available for this tenant.'));
} else {
$ldapUsername = trim((string) (requestInput()->bodyAll()['ldap_username'] ?? ''));
$ldapPassword = (string) (requestInput()->bodyAll()['password'] ?? '');
$remember = !empty(requestInput()->bodyAll()['remember']);
$ldapAuthService = $authServicesFactory->createLdapAuthService();
$ssoUserLinkService = $authServicesFactory->createSsoUserLinkService();
$tenantRecord = app(\MintyPHP\Service\Auth\AuthGatewayFactory::class)
->createAuthTenantGateway()
->findById($selectedTenantId);
if (!$tenantRecord) {
$setLoginWarning(t('We could not continue with these login details'));
} else {
$ldapConfigResult = $tenantSsoService->getEffectiveLdapProviderConfig($tenantRecord);
if (!($ldapConfigResult['ok'] ?? false)) {
$setLoginWarning(t('LDAP login is not available for this tenant.'));
} else {
$ldapConfig = $ldapConfigResult['config'];
$allowedDomains = trim((string) ($ldapConfig['allowed_domains'] ?? ''));
if ($allowedDomains !== '' && $email !== '') {
$domainList = array_filter(array_map('trim', explode(',', $allowedDomains)));
if (!$tenantSsoService->isEmailDomainAllowed($email, $domainList)) {
$setLoginWarning(t('Your email domain is not allowed for LDAP login on this tenant.'));
return;
}
}
$ldapResult = $ldapAuthService->authenticate($ldapConfig, $ldapUsername !== '' ? $ldapUsername : $email, $ldapPassword);
if (!($ldapResult['ok'] ?? false)) {
$ldapError = $ldapResult['error'] ?? 'unknown';
if ($ldapError === 'credentials_empty') {
$setLoginWarning(t('Please enter your username and password.'));
} elseif ($ldapError === 'connection_failed') {
$setLoginWarning(t('LDAP server is not reachable. Please try again later.'));
} else {
$setLoginWarning(t('Username or password not valid'));
}
$passwordFailureResult = $rateLimiterService->registerFailure(
$loginPasswordScope,
$passwordAttemptKey,
$loginPasswordLimit,
$loginPasswordWindow,
$loginPasswordBlock
);
if (!($passwordFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
}
} else {
$linkResult = $ssoUserLinkService->linkOrProvisionLdapUser($tenantRecord, $ldapResult, $ldapConfig);
if (!($linkResult['ok'] ?? false)) {
$linkError = $linkResult['error'] ?? 'unknown';
if ($linkError === 'user_inactive') {
$setLoginWarning(t('Your account is deactivated.'));
} elseif ($linkError === 'email_missing') {
$setLoginWarning(t('Your LDAP account does not have an email address configured.'));
} else {
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
}
} else {
$ldapUserId = (int) ($linkResult['user_id'] ?? 0);
$loginResult = $authService->loginUserById($ldapUserId, $selectedTenantId, 'ldap');
if (!($loginResult['ok'] ?? false)) {
$setLoginWarning(t('Login could not be completed. Please contact your administrator.'));
} else {
$rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey);
if ($remember || $tenantSsoService->shouldAutoRememberOnLdapLogin($selectedTenantId)) {
$rememberMeService->rememberUser($ldapUserId);
}
$sessionStore->set('session_started_at', time());
$sessionStore->set('session_last_activity', time());
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
return;
}
}
}
}
}
}
2026-03-05 11:17:42 +01:00
} elseif ($postedStage === 'login_password') {
$stage = 'methods';
$passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp;
$passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey);
if (!($passwordLimitResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60));
return;
}
if (empty($selectedTenantMethods['local'])) {
$setLoginWarning(t('Password login is disabled for this tenant. Please use Microsoft login.'));
} else {
$password = (string) (requestInput()->bodyAll()['password'] ?? '');
$remember = !empty(requestInput()->bodyAll()['remember']);
$result = $authService->login($email, $password);
if (!($result['ok'] ?? false)) {
if (!empty($result['needs_verification'])) {
$sessionStore->set('email_verification_email', $result['email'] ?? $email);
2026-03-05 11:17:42 +01:00
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
Router::redirect('verify-email');
return;
}
$setLoginWarning(t((string) ($result['message'] ?? 'Email/password not valid')));
$passwordFailureResult = $rateLimiterService->registerFailure(
$loginPasswordScope,
$passwordAttemptKey,
$loginPasswordLimit,
$loginPasswordWindow,
$loginPasswordBlock
);
if (!($passwordFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
}
} else {
$authenticatedUser = $sessionStore->get('user', []);
$userId = (int) (is_array($authenticatedUser) ? ($authenticatedUser['id'] ?? 0) : 0);
2026-03-05 11:17:42 +01:00
if ($userId <= 0 || !$authService->canLoginToTenant($userId, $selectedTenantId)) {
$authService->logout();
$setLoginWarning(t('No access to this tenant'));
$passwordFailureResult = $rateLimiterService->registerFailure(
$loginPasswordScope,
$passwordAttemptKey,
$loginPasswordLimit,
$loginPasswordWindow,
$loginPasswordBlock
);
if (!($passwordFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
}
} else {
$setTenant = $userTenantContextService->setCurrentTenant($userId, $selectedTenantId);
if (!($setTenant['ok'] ?? false)) {
$authService->logout();
$setLoginWarning(t('No access to this tenant'));
$passwordFailureResult = $rateLimiterService->registerFailure(
$loginPasswordScope,
$passwordAttemptKey,
$loginPasswordLimit,
$loginPasswordWindow,
$loginPasswordBlock
);
if (!($passwordFailureResult['allowed'] ?? true)) {
$setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock));
}
} else {
$rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey);
$authService->loadTenantDataIntoSession($userId);
if ($remember) {
$rememberMeService->rememberUser($userId);
}
$sessionStore->set('session_started_at', time());
$sessionStore->set('session_last_activity', time());
Router::redirect($intendedUrlService->retrieveAndForget($sessionStore));
2026-03-05 11:17:42 +01:00
return;
}
}
}
}
} else {
$stage = 'resolve_email';
$setLoginWarning(t('We could not continue with these login details'));
}
}
}
}
}
}
2026-02-04 23:31:53 +01:00
}
2026-03-04 15:56:58 +01:00
$errors = $errorBag->toFlatList();