appTitle !== '' ? $this->appTitle : appTitle(); } private function resolveAppUrl(string $path = ''): string { if ($this->appUrl !== '') { return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : ''); } return appUrl($path); } 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 = $this->resolveAppUrl($verifyPath); $previousLocale = I18n::$locale ?? null; I18n::$locale = $locale; $subject = t('Password reset code'); I18n::$locale = $previousLocale; $vars = [ 'app_name' => $this->resolveAppTitle(), 'app_logo_url' => $this->resolveAppUrl(appLogoUrl(128)), 'imprint_url' => $this->resolveAppUrl(Request::withLocale('imprint', $locale)), 'privacy_url' => $this->resolveAppUrl(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); } }