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 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 = $this->resolveAppUrl($verifyPath); $previousLocale = I18n::$locale ?? null; I18n::$locale = $locale; $subject = t('Email verification 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('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); } 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); } }