Files
fitme3070.pt/shared/platform-tools/functions_password_reset.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

208 lines
8.7 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']] ?? ''));
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();
}