exec( "CREATE TABLE IF NOT EXISTS `password_resets` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `token_hash` CHAR(64) NOT NULL, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `requested_ip` VARCHAR(45) NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_token_hash` (`token_hash`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } function password_reset_hash(string $token): string { return hash('sha256', $token); } /** * Issue a reset link and mail it. * * $resetUrl receives the token: "https://site/auth/reset.php?token=%s". * * 'ok' is what the public form should show, and it is true for every * well-formed address -- known or not, delivered or not. A transport failure * comes back separately in 'delivery_error', because if that were folded into * 'ok' the form would answer differently for a known address than an unknown * one whenever SMTP is down, which is the enumeration leak this is avoiding. * Show 'delivery_error' to an operator, never to an anonymous visitor. */ function password_reset_request(PDO $pdo, string $email, string $resetUrl, array $opts = []): array { password_reset_ensure_table($pdo); $s = auth_schema(); $email = mb_strtolower(trim($email)); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return ['ok' => false, 'sent' => false, 'error' => 'Endereço de email inválido.', 'delivery_error' => null]; } $st = $pdo->prepare( "SELECT * FROM `{$s['table']}` WHERE `{$s['email']}` = ? AND " . auth_active_sql() . " LIMIT 1" ); $st->execute([$email]); $user = $st->fetch(PDO::FETCH_ASSOC) ?: null; if ($user === null) { error_log("[password_reset] request for unknown or inactive address: {$email}"); return ['ok' => true, 'sent' => false, 'error' => null, 'delivery_error' => null]; } $token = bin2hex(random_bytes(32)); $pdo->prepare( "INSERT INTO password_resets (user_id, token_hash, expires_at, requested_ip) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?)" )->execute([ (int) $user[$s['id']], password_reset_hash($token), (int) ($opts['ttl'] ?? PASSWORD_RESET_TTL_SECONDS), auth_client_ip(), ]); $link = sprintf($resetUrl, urlencode($token)); $appName = (string) ($opts['app_name'] ?? 'a plataforma'); $name = trim((string) ($user[$s['name']] ?? '')) ?: $email; $minutes = (int) round(((int) ($opts['ttl'] ?? PASSWORD_RESET_TTL_SECONDS)) / 60); $safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8'); $safeApp = htmlspecialchars($appName, ENT_QUOTES, 'UTF-8'); $safeLink = htmlspecialchars($link, ENT_QUOTES, 'UTF-8'); $html = "

Olá {$safeName},

" . "

Recebemos um pedido para redefinir a palavra-passe da tua conta em {$safeApp}.

" . "

Definir uma nova palavra-passe

" . "

O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.

" . "

Se não foste tu, ignora este email — a palavra-passe atual mantém-se.

"; $text = "Olá {$name},\n\n" . "Recebemos um pedido para redefinir a palavra-passe da tua conta em {$appName}.\n\n" . "{$link}\n\n" . "O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.\n" . "Se não foste tu, ignora este email — a palavra-passe atual mantém-se.\n"; $res = platform_mail_send( [ 'to' => $email, 'to_name' => $name, 'subject' => 'Redefinir a palavra-passe — ' . $appName, 'body' => $html, 'alt_body' => $text, ], (string) ($opts['repo_root'] ?? ''), platform_smtp_config($pdo) ); if (empty($res['ok'])) { error_log('[password_reset] send failed: ' . (string) ($res['error'] ?? 'unknown')); return ['ok' => true, 'sent' => false, 'error' => null, 'delivery_error' => 'Não foi possível enviar o email. Verifica a configuração de SMTP.']; } return ['ok' => true, 'sent' => true, 'error' => null, 'delivery_error' => null]; } /** Returns the user row a valid, unexpired, unused token belongs to. */ function password_reset_validate(PDO $pdo, string $token): ?array { password_reset_ensure_table($pdo); $s = auth_schema(); $st = $pdo->prepare( "SELECT r.id AS reset_id, r.user_id FROM password_resets r WHERE r.token_hash = ? AND r.used_at IS NULL AND r.expires_at > NOW() LIMIT 1" ); $st->execute([password_reset_hash($token)]); $row = $st->fetch(PDO::FETCH_ASSOC); if (!$row) return null; $st = $pdo->prepare( "SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1" ); $st->execute([(int) $row['user_id']]); $user = $st->fetch(PDO::FETCH_ASSOC) ?: null; if ($user === null) return null; $user['_reset_id'] = (int) $row['reset_id']; return $user; } /** * Set the new password and burn every outstanding token for that user. * * Wrapped in a transaction: a password changed without its token being * invalidated would leave a working reset link behind. */ function password_reset_consume(PDO $pdo, string $token, string $newPassword, array $opts = []): array { $minLength = (int) ($opts['min_length'] ?? 10); if (mb_strlen($newPassword) < $minLength) { return ['ok' => false, 'error' => "A palavra-passe tem de ter pelo menos {$minLength} caracteres."]; } $user = password_reset_validate($pdo, $token); if ($user === null) { return ['ok' => false, 'error' => 'Link inválido ou expirado. Pede um novo.']; } $s = auth_schema(); try { $pdo->beginTransaction(); $pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?") ->execute([auth_password_hash($newPassword), (int) $user[$s['id']]]); $pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL") ->execute([(int) $user[$s['id']]]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); error_log('[password_reset] consume failed: ' . $e->getMessage()); return ['ok' => false, 'error' => 'Não foi possível guardar a nova palavra-passe.']; } // A completed reset is a privilege change; no pre-existing session survives. auth_session_regenerate(); auth_throttle_clear($pdo, (string) ($user[$s['email']] ?? '')); // Uma palavra-passe reposta termina as sessões lembradas em todos os // dispositivos: repor é o que se faz quando se suspeita de que alguém a tem. // (O dance fazia-o à mão; passou a ser aqui, para toda a frota.) require_once __DIR__ . '/functions_remember.php'; try { auth_remember_forget_user($pdo, (int) $user[$s['id']]); } catch (Throwable $e) { error_log('[password_reset] remember: ' . $e->getMessage()); } return ['ok' => true, 'error' => null, 'user_id' => (int) $user[$s['id']]]; } /** Housekeeping for a cron job: drop spent and expired rows. */ function password_reset_prune(PDO $pdo, int $keepDays = 7): int { password_reset_ensure_table($pdo); $st = $pdo->prepare( "DELETE FROM password_resets WHERE (used_at IS NOT NULL OR expires_at < NOW()) AND created_at < (NOW() - INTERVAL ? DAY)" ); $st->execute([$keepDays]); return $st->rowCount(); }