- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.1 KiB
PHP
215 lines
9.1 KiB
PHP
<?php
|
|
// ─── Password reset over the shared SMTP transport ───────────────────────────
|
|
//
|
|
// Recovery used to be somebody else's problem: silver's views/auth/restore.php
|
|
// and reset_password.php were one-line redirects to id.andmore.pt, which no
|
|
// longer exists, so nobody could recover an account at all. This implements it
|
|
// locally, delivering through platform_mail_send() with platform_smtp_config()
|
|
// -- the same transport the rest of the estate already uses, so a site that can
|
|
// send any mail can send these.
|
|
//
|
|
// Token handling, and why:
|
|
// - The token is 32 random bytes, hex. Only its SHA-256 is stored, so a
|
|
// leaked database dump cannot be used to reset anyone's password.
|
|
// - Requesting a reset for an unknown address returns the same result as a
|
|
// known one. Otherwise the form enumerates which addresses have accounts.
|
|
// - Consuming a token deletes every outstanding token for that user, so a
|
|
// forwarded or resent link cannot be replayed after the real reset.
|
|
// - The session is regenerated on success: a password reset is a privilege
|
|
// change, and any session an attacker already holds must not survive it.
|
|
|
|
require_once __DIR__ . '/functions_auth.php';
|
|
require_once __DIR__ . '/functions_mail.php';
|
|
require_once __DIR__ . '/functions_settings.php';
|
|
|
|
const PASSWORD_RESET_TTL_SECONDS = 3600; // one hour
|
|
|
|
function password_reset_ensure_table(PDO $pdo): void {
|
|
$pdo->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 = "<p>Olá {$safeName},</p>"
|
|
. "<p>Recebemos um pedido para redefinir a palavra-passe da tua conta em <strong>{$safeApp}</strong>.</p>"
|
|
. "<p><a href=\"{$safeLink}\">Definir uma nova palavra-passe</a></p>"
|
|
. "<p>O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.</p>"
|
|
. "<p>Se não foste tu, ignora este email — a palavra-passe atual mantém-se.</p>";
|
|
|
|
$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();
|
|
}
|