2026-02-04 23:31:53 +01:00
|
|
|
<?php
|
|
|
|
|
|
2026-02-11 19:28:12 +01:00
|
|
|
namespace MintyPHP\Service\Auth;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
use MintyPHP\Http\Request;
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\I18n;
|
2026-03-05 08:26:51 +01:00
|
|
|
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
use MintyPHP\Service\Mail\MailService;
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\Service\User\UserPasswordService;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
class PasswordResetService
|
|
|
|
|
{
|
|
|
|
|
private const CODE_LENGTH = 6;
|
|
|
|
|
private const EXPIRY_MINUTES = 15;
|
|
|
|
|
private const MAX_ATTEMPTS = 5;
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function __construct(
|
2026-03-05 08:26:51 +01:00
|
|
|
private readonly UserReadRepositoryInterface $userReadRepository,
|
|
|
|
|
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
|
2026-02-23 12:58:19 +01:00
|
|
|
private readonly UserPasswordService $userPasswordService,
|
|
|
|
|
private readonly RememberMeService $rememberMeService,
|
|
|
|
|
private readonly MailService $mailService
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function requestReset(string $email, ?string $locale = null): array
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$email = trim($email);
|
|
|
|
|
if ($email === '') {
|
|
|
|
|
return ['ok' => false, 'error' => 'email_required'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$user = $this->userReadRepository->findByEmail($email);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!$user || !isset($user['id'])) {
|
|
|
|
|
return ['ok' => true];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$userId = (int) $user['id'];
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->passwordResetRepository->invalidateForUser($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$code = $this->generateCode();
|
2026-02-04 23:31:53 +01:00
|
|
|
$codeHash = password_hash($code, PASSWORD_DEFAULT);
|
|
|
|
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$resetId = $this->passwordResetRepository->create($userId, $codeHash, $expiresAt);
|
2026-02-04 23:31:53 +01:00
|
|
|
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,
|
|
|
|
|
];
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->mailService->sendTemplate('reset_code', $vars, $email, $subject, $locale);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
return ['ok' => true];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function verifyCode(string $email, string $code): array
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$email = trim($email);
|
|
|
|
|
$code = trim($code);
|
|
|
|
|
if ($email === '' || $code === '') {
|
|
|
|
|
return ['ok' => false, 'error' => 'invalid'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$user = $this->userReadRepository->findByEmail($email);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!$user || !isset($user['id'])) {
|
|
|
|
|
return ['ok' => false, 'error' => 'invalid'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$reset = $this->passwordResetRepository->findActiveByUserId((int) $user['id']);
|
2026-02-04 23:31:53 +01:00
|
|
|
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)) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->passwordResetRepository->incrementAttempts((int) $reset['id']);
|
2026-02-04 23:31:53 +01:00
|
|
|
return ['ok' => false, 'error' => 'invalid'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['ok' => true, 'reset_id' => (int) $reset['id'], 'user_id' => (int) $user['id']];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function resetPassword(int $resetId, string $password, string $password2): array
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-02-23 12:58:19 +01:00
|
|
|
$reset = $this->passwordResetRepository->findById($resetId);
|
2026-02-04 23:31:53 +01:00
|
|
|
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')]];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$result = $this->userPasswordService->resetPassword($userId, $password, $password2);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!($result['ok'] ?? false)) {
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->passwordResetRepository->markUsed($resetId);
|
|
|
|
|
$this->rememberMeService->forgetAllForUser($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
return ['ok' => true];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function generateCode(): string
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$max = (10 ** self::CODE_LENGTH) - 1;
|
|
|
|
|
$code = (string) random_int(0, $max);
|
|
|
|
|
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
|
|
|
|
}
|
|
|
|
|
}
|