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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user