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
+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) {
|
||||
|
||||
Reference in new issue
Block a user