config/db.php liga-se com 'auth' => 'shared' em vez de 'own': login, sessão, papéis e guardas passam a correr sobre shared/platform-tools/functions_auth.php (auth_attempt/auth_login/ auth_at_least/auth_is/auth_session_start/auth_remember_*) em vez da lógica local duplicada. Os ~30 ficheiros em views/ que chamam require_login()/require_admin()/require_staff()/require_sysadmin()/ is_admin()/etc. não mudam — só o que está por baixo dessas funções. - roles: staff=40, client=10 registados para preservar a hierarquia sysadmin>admin>staff>client que os antigos in_array() garantiam. `users` já tinha exactamente as colunas por omissão do auth_schema(), não foi preciso mapear nada. - sessão: o bloco manual de ini_set()/session_start() em config/db.php foi substituído pela opção 'session' do platform_connect() — mesmo nome de cookie (FIT_SESS), mesmo SameSite=Lax, secure/httponly passam a ser geridos por auth_session_start(). - «Manter sessão iniciada»: config/functions_remember.php (a reimplementação local do selector/validator) foi eliminado a favor do módulo partilhado (auth_remember_issue/resume/clear via 'remember' => true), que já cobria o mesmo desenho. - auth_attempts: tinha (ip, scope) do throttle próprio da app (config/functions_throttle.php), não o `identifier` que o throttle partilhado exige. Migração aditiva (migration_20260925_shared_auth_attempts.sql) torna a tabela conforme sem tocar no throttle da app, que continua a ser o único usado para login/registo/recuperação — o throttle partilhado não é chamado, para não duplicar a mesma defesa com duas contagens. - last_login_ua (sem equivalente partilhado) passa a ser gravado por fit_stamp_login_ua(), chamado a seguir a auth_login(). Testado ponta-a-ponta num contentor scratch na VPS (site-fitme:8.4, cópia da BD de produção + migração aplicada): visitante anónimo é redirigido, password errada dá o mesmo erro genérico e conta para o throttle (bloqueia à 5ª tentativa, 429), login correcto regenera a sessão, grava last_login_at/ip/ua, audita "auth.login" e (com "lembrar-me") emite o cookie am_remember; esse cookie sozinho, numa sessão nova, repõe a sessão certa com rotação do validator; logout limpa sessão+cookie+token e audita "auth.logout"; platform.php (guard=require_sysadmin) e views/admin/dashboard.php respondem 302 sem sessão e 200 com sessão de sysadmin. php -l limpo em todos os ficheiros alterados. Migração já aplicada em produção (aditiva). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
286 lines
14 KiB
PHP
286 lines
14 KiB
PHP
<?php
|
|
// ─── Users: lookup, registration, password reset ─────────────────────────────
|
|
// This install owns its identity — passwords live in `users.password_hash` —
|
|
// but auditoria 2026-09-24 ponto 1.3 moved credential verification and
|
|
// session establishment to shared/platform-tools/functions_auth.php
|
|
// (auth_attempt()/auth_login(), wired in config/db.php). What is left here is
|
|
// fitme-specific: registration, backoffice user creation, password reset
|
|
// tokens and their emails — none of which has a shared equivalent.
|
|
//
|
|
// parse_user_agent() lives in config/functions.php; db_get_user() /
|
|
// db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
|
|
// — not redefined here.
|
|
|
|
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
|
|
|
// Active user by email, or false. Used by the login and reset-request flows,
|
|
// which must never act on a deactivated account.
|
|
function user_find_by_email(PDO $pdo, string $email): array|false {
|
|
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND is_active = 1 LIMIT 1");
|
|
$stmt->execute([strtolower(trim($email))]);
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
// True once any sysadmin/admin row exists — gates views/auth/setup.php.
|
|
function admin_exists(PDO $pdo): bool {
|
|
return (bool) $pdo->query(
|
|
"SELECT 1 FROM users WHERE role IN ('sysadmin','admin') LIMIT 1"
|
|
)->fetchColumn();
|
|
}
|
|
|
|
// ─── Authentication ──────────────────────────────────────────────────────────
|
|
//
|
|
// Credential verification is now auth_attempt($pdo, $email, $password) from
|
|
// shared/platform-tools/functions_auth.php — same account-enumeration timing
|
|
// defence and transparent rehash, one implementation instead of two. Login
|
|
// itself (session + audit "auth.login" + last_login_at/ip stamp) is
|
|
// auth_login($pdo, $user) from the same file. See views/auth/login.php.
|
|
//
|
|
// last_login_ua has no shared equivalent (functions_auth.php only tracks
|
|
// last_login/last_ip) and is still fitme's own — stamped separately, right
|
|
// after auth_login(), by fit_stamp_login_ua() below.
|
|
|
|
// Stamps the user-agent of the browser that just logged in. Best-effort:
|
|
// shown in views/user/index.php ("last seen from …") but never worth failing
|
|
// a login over.
|
|
function fit_stamp_login_ua(PDO $pdo, int $userId): void {
|
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
|
try {
|
|
$pdo->prepare("UPDATE users SET last_login_ua = ? WHERE id = ?")
|
|
->execute([$ua, $userId]);
|
|
} catch (Throwable $e) {
|
|
error_log('[auth] last_login_ua stamp failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Writes a new password hash. Shared by the reset flow, the profile page and
|
|
// the admin override in views/user/set_password.php. Any outstanding reset
|
|
// token is burned so an old "set your password" email can't be replayed to
|
|
// take the account back.
|
|
function auth_set_password(PDO $pdo, int $userId, string $password): void {
|
|
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
|
->execute([auth_password_hash($password), $userId]);
|
|
// Uma password nova invalida qualquer «manter sessão iniciada» antigo —
|
|
// sem isto, um cookie roubado antes da mudança continuava a entrar depois.
|
|
auth_remember_forget_user($pdo, $userId);
|
|
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
|
|
audit($self ? 'auth.password_change' : 'user.password_set', [
|
|
'target_type' => 'user', 'target_id' => $userId,
|
|
'summary' => $self ? 'O próprio alterou a palavra-passe' : 'Palavra-passe definida por outra pessoa ou por link de recuperação',
|
|
]);
|
|
|
|
// Best-effort: the password itself is already saved, so a missing
|
|
// `password_resets` table (migration not applied yet) must not turn a
|
|
// successful change into a 500 that makes the caller think it failed.
|
|
try {
|
|
$pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL")
|
|
->execute([$userId]);
|
|
} catch (PDOException $e) {
|
|
error_log('[auth] could not burn reset tokens (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// ─── Registration ────────────────────────────────────────────────────────────
|
|
|
|
// Self-registration for clients (views/auth/register.php).
|
|
//
|
|
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
|
//
|
|
// A row with no password — left behind by an admin who created the account, or
|
|
// by the SSO era, when client rows were provisioned from the hub with
|
|
// password_hash NULL — is *claimable* by whoever registers with that address,
|
|
// but only when it is a plain client. A passwordless admin/staff/sysadmin row
|
|
// must not be claimable through the public form: that would let anyone who
|
|
// knows a staff email self-assign the role. Those report as taken and have to
|
|
// be set up from the backoffice.
|
|
function auth_register_client(PDO $pdo, array $data): array {
|
|
$email = strtolower(trim($data['email']));
|
|
$name = trim($data['name']);
|
|
$phone = trim($data['phone'] ?? '') ?: null;
|
|
|
|
$stmt = $pdo->prepare("SELECT id, role, password_hash FROM users WHERE email = ? LIMIT 1");
|
|
$stmt->execute([$email]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($row) {
|
|
if (!empty($row['password_hash']) || $row['role'] !== 'client') {
|
|
return ['error' => 'email_taken'];
|
|
}
|
|
$pdo->prepare(
|
|
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
|
|
)->execute([$name, $phone, auth_password_hash($data['password']), $row['id']]);
|
|
audit('user.register', ['target_type' => 'user', 'target_id' => (int) $row['id'], 'target_label' => $name,
|
|
'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]);
|
|
return ['id' => (int) $row['id']];
|
|
}
|
|
|
|
// UNIQUE(email) is the real guard — the SELECT above can lose a race with a
|
|
// concurrent signup, so the insert is what decides.
|
|
try {
|
|
$pdo->prepare("
|
|
INSERT INTO users (name, email, phone, password_hash, role, marketing_consent, is_active)
|
|
VALUES (?, ?, ?, ?, 'client', ?, 1)
|
|
")->execute([
|
|
$name,
|
|
$email,
|
|
$phone,
|
|
auth_password_hash($data['password']),
|
|
!empty($data['marketing_consent']) ? 1 : 0,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
|
throw $e;
|
|
}
|
|
$newId = (int) $pdo->lastInsertId();
|
|
audit('user.register', ['target_type' => 'user', 'target_id' => $newId, 'target_label' => $name,
|
|
'summary' => 'Novo cliente registado no site', 'actor' => null]);
|
|
return ['id' => $newId];
|
|
}
|
|
|
|
// ─── Backoffice user creation ────────────────────────────────────────────────
|
|
|
|
// Creates a sysadmin (full access incl. backups + platform update). Used once,
|
|
// by views/auth/setup.php, on a fresh install.
|
|
function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
|
|
$stmt = $pdo->prepare("
|
|
INSERT INTO users (name, email, password_hash, role)
|
|
VALUES (?, ?, ?, 'sysadmin')
|
|
");
|
|
$stmt->execute([
|
|
$name,
|
|
strtolower(trim($email)),
|
|
auth_password_hash($password),
|
|
]);
|
|
$id = (int) $pdo->lastInsertId();
|
|
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
|
|
'summary' => 'Sysadmin criado pelo setup inicial', 'actor' => null]);
|
|
return $id;
|
|
}
|
|
|
|
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
|
function auth_create_admin(PDO $pdo, string $name, string $email, string $phone, string $password): array {
|
|
return _create_backoffice_user($pdo, 'admin', $name, $email, $phone, $password);
|
|
}
|
|
|
|
function auth_create_staff(PDO $pdo, string $name, string $email, string $phone, string $password): array {
|
|
return _create_backoffice_user($pdo, 'staff', $name, $email, $phone, $password);
|
|
}
|
|
|
|
function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
|
|
if (!in_array($role, ['admin', 'staff'], true)) {
|
|
throw new InvalidArgumentException("Invalid backoffice role: {$role}");
|
|
}
|
|
try {
|
|
$pdo->prepare("
|
|
INSERT INTO users (name, email, phone, password_hash, role)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
")->execute([
|
|
$name,
|
|
strtolower(trim($email)),
|
|
$phone ?: null,
|
|
auth_password_hash($password),
|
|
$role,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
|
throw $e;
|
|
}
|
|
$id = (int) $pdo->lastInsertId();
|
|
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
|
|
'changes' => ['role' => ['antes' => null, 'depois' => $role]]]);
|
|
return ['id' => $id];
|
|
}
|
|
|
|
// ─── Password reset ──────────────────────────────────────────────────────────
|
|
// Single-use tokens in `password_resets`, valid for 2 hours. Minting a new one
|
|
// invalidates any outstanding token for that user.
|
|
|
|
function auth_create_reset_token(PDO $pdo, int $userId): string {
|
|
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL")
|
|
->execute([$userId]);
|
|
$token = bin2hex(random_bytes(32));
|
|
$pdo->prepare(
|
|
"INSERT INTO password_resets (user_id, token, expires_at)
|
|
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))"
|
|
)->execute([$userId, $token]);
|
|
return $token;
|
|
}
|
|
|
|
function auth_get_valid_reset_token(PDO $pdo, string $token): array|false {
|
|
$stmt = $pdo->prepare("
|
|
SELECT pr.user_id, pr.token, u.name, u.email, u.role
|
|
FROM password_resets pr
|
|
JOIN users u ON u.id = pr.user_id
|
|
WHERE pr.token = ?
|
|
AND pr.used_at IS NULL
|
|
AND pr.expires_at > NOW()
|
|
AND u.is_active = 1
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([$token]);
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword): bool {
|
|
$reset = auth_get_valid_reset_token($pdo, $token);
|
|
if (!$reset) return false;
|
|
// auth_set_password() burns every outstanding token for this user, this one
|
|
// included, so the link can't be replayed.
|
|
auth_set_password($pdo, (int) $reset['user_id'], $newPassword);
|
|
return true;
|
|
}
|
|
|
|
// ─── Email: password reset / first-password invitation ───────────────────────
|
|
// Delivery is centralized at the hub's /notify endpoint — see
|
|
// config/functions_queue.php (enqueue_email + flush_message).
|
|
|
|
// $flush=false deixa a mensagem na fila para o cron (≤5 min). O pedido de
|
|
// recuperação usa-o: um envio SMTP síncrono só quando a conta existe fazia a
|
|
// resposta demorar segundos apenas nesse caso — um oráculo de contas.
|
|
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null, bool $flush = true): bool {
|
|
$body = load_email_template('password_reset', [
|
|
'user_name' => $name,
|
|
'reset_url' => $resetUrl,
|
|
'app_name' => APP_NAME,
|
|
]);
|
|
$id = enqueue_email([
|
|
'to_user_id' => $userId,
|
|
'to_address' => $email,
|
|
'to_name' => $name,
|
|
'subject' => 'Redefinir palavra-passe',
|
|
'body' => $body,
|
|
'alt_body' => "Olá {$name},\n\nPara definir uma nova palavra-passe aceda a:\n{$resetUrl}\n\nO link é válido durante 2 horas.",
|
|
'context_type' => 'password_reset',
|
|
'context_id' => $userId,
|
|
]);
|
|
return $flush ? flush_message($id) : $id > 0;
|
|
}
|
|
|
|
// An account created from the backoffice has no password, so the person has no
|
|
// way of knowing it exists. Invites them to set one, reusing the reset token —
|
|
// same mechanism, different copy.
|
|
function send_password_invite_email(PDO $pdo, array $user): bool {
|
|
if (empty($user['email'])) return false;
|
|
audit('user.invite', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) ($user['name'] ?? '')]);
|
|
$token = auth_create_reset_token($pdo, (int) $user['id']);
|
|
$url = base_url('views/auth/reset_password.php?token=' . $token);
|
|
$name = (string) ($user['name'] ?? '');
|
|
|
|
$body = load_email_template('password_invite', [
|
|
'user_name' => $name,
|
|
'reset_url' => $url,
|
|
'app_name' => APP_NAME,
|
|
]);
|
|
$id = enqueue_email([
|
|
'to_user_id' => (int) $user['id'],
|
|
'to_address' => $user['email'],
|
|
'to_name' => $name,
|
|
'subject' => 'A sua conta ' . APP_NAME,
|
|
'body' => $body,
|
|
'alt_body' => "Olá {$name},\n\nFoi criada uma conta para si em " . APP_NAME . ".\nDefina a sua palavra-passe em:\n{$url}\n\nO link é válido durante 2 horas.",
|
|
'context_type' => 'password_invite',
|
|
'context_id' => (int) $user['id'],
|
|
]);
|
|
return flush_message($id);
|
|
}
|