prepare("SELECT TIMESTAMPDIFF(SECOND, created_at, NOW()) FROM {$table} WHERE email = ? LIMIT 1"); $stmt->execute([$email]); $age = $stmt->fetchColumn(); if ($age !== false && (int) $age < CODE_RESEND_SECONDS) { return null; } $code = generate_code(); $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); $pdo->prepare("INSERT INTO {$table} (email, token, attempts, created_at) VALUES (?, ?, 0, NOW())") ->execute([$email, hash('sha256', $code)]); return $code; } /** * Checks a code and consumes an attempt. Returns 'ok', 'code_invalid' or 'code_expired'. * A spent or exhausted record is deleted, so the caller must request a new code. */ function check_code(PDO $pdo, string $table, string $email, string $code): string { $stmt = $pdo->prepare( "SELECT token, attempts, TIMESTAMPDIFF(MINUTE, created_at, NOW()) AS age FROM {$table} WHERE email = ? LIMIT 1" ); $stmt->execute([$email]); $row = $stmt->fetch(); if (!$row) { return 'code_invalid'; } if ((int) $row['age'] >= CODE_TTL_MINUTES) { $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); return 'code_expired'; } if (!hash_equals((string) $row['token'], hash('sha256', $code))) { $attempts = (int) $row['attempts'] + 1; if ($attempts >= CODE_MAX_ATTEMPTS) { $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); return 'code_expired'; } $pdo->prepare("UPDATE {$table} SET attempts = ? WHERE email = ?")->execute([$attempts, $email]); return 'code_invalid'; } $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); return 'ok'; } /** Issues a bearer token exactly like login.php does. */ function issue_token(PDO $pdo, int $userId, string $deviceName): string { $config = require __DIR__ . '/config.php'; $plain = bin2hex(random_bytes(32)); $expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s'); $pdo->prepare( 'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at) VALUES (?, ?, ?, ?, NOW())' )->execute([$userId, $deviceName, hash('sha256', $plain), $expires]); return $plain; }