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:
1 parent
ccc644ac0d
commit
fa87aabb87
9 files changed
+196
-274
No files matched your search
+50
-39
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user