diff --git a/public/api/auth_register.php b/public/api/auth_register.php index b8b6e6c..01e1a74 100644 --- a/public/api/auth_register.php +++ b/public/api/auth_register.php @@ -19,6 +19,19 @@ if ($action === 'verify') { json_out($result, $result['ok'] ? 200 : 400); } +if ($action === 'resend') { + $result = AuthRegistration::resendVerification((string) ($data['email'] ?? '')); + json_out($result, $result['ok'] ? 200 : 400); +} + +if ($action === 'pending') { + $pending = AuthRegistration::pendingVerification((string) ($data['email'] ?? '')); + if ($pending === null) { + json_out(['ok' => false, 'error' => 'not_pending'], 404); + } + json_out($pending, ($pending['ok'] ?? false) ? 200 : 410); +} + $email = (string) ($data['email'] ?? ''); $password = (string) ($data['password'] ?? ''); $username = (string) ($data['username'] ?? ''); diff --git a/src/AuthMailer.php b/src/AuthMailer.php index 8fb5e5b..9846728 100644 --- a/src/AuthMailer.php +++ b/src/AuthMailer.php @@ -28,7 +28,7 @@ final class AuthMailer { return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); } - public static function sendVerifyEmail(string $to, string $verifyUrl): bool { + public static function sendVerifyEmail(string $to, string $verifyUrl, string $validFor = '24h'): bool { $subject = 'Verify your Android Cast account'; $shortUrl = $verifyUrl; $qrImageUrl = null; @@ -49,7 +49,7 @@ final class AuthMailer { $qrImageUrl = null; } } - $body = "Open this link to verify your email (valid 24h):\n\n" + $body = "Open this link to verify your email (valid {$validFor}):\n\n" . $shortUrl . "\n"; $bodyHtml = null; $inline = []; @@ -70,7 +70,7 @@ final class AuthMailer { $cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast'; $escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $bodyHtml = '' - . '

Open this link to verify your email (valid 24h):

' + . '

Open this link to verify your email (valid ' . htmlspecialchars($validFor, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '):

' . '

' . $escUrl . '

' . '

Or scan this QR code on your phone:

' . '

Verify email QR code

' diff --git a/src/AuthRegistration.php b/src/AuthRegistration.php index 7a0a33f..259e67c 100644 --- a/src/AuthRegistration.php +++ b/src/AuthRegistration.php @@ -3,8 +3,6 @@ declare(strict_types=1); /** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */ final class AuthRegistration { - private const TOKEN_TTL_S = 86400; - private function __construct() { } @@ -12,7 +10,12 @@ final class AuthRegistration { AuthEmailSchema::ensure(Database::pdo()); } - /** @return array{ok:bool,error?:string} */ + public static function verifyEmailTtlSeconds(): int { + $ttl = (int) cfg('auth.verify_email_ttl', 86400); + return max(60, min($ttl, 604800)); + } + + /** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */ public static function register(string $email, string $password, string $username = ''): array { self::ensureSchema(); if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) { @@ -33,9 +36,13 @@ final class AuthRegistration { } $pdo = Database::pdo(); self::purgeStalePending($pdo); - $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); + $st = $pdo->prepare('SELECT id, status FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); $st->execute([$emailNorm, $username]); - if ($st->fetchColumn()) { + $existing = $st->fetch(PDO::FETCH_ASSOC); + if ($existing) { + if (($existing['status'] ?? '') === 'pending') { + return self::resendVerification($emailNorm); + } AuthAttempts::record('register_fail', $username); return ['ok' => false, 'error' => 'already_registered']; } @@ -50,20 +57,117 @@ final class AuthRegistration { 'pending', ]); $userId = (int) $pdo->lastInsertId(); + $sent = self::issueVerifyToken($pdo, $userId, $emailNorm); + if (!$sent['ok']) { + return $sent; + } + AuthAttempts::record('register_ok', $username); + return [ + 'ok' => true, + 'expires_at' => $sent['expires_at'], + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */ + public static function resendVerification(string $email): array { + self::ensureSchema(); + $emailNorm = self::normalizeEmail($email); + if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'invalid_email']; + } + if (AuthAttempts::isRateLimited('resend:' . $emailNorm)) { + return ['ok' => false, 'error' => 'rate_limited']; + } + $pdo = Database::pdo(); + self::purgeStalePending($pdo); + $st = $pdo->prepare( + "SELECT id FROM users WHERE email_normalized = ? AND status = 'pending' AND email_verified_at IS NULL LIMIT 1" + ); + $st->execute([$emailNorm]); + $userId = (int) $st->fetchColumn(); + if ($userId <= 0) { + return ['ok' => false, 'error' => 'not_pending']; + } + $pdo->prepare( + "UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL" + )->execute([date('Y-m-d H:i:s'), $userId]); + $sent = self::issueVerifyToken($pdo, $userId, $emailNorm); + if (!$sent['ok']) { + return $sent; + } + AuthAttempts::record('register_resend', $emailNorm); + return [ + 'ok' => true, + 'expires_at' => $sent['expires_at'], + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** + * Pending verification window for countdown UI. + * + * @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string}|null + */ + public static function pendingVerification(string $email): ?array { + self::ensureSchema(); + $emailNorm = self::normalizeEmail($email); + if ($emailNorm === '') { + return null; + } + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT u.id FROM users u + WHERE u.email_normalized = ? AND u.status = 'pending' AND u.email_verified_at IS NULL + LIMIT 1" + ); + $st->execute([$emailNorm]); + $userId = (int) $st->fetchColumn(); + if ($userId <= 0) { + return null; + } + $tok = $pdo->prepare( + "SELECT expires_at FROM auth_tokens + WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL + ORDER BY id DESC LIMIT 1" + ); + $tok->execute([$userId]); + $expiresAt = (string) ($tok->fetchColumn() ?: ''); + if ($expiresAt === '') { + return null; + } + $exp = strtotime($expiresAt); + if ($exp !== false && $exp < time()) { + return ['ok' => false, 'error' => 'expired_token', 'email' => $emailNorm]; + } + return [ + 'ok' => true, + 'expires_at' => $expiresAt, + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** @return array{ok:bool,error?:string,expires_at?:string} */ + private static function issueVerifyToken(\PDO $pdo, int $userId, string $emailNorm): array { $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); - $expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S); + $ttl = self::verifyEmailTtlSeconds(); + $expires = date('Y-m-d H:i:s', time() + $ttl); $pdo->prepare( 'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)' )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); $verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token); - if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { + $ttlLabel = self::formatTtlLabel($ttl); + if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl, $ttlLabel)) { error_log('AuthRegistration: verify mail failed for ' . $emailNorm); + return ['ok' => false, 'error' => 'mail_failed']; } - AuthAttempts::record('register_ok', $username); - return ['ok' => true]; + return ['ok' => true, 'expires_at' => $expires]; } /** @return array{ok:bool,error?:string} */ @@ -96,13 +200,26 @@ final class AuthRegistration { return ['ok' => true]; } + private static function formatTtlLabel(int $ttlSeconds): string { + if ($ttlSeconds >= 86400 && $ttlSeconds % 86400 === 0) { + $days = (int) ($ttlSeconds / 86400); + return $days === 1 ? '24h' : $days . ' days'; + } + if ($ttlSeconds >= 3600 && $ttlSeconds % 3600 === 0) { + $hours = (int) ($ttlSeconds / 3600); + return $hours === 1 ? '1 hour' : $hours . ' hours'; + } + $mins = max(1, (int) ceil($ttlSeconds / 60)); + return $mins === 1 ? '1 minute' : $mins . ' minutes'; + } + private static function normalizeEmail(string $email): string { return strtolower(trim($email)); } - /** Drop unverified accounts started more than 24h ago. */ + /** Drop unverified accounts started more than verify TTL ago. */ private static function purgeStalePending(\PDO $pdo): void { - $cutoff = date('Y-m-d H:i:s', time() - self::TOKEN_TTL_S); + $cutoff = date('Y-m-d H:i:s', time() - self::verifyEmailTtlSeconds()); $st = $pdo->prepare( "SELECT id FROM users WHERE status = 'pending' AND email_verified_at IS NULL AND (created_at IS NULL OR created_at < ?)"