: // echo platform_captcha_field('registo', $pdo); // // // no handler, depois do CSRF: // if (($erro = platform_captcha_verify('registo', $pdo)) !== null) { // $error = $erro; // já em português, pronto a mostrar // } // // O primeiro argumento é o âmbito: entra na assinatura, para que um desafio // colhido no formulário de contacto não sirva no de registo. Dois formulários na // mesma página com âmbitos diferentes são independentes. // // API pública: // platform_captcha_config(?array) — configuração do módulo // platform_captcha_field(string, ?PDO) — o bloco HTML pronto a imprimir // platform_captcha_issue(string, ?PDO) — pergunta + token, para marcação própria // platform_captcha_verify(string, ?PDO) — null passa, ou a mensagem de erro // platform_captcha_ok(string, ?PDO) — o mesmo em booleano // // E o recurso — a razão pela qual este módulo existe. Ver a secção no fim: // platform_captcha_appeal_offer(PDO, form, reason) — guarda a submissão recusada // platform_captcha_appeal_field(token, form, ?PDO) — o bloco do segundo ecrã // platform_captcha_appeal_claim(PDO, form, &$error) — devolve a submissão libertada require_once __DIR__ . '/functions_settings.php'; require_once __DIR__ . '/functions_client_ip.php'; /** * Configuração. Com um array define; sem argumentos, lê. */ function platform_captcha_config(?array $set = null): array { static $cfg = [ // 'math' (contas), 'letter' (letra de uma palavra) ou 'mixed' (sorteia // uma das duas por desafio). Misturar é de graça e obriga quem // automatiza a resolver dois formatos em vez de um. 'type' => 'mixed', // Validade do desafio. Curta de propósito: sem sessão activa é ela que // limita quantas respostas cabem num token. 15 minutos chegam para // preencher um formulário e não chegam para um ataque com paciência. 'ttl' => 900, // Nomes dos campos. Configuráveis porque um site pode já ter um campo // chamado 'captcha' vindo de outra coisa. 'field_answer' => 'captcha_answer', 'field_token' => 'captcha_token', // Classes do Bootstrap 5, que é o que toda a frota carrega. Um sítio sem // Bootstrap passa strings vazias e estiliza por fora. 'class_wrap' => 'mb-3', 'class_label' => 'form-label', 'class_input' => 'form-control', 'class_help' => 'form-text', 'help' => 'Uma pergunta simples, para travar envios automáticos.', 'autocomplete' => 'off', // Uma tentativa por pergunta, quando há sessão. Ver o cabeçalho. 'single_use' => true, // Um token forjado ou em falta é sinal de bot e conta para a escalada do // functions_ip_ban.php. Uma resposta errada NÃO conta, e a diferença é // deliberada: a escada bane aos 3 registos em 24 h, e quem se engana // duas vezes a escrever "catorze" não é um bot — seria banir gente por // errar uma pergunta que nós próprios lhe fizemos. 'offence_forged' => true, 'offence_wrong' => false, // ─── O recurso ────────────────────────────────────────────────────── // Tabela própria, criada à cabeça como as do functions_ip_ban.php: a // `blocked_submissions` tem forma diferente em cada site e é um registo, // não uma fila de trabalho. 'appeal_table' => 'captcha_appeals', // Duas horas para responder. É uma pessoa a olhar para o ecrã, não um // formulário deixado aberto — e quanto mais curto, menos tempo a // submissão recusada fica guardada. 'appeal_ttl' => 7200, // Tentativas por recurso, e recursos por IP à hora. O primeiro impede // que se esgotem as respostas possíveis numa só submissão retida; o // segundo impede que a fila de recursos vire um depósito de spam. 'appeal_tries' => 6, 'appeal_max_ip' => 5, // Motivos que NÃO dão direito a recurso. A recusa silenciosa existe // para o bot não ficar a saber o que o apanhou, e estes dois não têm // leitura inocente: o honeypot é um campo que ninguém vê, e o carimbo só // é produzido pelo nosso próprio formulário. Oferecer recurso a quem // falha ali é ensiná-lo a contornar. Todos os outros — país, limite por // IP, pontuação de conteúdo, formulário expirado — têm falsos positivos // reais e é para eles que isto serve. 'appeal_never' => ['honeypot', 'carimbo inválido'], // Palavras do desafio da letra. Sem acentos, de propósito: a resposta é // uma letra e não vale a pena pôr ninguém a decidir se escreve "a" ou // "á". 'words' => [ 'CADEIRA', 'JANELA', 'SAPATO', 'CAMINHO', 'MERCADO', 'PALAVRA', 'LARANJA', 'MONTANHA', 'TELEFONE', 'BICICLETA', 'ESCADA', 'FLORESTA', 'CAMISOLA', 'GUITARRA', 'PADEIRO', 'CADERNO', ], ]; if ($set !== null) { $cfg = array_merge($cfg, $set); } return $cfg; } /** * A chave que assina os desafios. * * Domínio próprio: um carimbo do anti-spam não valida aqui, nem ao contrário. A * derivação e os seus três degraus estão em functions_settings.php, que é onde * vive a única implementação. */ function platform_captcha_key(?PDO $pdo = null): string { return platform_derived_key('captcha', $pdo); } /** * A pergunta e a resposta, derivadas do nonce. * * Determinista: o mesmo nonce, com a mesma chave e o mesmo âmbito, dá sempre o * mesmo par. É isso que dispensa guardar seja o que for — o servidor volta a * calcular a resposta no pedido seguinte em vez de se lembrar dela. * * Note-se que a resposta nunca vai para o cliente, nem sequer como hash: um hash * de "14" quebra-se com vinte tentativas numa consola. * * @return array{question:string, answer:string} */ function platform_captcha_challenge(string $form, string $nonce, ?PDO $pdo = null): array { $cfg = platform_captcha_config(); $b = array_values(unpack( 'C*', hash_hmac('sha256', 'q|' . $form . '|' . $nonce, platform_captcha_key($pdo), true) )); $type = $cfg['type']; if ($type === 'mixed') { $type = ($b[0] % 2 === 0) ? 'math' : 'letter'; } if ($type === 'letter') { $words = array_values($cfg['words']); $word = $words ? $words[$b[1] % count($words)] : 'CADEIRA'; // Até à quinta letra, e nunca além do tamanho da palavra. $max = min(5, mb_strlen($word, 'UTF-8')); $pos = 1 + ($b[2] % $max); $ord = [1 => 'primeira', 2 => 'segunda', 3 => 'terceira', 4 => 'quarta', 5 => 'quinta'][$pos]; return [ 'question' => 'Escreva a ' . $ord . ' letra da palavra ' . $word . '.', 'answer' => mb_substr($word, $pos - 1, 1, 'UTF-8'), ]; } // Contas. Os limites dos operandos mantêm o resultado entre 0 e 20, que é o // intervalo que platform_captcha_number_words() sabe escrever por extenso. switch ($b[1] % 3) { case 0: $x = 1 + ($b[2] % 9); $y = 1 + ($b[3] % 9); $op = 'mais'; $r = $x + $y; break; case 1: // O maior primeiro: um resultado negativo é uma pergunta injusta. $x = 2 + ($b[2] % 8); $y = 1 + ($b[3] % ($x - 1)); $op = 'menos'; $r = $x - $y; break; default: $x = 2 + ($b[2] % 4); $y = 2 + ($b[3] % 3); $op = 'vezes'; $r = $x * $y; } // Um operando por extenso e o outro em algarismos, sorteados. Custa nada e // parte o regex /(\d+)\s*\+\s*(\d+)/ com que se resolve um captcha destes em // três linhas. $words = platform_captcha_number_words(); $left = ($b[4] % 2 === 0) ? (string) $x : $words[$x]; $right = ($b[4] % 2 === 0) ? $words[$y] : (string) $y; // "duas vezes três", não "dois vezes três": quem conta ali são as vezes, e // "vez" é feminino. Só acontece com o operando da esquerda de uma // multiplicação, e só quando calha ser escrito por extenso. A resposta não // muda com isto — platform_captcha_canonical_answer() já lê "duas" como 2. if ($op === 'vezes' && $left === 'dois') { $left = 'duas'; } return [ 'question' => 'Quanto é ' . $left . ' ' . $op . ' ' . $right . '?', 'answer' => (string) $r, ]; } /** 0–20 por extenso, já na forma normalizada (minúsculas, sem acentos). */ function platform_captcha_number_words(): array { return [ 0 => 'zero', 'um', 'dois', 'tres', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove', 'dez', 'onze', 'doze', 'treze', 'catorze', 'quinze', 'dezasseis', 'dezassete', 'dezoito', 'dezanove', 'vinte', ]; } /** * Normaliza uma resposta: minúsculas, sem acentos, sem nada que não seja letra * ou algarismo. "Catorze", "catorze " e "CATORZE" são a mesma resposta. */ function platform_captcha_normalize(string $s): string { $s = mb_strtolower(trim($s), 'UTF-8'); $s = strtr($s, [ 'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'ñ' => 'n', ]); return (string) preg_replace('/[^a-z0-9]+/', '', $s); } /** * A resposta de quem preencheu, posta na mesma forma que a nossa: por extenso * vira algarismos, e as variantes do Brasil são aceites porque quem as escreve * respondeu certo. */ function platform_captcha_canonical_answer(string $raw): string { $n = platform_captcha_normalize($raw); $alias = [ 'uma' => 'um', 'duas' => 'dois', 'quatorze' => 'catorze', 'dezesseis' => 'dezasseis', 'dezessete' => 'dezassete', 'dezenove' => 'dezanove', ]; $n = $alias[$n] ?? $n; $words = array_flip(platform_captcha_number_words()); if (isset($words[$n])) { return (string) $words[$n]; } // "07" e "7" são a mesma resposta. if ($n !== '' && ctype_digit($n)) { return (string) (int) $n; } return $n; } /** * Emite um desafio: a pergunta, o token e os nomes dos campos. * * Para quem quer a sua própria marcação — um formulário em grelha, um modal, um * passo de um assistente. Quem só quer um campo normal usa * platform_captcha_field(), que é isto com HTML à volta. * * @return array{question:string, token:string, field_answer:string, field_token:string, id:string} */ function platform_captcha_issue(string $form = 'form', ?PDO $pdo = null): array { $cfg = platform_captcha_config(); $ts = time(); $nonce = bin2hex(random_bytes(9)); $sig = platform_captcha_sign($form, $ts, $nonce, $pdo); $ch = platform_captcha_challenge($form, $nonce, $pdo); return [ 'question' => $ch['question'], 'token' => $ts . '.' . $nonce . '.' . $sig, 'field_answer' => $cfg['field_answer'], 'field_token' => $cfg['field_token'], // Único por instância: a mesma página pode ter dois captchas, e dois //