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'],
];
}