feat(auth): migra login/sessão/roles para o auth partilhado (auditoria 1.3)

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>
This commit is contained in:
andreandClaude Sonnet 5 committed 2026-09-25 17:25:54 +01:00
1 parent ccc644ac0d
commit fa87aabb87
9 files changed
+196 -274

No files matched your search

+50 -39
View File
@@ -1,24 +1,13 @@
<?php
// Auth guards — require functions.php (and session_start) before this file.
// Auth guards — require functions.php (and the shared session, started by
// platform_connect() in config/db.php) before this file.
//
// This install owns its passwords: they live in `users.password_hash` and
// views/auth/login.php is a real form. There is no identity provider in front
// of the app.
function require_login(): array {
if (empty($_SESSION['user_id']) && ($GLOBALS['pdo'] ?? null) instanceof PDO) {
// «Manter sessão iniciada»: antes de mandar para o login, tenta repor
// a sessão a partir do cookie (config/functions_remember.php).
remember_resume($GLOBALS['pdo']);
}
if (empty($_SESSION['user_id'])) {
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
exit;
}
return $_SESSION['user'];
}
// Auditoria 2026-09-24, ponto 1.3: estas funções deixaram de ter lógica
// própria de sessão/roles — delegam em
// shared/platform-tools/functions_auth.php (auth_check/auth_user/
// auth_at_least/auth_is/...), ligado em config/db.php ('auth' => 'shared').
// Os nomes e assinaturas mantêm-se exactamente iguais porque há ~30
// ficheiros em views/ a chamá-las; só o que está por baixo mudou.
// ─── Role ladder ─────────────────────────────────────────────────────────────
// sysadmin — super-admin (backups, platform update, app_base_url).
@@ -29,6 +18,12 @@ function require_login(): array {
// client — portal-only.
// is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff()
// also includes the plain `staff` role.
//
// A hierarquia é agora expressa por posto numérico (auth_roles_register() em
// config/db.php: sysadmin=100, admin=80, staff=40, client=10) em vez dos
// antigos in_array() — auth_at_least('admin') dá exactamente o mesmo
// resultado que in_array($role, ['sysadmin','admin']) dava, porque staff(40)
// e client(10) ficam sempre abaixo de admin(80).
// Resolves the dashboard path for a given role. Used by index.php, the auth
// pages, and require_admin()'s bounce fallback so all four agree on routing.
@@ -41,12 +36,27 @@ function post_login_destination(string $role): string {
};
}
function require_login(): array {
// O «manter sessão iniciada» já corre em config/db.php
// (platform_connect(['remember' => true, ...]) chama auth_remember_resume()
// logo no arranque, antes de qualquer view), por isso não há aqui nada
// equivalente ao antigo remember_resume() manual — se havia um cookie
// válido, $_SESSION['user'] já está preenchido a esta altura.
if (!auth_check()) {
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
exit;
}
return auth_user();
}
// Accepts sysadmin + admin only. Plain staff and clients are bounced to the
// dashboard appropriate for their role (avoids redirect loops when an admin-
// only page bounces a staff user).
function require_admin(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin'])) {
if (!auth_at_least('admin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
@@ -57,7 +67,7 @@ function require_admin(): array {
// Clients are redirected to the public index.
function require_staff(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin', 'staff'])) {
if (!auth_at_least('staff')) {
header('Location: ' . base_url('index.php'));
exit;
}
@@ -70,7 +80,7 @@ function require_staff(): array {
// the role-appropriate sessions list.
function require_session_ownership(array $session): array {
$user = require_staff();
if (in_array($user['role'], ['sysadmin', 'admin'], true)) return $user;
if (auth_at_least('admin')) return $user;
if ((int) ($session['photographer_id'] ?? 0) !== (int) $user['id']) {
header('Location: ' . base_url('views/sessions/index.php'));
exit;
@@ -82,7 +92,7 @@ function require_session_ownership(array $session): array {
// Plain admins are sent back to their dashboard.
function require_sysadmin(): array {
$user = require_login();
if ($user['role'] !== 'sysadmin') {
if (!auth_is('sysadmin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
@@ -90,34 +100,34 @@ function require_sysadmin(): array {
}
function current_user(): ?array {
return $_SESSION['user'] ?? null;
return auth_user();
}
// True for sysadmin + admin (NOT plain staff).
function is_admin(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin']);
return auth_at_least('admin');
}
// True for any backoffice user (sysadmin + admin + staff). Use this to show
// a UI element that any non-client backoffice user should see.
function is_staff(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin', 'staff']);
return auth_at_least('staff');
}
function is_sysadmin(): bool {
return (current_user()['role'] ?? '') === 'sysadmin';
return auth_is('sysadmin');
}
// True for plain admins (not sysadmin, not staff). Useful for "hide from
// sysadmin" edge cases; most code wants is_admin() or is_sysadmin().
function is_admin_role(): bool {
return (current_user()['role'] ?? '') === 'admin';
return auth_is('admin');
}
// True for plain staff only — the assignment-only role. Used to scope queries
// to "photographer_id = me" instead of returning all sessions.
function is_staff_role(): bool {
return (current_user()['role'] ?? '') === 'staff';
return auth_is('staff');
}
// Back-compat shim: every is_designer() caller now means "is plain admin".
@@ -125,21 +135,22 @@ function is_designer(): bool {
return is_admin_role();
}
// Refreshes the session's user fields (name/email/role) without a full
// login — used after a profile edit or an admin-driven role change, where a
// full auth_login() (rehash checks, last_login stamp, audit "auth.login")
// would be the wrong event to log. Still rotates the session id: any
// privilege/credential change should get a fresh one.
function auth_set_session(array $user): void {
// Rotate the session id on every privilege change (login, registration,
// password reset). Defeats session fixation: an attacker who pre-seeded
// FIT_SESS now ends up holding a dead id while the real user gets a fresh
// one. The CSRF token is rotated alongside the session id.
auth_session_regenerate();
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
unset($_SESSION['csrf_token']);
}
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['user'] = [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'id' => (int) $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
];
}
+47 -26
View File
@@ -66,36 +66,57 @@ try {
_bootstrap_fail('PDO connect failed: ' . $e->getMessage());
}
// Distinct session cookie per app so installs that share a host (e.g.
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
// each other's PHPSESSID. Must run before session_start().
//
// Cookie e modo estrito definidos aqui, no código, para valerem em qualquer
// servidor: antes o cookie saía só com path=/ (sem Secure, HttpOnly nem
// SameSite) e o PHP aceitava IDs de sessão inventados pelo cliente.
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
if (request_is_https()) ini_set('session.cookie_secure', '1');
session_name('FIT_SESS');
session_start();
}
// ─── Ligação ao platform-tools ───────────────────────────────────────────────
//
// A app mantém as suas próprias passwords e sessão (`config/auth.php`,
// `views/auth/login.php`) — não passa a usar o functions_auth.php partilhado
// (`'auth' => 'own'`), porque a `auth_attempts` desta app não tem a coluna
// `identifier` que o throttle partilhado exige (tem `ip`+`scope`, do seu
// próprio throttle em functions_throttle.php). Ainda assim liga-se ao
// bloqueio de IP da frota, ao router das páginas de sistema (Backups,
// Actualizações, Auditoria, IP bans, Geo, Origem das visitas, Hub) e ao
// resto do platform-tools, tudo por cima da autenticação própria.
// Auditoria 2026-09-24, ponto 1.3: a app passou a usar a autenticação
// partilhada (`shared/platform-tools/functions_auth.php`) em vez da sua
// própria — login, sessão, roles e guardas (config/auth.php,
// config/functions_user.php) agora chamam auth_attempt()/auth_login()/
// auth_at_least()/etc. por baixo, com os mesmos nomes de função de sempre
// para os ~30 ficheiros que os chamam não mudarem.
//
// O cookie de sessão (nome, Secure/HttpOnly/SameSite, modo estrito) passa a
// ser inteiramente gerido por auth_session_start() através da opção
// 'session' abaixo — o bloco que estava aqui antes (ini_set + session_start
// manuais) foi removido para não haver dois sítios a configurar a mesma
// sessão.
//
// `users` já tem exactamente as colunas por omissão do auth_schema()
// (id, email, name, password_hash, role, is_active, last_login_at,
// last_login_ip — confirmado via information_schema em produção), por isso
// não é preciso mapear nada em 'schema'.
//
// auth_attempts (o throttle da APP, config/functions_throttle.php — ip+scope,
// cobre login/registo/recuperação) continua a ser o único throttle de login
// usado; o throttle do módulo partilhado (auth_throttle_check/hit, que exige
// `identifier`) não é chamado, para não duplicar a defesa do mesmo
// formulário com duas contagens diferentes. A tabela foi tornada conforme na
// mesma (database/migration_20260925_shared_auth_attempts.sql) para o
// relatório de saúde abaixo ('conformance' => 'report') não acusar erro e
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
// views/auth/login.php para a nota completa.
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
platform_connect($pdo, [
'auth' => 'own',
'auth' => 'shared',
'roles' => [
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
// admin > staff > client, por in_array(), não por posto numérico):
// staff acima de client, e os dois abaixo de admin(80), para
// require_staff()/is_staff() continuarem a excluir clientes e
// require_admin()/is_admin() continuarem a excluir staff e clientes.
// sysadmin(100)/admin(80)/user(10) já vêm registados por omissão.
'staff' => 40,
'client' => 10,
],
'session' => [
'name' => 'FIT_SESS',
'samesite' => 'Lax',
// secure/httponly ficam a cargo de auth_session_start(): secure
// segue o mesmo critério de HTTPS que o bloco antigo usava,
// httponly está sempre ligado.
],
'remember' => true, // «Manter sessão iniciada» — agora auth_remember_*
// do platform-tools; ver views/auth/login.php.
'guard' => 'require_sysadmin',
'router' => true,
'conformance' => 'report', // nunca recusa — só reporta no health endpoint
+4 -2
View File
@@ -15,9 +15,11 @@ require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_s
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit()
require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
require_once __DIR__ . '/functions_user.php'; // register/reset/user creators (auth itself: platform-tools)
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
require_once __DIR__ . '/functions_remember.php'; // «Manter sessão iniciada»
// «Manter sessão iniciada»: shared/platform-tools/functions_remember.php,
// ligado por platform_connect(['remember' => true]) em config/db.php — já não
// há um functions_remember.php local (auditoria 1.3).
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
-140
View File
@@ -1,140 +0,0 @@
<?php
// ─── «Manter sessão iniciada» ────────────────────────────────────────────────
// Mesmo desenho do platform-tools functions_remember.php, reimplementado aqui
// porque esta app tem autenticação própria (config/auth.php, não
// functions_auth.php partilhado) e chama auth_set_session() directamente.
//
// Cookie: "<selector>:<validator>". O selector (32 hex) indexa a linha; só o
// SHA-256 do validator (64 hex) fica na BD, por isso um dump da tabela não dá
// para forjar um cookie. Cada utilização gasta o validator e emite outro —
// um cookie roubado só vale até a próxima visita do dono; quando o dono volta
// com um validator que já não bate certo, é a assinatura de uma cópia em
// circulação, e todos os tokens da conta são revogados.
const REMEMBER_COOKIE = 'fit_remember';
const REMEMBER_DAYS = 30;
function remember_set_cookie(?string $value, int $expires = 0): void {
if (headers_sent()) return;
setcookie(REMEMBER_COOKIE, (string) $value, [
'expires' => $expires,
'path' => '/',
'secure' => request_is_https(),
'httponly' => true,
// Lax, não Strict: um link de fora (email) não traz um cookie Strict,
// e a pessoa via o login outra vez apesar de ter pedido para ficar.
'samesite' => 'Lax',
]);
}
function remember_forget_cookie(): void {
remember_set_cookie('', time() - 3600);
}
function remember_parse_cookie(): ?array {
$raw = (string) ($_COOKIE[REMEMBER_COOKIE] ?? '');
if ($raw === '' || substr_count($raw, ':') !== 1) return null;
[$selector, $validator] = explode(':', $raw, 2);
if (strlen($selector) !== 32 || !ctype_xdigit($selector)) return null;
if (strlen($validator) !== 64 || !ctype_xdigit($validator)) return null;
return ['selector' => $selector, 'validator' => $validator];
}
// Chamar depois de um login bem sucedido, só quando a pessoa pediu para ficar.
function remember_issue(PDO $pdo, int $userId): void {
if ($userId <= 0) return;
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$expires = time() + REMEMBER_DAYS * 86400;
try {
$pdo->prepare(
"INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at, last_ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$userId, $selector, hash('sha256', $validator), date('Y-m-d H:i:s', $expires),
client_ip(), mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
} catch (Throwable $e) {
// Um login válido nunca deve falhar por causa disto.
error_log('[remember] issue failed: ' . $e->getMessage());
return;
}
remember_set_cookie($selector . ':' . $validator, $expires);
if (random_int(0, 19) === 0) remember_prune($pdo);
}
// Repõe a sessão a partir do cookie, se houver um válido. Não redirecciona.
function remember_resume(PDO $pdo): void {
if (!empty($_SESSION['user_id'])) return;
$cookie = remember_parse_cookie();
if ($cookie === null) return;
try {
$st = $pdo->prepare("SELECT * FROM remember_tokens WHERE selector = ? LIMIT 1");
$st->execute([$cookie['selector']]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
error_log('[remember] lookup failed: ' . $e->getMessage());
return;
}
if ($row === null) {
remember_forget_cookie();
return;
}
if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) {
error_log('[remember] validator mismatch for user ' . (int) $row['user_id'] . ' — a revogar todos os tokens da conta');
remember_forget_user($pdo, (int) $row['user_id']);
remember_forget_cookie();
return;
}
if (strtotime((string) $row['expires_at']) <= time()) {
remember_delete($pdo, (string) $row['selector']);
remember_forget_cookie();
return;
}
$st = $pdo->prepare("SELECT * FROM users WHERE id = ? AND is_active = 1 LIMIT 1");
$st->execute([(int) $row['user_id']]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) {
remember_forget_user($pdo, (int) $row['user_id']);
remember_forget_cookie();
return;
}
auth_set_session($user);
$_SESSION['_remembered'] = true;
remember_rotate($pdo, (string) $row['selector']);
}
function remember_rotate(PDO $pdo, string $selector): void {
$validator = bin2hex(random_bytes(32));
$expires = time() + REMEMBER_DAYS * 86400;
try {
$pdo->prepare("UPDATE remember_tokens SET validator_hash = ?, expires_at = ?, last_ip = ? WHERE selector = ?")
->execute([hash('sha256', $validator), date('Y-m-d H:i:s', $expires), client_ip(), $selector]);
} catch (Throwable $e) {
error_log('[remember] rotate failed: ' . $e->getMessage());
return;
}
remember_set_cookie($selector . ':' . $validator, $expires);
}
function remember_delete(PDO $pdo, string $selector): void {
try { $pdo->prepare("DELETE FROM remember_tokens WHERE selector = ?")->execute([$selector]); }
catch (Throwable $e) { error_log('[remember] delete failed: ' . $e->getMessage()); }
}
// Chamar no logout e ao mudar a palavra-passe — corta qualquer cookie roubado.
function remember_forget_user(PDO $pdo, int $userId): void {
try { $pdo->prepare("DELETE FROM remember_tokens WHERE user_id = ?")->execute([$userId]); }
catch (Throwable $e) { error_log('[remember] forget_user failed: ' . $e->getMessage()); }
}
function remember_prune(PDO $pdo): void {
try { $pdo->exec("DELETE FROM remember_tokens WHERE expires_at < NOW()"); }
catch (Throwable $e) { /* accessório */ }
}
+33 -48
View File
@@ -1,8 +1,11 @@
<?php
// ─── Users: lookup, authentication, registration, password reset ─────────────
// This install owns its identity. Passwords live in `users.password_hash`;
// views/auth/login.php is a real form and require_login() (config/auth.php)
// bounces unauthenticated visitors there. Nothing is delegated to a hub.
// ─── 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
@@ -26,46 +29,28 @@ function admin_exists(PDO $pdo): bool {
}
// ─── 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.
// Hash bcrypt (custo 12) de uma password aleatória descartada — só serve para
// igualar o tempo de resposta quando a conta não existe.
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2';
// Verifies credentials and stamps the login trail. Returns the user row on
// success, null on bad email / bad password / inactive account / no password
// set. Callers must not leak which of the four it was.
function auth_login(PDO $pdo, string $email, string $password): ?array {
$stmt = $pdo->prepare(
"SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL LIMIT 1"
);
$stmt->execute([strtolower(trim($email))]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// Sem conta, verifica na mesma contra um hash de referência: um bcrypt
// de custo 12 demora dezenas de ms, e responder mais depressa quando o
// email não existe diria a quem tenta quais emails têm conta.
password_verify($password, AUTH_DUMMY_HASH);
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
// Transparently upgrade hashes when PHP's default algorithm/cost moves on.
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
$ip = client_ip();
// 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;
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
->execute([$ip, $ua, $user['id']]);
$user['last_login_at'] = date('Y-m-d H:i:s');
$user['last_login_ip'] = $ip;
$user['last_login_ua'] = $ua;
return $user;
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
@@ -74,10 +59,10 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
// 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([password_hash($password, PASSWORD_DEFAULT), $userId]);
->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.
remember_forget_user($pdo, $userId);
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,
@@ -123,7 +108,7 @@ function auth_register_client(PDO $pdo, array $data): array {
}
$pdo->prepare(
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['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']];
@@ -139,7 +124,7 @@ function auth_register_client(PDO $pdo, array $data): array {
$name,
$email,
$phone,
password_hash($data['password'], PASSWORD_DEFAULT),
auth_password_hash($data['password']),
!empty($data['marketing_consent']) ? 1 : 0,
]);
} catch (PDOException $e) {
@@ -164,7 +149,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
$stmt->execute([
$name,
strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT),
auth_password_hash($password),
]);
$id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
@@ -193,7 +178,7 @@ function _create_backoffice_user(PDO $pdo, string $role, string $name, string $e
$name,
strtolower(trim($email)),
$phone ?: null,
password_hash($password, PASSWORD_DEFAULT),
auth_password_hash($password),
$role,
]);
} catch (PDOException $e) {
@@ -0,0 +1,34 @@
-- ─── auth_attempts: torna-a conforme com o throttle do platform-tools ────────
-- Auditoria 2026-09-24, ponto 1.3: a app passa a usar o auth partilhado
-- (shared/platform-tools/functions_auth.php) em vez do seu próprio login/
-- sessão/roles. Ver config/db.php ('auth' => 'shared').
--
-- Esta tabela já existia (migration_20260913_local_auth.sql) com o formato do
-- throttle PRÓPRIO da app (config/functions_throttle.php: `ip` + `scope`,
-- cobre login/registo/recuperação). O throttle partilhado
-- (auth_throttle_check/hit em functions_auth.php) espera `identifier` + `ip`
-- e falharia (INSERT INTO auth_attempts (identifier, ip) ...) com um erro de
-- SQL — silenciosamente engolido pelo try/catch do throttle partilhado, que
-- por desenho falha aberto e deixaria de bloquear ninguém.
--
-- Decisão: a app CONTINUA a usar só o seu próprio throttle (login+registo+
-- recuperação, dois níveis IP+conta) — não passa a chamar
-- auth_throttle_check()/auth_throttle_hit() em lado nenhum, para não duplicar
-- a defesa contra o mesmo formulário com duas contagens diferentes. Esta
-- migração só torna a TABELA conforme com o que o platform_conformance()
-- (config/db.php, 'conformance' => 'report') verifica, para o relatório de
-- saúde ficar limpo e para deixar a porta aberta a usar o throttle partilhado
-- no futuro sem precisar de outra migração.
--
-- `identifier` e `success` são aditivos, com omissão — invisíveis para o
-- INSERT/SELECT do throttle próprio, que continua a nomear só `ip`+`scope`.
-- `scope` passa a ter omissão (era NOT NULL sem omissão) porque é a única
-- coluna que o INSERT do throttle partilhado não preenche; sem omissão esse
-- INSERT falhava em modo estrito.
--
-- Safe to re-run (IF NOT EXISTS é extensão do MariaDB).
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `identifier` VARCHAR(190) NOT NULL DEFAULT '' AFTER `id`;
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `success` TINYINT(1) NOT NULL DEFAULT 0 AFTER `attempted_at`;
ALTER TABLE `auth_attempts` MODIFY COLUMN `scope` VARCHAR(20) NOT NULL DEFAULT '';
ALTER TABLE `auth_attempts` ADD INDEX IF NOT EXISTS `idx_identifier_time` (`identifier`, `attempted_at`);
+18 -5
View File
@@ -1,6 +1,16 @@
<?php
// Local login form. This install owns its passwords (users.password_hash);
// there is no identity provider in front of it.
// there is no identity provider in front of it. Credential check and session
// establishment go through shared/platform-tools/functions_auth.php
// (auth_attempt()/auth_login()) since auditoria 2026-09-24 ponto 1.3 — see
// config/db.php for the connection and the reasoning on auth_attempts.
//
// The throttle below is still fitme's own (config/functions_throttle.php),
// not the shared auth_throttle_check()/auth_throttle_hit(): it already covers
// login (two tiers: per-IP and per-account) plus register/forgot_password,
// which the shared throttle doesn't touch at all, so switching only the login
// scope over would mean running two different throttles for the same form
// with no reduction in code. Kept as one system.
//
// Carries through ?return_to=/path, set by require_login(), so the visitor
// lands back on the page they originally asked for.
@@ -54,13 +64,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
throttle_record($pdo, 'login');
throttle_record($pdo, 'login_acct', $acct);
$user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
$user = auth_attempt($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
if ($user) {
auth_set_session($user);
// auth_login() (shared) regenerates the session, stamps
// last_login_at/ip and audits "auth.login" itself — no need to also
// call the old auth_set_session()/audit() pair here.
auth_login($pdo, $user);
fit_stamp_login_ua($pdo, (int) $user['id']);
if (!empty($_POST['remember'])) {
remember_issue($pdo, (int) $user['id']);
auth_remember_issue($pdo, (int) $user['id']);
}
audit('auth.login', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) $user['name']]);
header('Location: ' . base_url($destination($user['role'])));
exit;
}
+6 -13
View File
@@ -1,25 +1,18 @@
<?php
// Ends the local session. Nothing to coordinate with an identity provider —
// this install owns its own sessions.
// this install owns its own sessions. auth_logout() (shared) audits
// "auth.logout" (before it wipes $_SESSION), clears the session and its
// cookie — same steps this file used to do by hand.
require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php';
if (!empty($_SESSION['user_id'])) {
audit('auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user_id']]);
if (auth_check()) {
// Só o token deste browser, não toda a conta: sair aqui não deve expulsar
// a pessoa de outro dispositivo onde pediu para ficar.
$cookie = remember_parse_cookie();
if ($cookie !== null) remember_delete($pdo, $cookie['selector']);
auth_remember_clear($pdo);
}
remember_forget_cookie();
$_SESSION = [];
// Drop the cookie too, so the browser doesn't keep presenting a dead id.
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
auth_logout();
header('Location: ' . base_url('views/auth/login.php?loggedout=1'));
exit;
+4 -1
View File
@@ -51,7 +51,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// login form with credentials they just typed.
$user = db_get_user($pdo, (int) $res['id']);
if ($user) {
auth_set_session($user);
// auth_login() (shared) — same primitive views/auth/login.php
// uses, so a fresh registration and a normal login leave the
// session/audit trail in the same shape.
auth_login($pdo, $user);
header('Location: ' . base_url(post_login_destination($user['role'])));
exit;
}