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:
'
. '
'
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 < ?)"