Files
breadcrumb-the-shire/core/Service/Auth/EmailVerificationService.php

167 lines
5.8 KiB
PHP

<?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,
private readonly string $appTitle = '',
private readonly string $appUrl = '',
) {
}
private function resolveAppTitle(): string
{
return $this->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' => appLogoUrlAbsolute(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);
}
}