Files
fitme3070.pt/shared/platform-tools/functions_auth.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

422 lines
18 KiB
PHP

<?php
// ─── Shared authentication: sessions, passwords, roles, guards ───────────────
//
// One implementation for every platform. Each site had grown its own: dance had
// no session hardening at all and no login throttle, hub2 and fotografia each
// solved both separately, and silver used camelCase guards returning void while
// the others returned the user array. The mechanics below have no business
// logic in them, so there is no reason for four versions.
//
// What stays per-site: the users table itself. The columns genuinely differ
// (fotografia carries CalDAV credentials, silver guardian_id), so this file
// adapts to the local schema through auth_schema() instead of demanding one
// shape. Roles are the same idea -- a common core plus whatever the app needs.
//
// Typical wiring, in the site's bootstrap, after the PDO is open:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
// auth_schema(['password' => 'password_hash', 'name' => 'name']);
// auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
// auth_session_start(['name' => 'dsa_session']);
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
require_once __DIR__ . '/functions_connector.php';
// ─── Schema map ─────────────────────────────────────────────────────────────
//
// Column names diverged before this file existed -- silver stores the hash in
// `password` and the name in `full_name`, everyone else uses `password_hash`
// and `name`. Renaming columns across four live sites is a migration; adapting
// is a function call. Sites converging on the standard names can simply not
// call auth_schema() at all.
// The defaults live in functions_connector.php, which the conformance check
// reads too — one list of standard names, not two that drift apart.
function auth_schema(?array $map = null): array {
static $schema = null;
$schema ??= platform_auth_schema_defaults();
if ($map !== null) $schema = array_merge($schema, $map);
return $schema;
}
/** Builds the "is this account usable" SQL fragment for the local schema. */
function auth_active_sql(string $alias = ''): string {
$s = auth_schema();
$p = $alias !== '' ? "`{$alias}`." : '';
$out = [];
if ($s['active'] !== null) $out[] = "{$p}`{$s['active']}` = 1";
if ($s['deleted_at'] !== null) $out[] = "{$p}`{$s['deleted_at']}` IS NULL";
return $out ? implode(' AND ', $out) : '1=1';
}
// ─── Roles ──────────────────────────────────────────────────────────────────
//
// Three roles are common to every platform and mean the same thing everywhere.
// Everything else is the app's own vocabulary -- dance has teacher/student/
// parent, fotografia staff/client, silver student -- so a site registers its
// own and they sit in the same ranking as the core ones.
//
// The rank is what makes "at least an admin" expressible without hardcoding a
// list. Higher is more privileged; equal ranks are peers, not substitutes.
const AUTH_ROLE_SYSADMIN = 'sysadmin';
const AUTH_ROLE_ADMIN = 'admin';
const AUTH_ROLE_USER = 'user';
function auth_roles_register(?array $roles = null): array {
static $registry = [
AUTH_ROLE_SYSADMIN => 100,
AUTH_ROLE_ADMIN => 80,
AUTH_ROLE_USER => 10,
];
if ($roles !== null) {
foreach ($roles as $name => $rank) {
$registry[(string) $name] = (int) $rank;
}
arsort($registry);
}
return $registry;
}
function auth_roles_all(): array { return array_keys(auth_roles_register()); }
function auth_role_exists(string $role): bool { return isset(auth_roles_register()[$role]); }
/** Unknown roles rank 0 -- below everything, so a typo cannot grant access. */
function auth_role_rank(string $role): int {
return auth_roles_register()[$role] ?? 0;
}
// ─── Session ────────────────────────────────────────────────────────────────
/**
* Start a hardened session. Safe to call more than once.
*
* dance called a bare session_start() with no cookie parameters at all -- no
* httponly, no samesite, no strict mode -- which is exactly the kind of gap
* that appears when four sites each solve the same problem separately.
*/
function auth_session_start(array $opts = []): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
// Strict mode makes PHP refuse a session id it did not issue, which is what
// closes session fixation. It has to be set before the session starts.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_set_cookie_params([
'lifetime' => (int) ($opts['lifetime'] ?? 0),
'path' => (string) ($opts['path'] ?? '/'),
'domain' => (string) ($opts['domain'] ?? ''),
'secure' => (bool) ($opts['secure'] ?? $https),
'httponly' => true,
'samesite' => (string) ($opts['samesite'] ?? 'Lax'),
]);
if (!empty($opts['name'])) session_name((string) $opts['name']);
session_start();
$idle = (int) ($opts['idle_seconds'] ?? 0);
if ($idle > 0) {
$last = (int) ($_SESSION['_auth_seen'] ?? 0);
if ($last > 0 && (time() - $last) > $idle) {
auth_logout(false);
session_start();
}
$_SESSION['_auth_seen'] = time();
}
}
/** Call on every privilege change -- login, logout, role change. */
function auth_session_regenerate(): void {
if (session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
}
// ─── Passwords ──────────────────────────────────────────────────────────────
//
// PASSWORD_DEFAULT, deliberately, so the estate follows PHP forward. dance
// pinned PASSWORD_BCRYPT, which means the day DEFAULT moves on, three sites
// migrate and one silently does not.
function auth_password_hash(string $plain): string {
return password_hash($plain, PASSWORD_DEFAULT);
}
/**
* Verify a password and transparently upgrade the stored hash.
*
* password_needs_rehash() appeared exactly twice across the whole estate, so
* hashes never actually improved. Login is the only moment the plaintext is
* available, so it is the only moment a rehash can happen.
*/
function auth_password_verify(PDO $pdo, array $user, string $plain): bool {
$s = auth_schema();
$hash = (string) ($user[$s['password']] ?? '');
if ($hash === '' || !password_verify($plain, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
try {
$pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?")
->execute([auth_password_hash($plain), $user[$s['id']]]);
} catch (Throwable $e) {
error_log('[auth] rehash failed: ' . $e->getMessage()); // never block a valid login
}
}
return true;
}
// ─── Login throttle ─────────────────────────────────────────────────────────
function auth_throttle_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `auth_attempts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(190) NOT NULL,
`ip` VARCHAR(45) NULL,
`attempted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`success` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_identifier_time` (`identifier`, `attempted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* Does this install's auth_attempts carry a `success` column?
*
* silver and fotografia already had the table, and theirs logs successful
* logins alongside failed ones. Counting every row in the window would then
* throttle a user for signing in successfully, so the count has to exclude
* them. Detected once rather than assumed, because hub2 has no such table and
* a fresh one created here starts out with the column too.
*/
function auth_throttle_has_success_column(PDO $pdo): bool {
static $has = null;
if ($has !== null) return $has;
try {
$st = $pdo->query("SHOW COLUMNS FROM `auth_attempts` LIKE 'success'");
$has = $st->fetch(PDO::FETCH_ASSOC) !== false;
} catch (Throwable $e) {
$has = false;
}
return $has;
}
/**
* Returns ['blocked'=>bool, 'remaining'=>int, 'retry_after'=>int].
*
* Throttles on identifier+IP together: on the identifier alone an attacker can
* lock a known user out of their own account, and on IP alone a NAT'd office
* locks itself out.
*/
function auth_throttle_check(PDO $pdo, string $identifier, int $max = 5, int $windowSeconds = 900): array {
auth_throttle_ensure_table($pdo);
$ip = auth_client_ip();
try {
$onlyFailures = auth_throttle_has_success_column($pdo) ? ' AND success = 0' : '';
$st = $pdo->prepare(
"SELECT COUNT(*) c, COALESCE(MAX(UNIX_TIMESTAMP(attempted_at)), 0) last
FROM auth_attempts
WHERE identifier = ? AND (ip = ? OR ? IS NULL)
AND attempted_at > (NOW() - INTERVAL ? SECOND)" . $onlyFailures
);
$st->execute([mb_strtolower($identifier), $ip, $ip, $windowSeconds]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: ['c' => 0, 'last' => 0];
} catch (Throwable $e) {
return ['blocked' => false, 'remaining' => $max, 'retry_after' => 0]; // never lock everyone out on a DB hiccup
}
$count = (int) $row['c'];
$blocked = $count >= $max;
if ($blocked) {
platform_security_event($pdo, 'login_throttled', hub_security_mask_identifier($identifier), $ip);
}
return [
'blocked' => $blocked,
'remaining' => max(0, $max - $count),
'retry_after' => $blocked ? max(0, ((int) $row['last'] + $windowSeconds) - time()) : 0,
];
}
function auth_throttle_hit(PDO $pdo, string $identifier): void {
auth_throttle_ensure_table($pdo);
try {
// ip is NOT NULL on the tables silver and fotografia already had, so a
// null from an odd SAPI would abort the insert and silently disable the
// throttle. Empty string records "unknown" without failing.
$pdo->prepare("INSERT INTO auth_attempts (identifier, ip) VALUES (?, ?)")
->execute([mb_strtolower($identifier), auth_client_ip() ?? '']);
} catch (Throwable $e) {
error_log('[auth] throttle record failed: ' . $e->getMessage());
}
platform_security_event($pdo, 'login_failed', hub_security_mask_identifier($identifier));
}
function auth_throttle_clear(PDO $pdo, string $identifier): void {
try {
$pdo->prepare("DELETE FROM auth_attempts WHERE identifier = ?")
->execute([mb_strtolower($identifier)]);
} catch (Throwable $e) { /* nothing worth failing a login over */ }
}
/**
* Client IP, honouring X-Forwarded-For only when TRUSTED_PROXY says to.
*
* Every site sits behind NPM, so REMOTE_ADDR is always the proxy. Trusting the
* header unconditionally would let a caller forge their own throttle identity.
*/
function auth_client_ip(): ?string {
// A regra mudou-se para functions_client_ip.php, para o antispam dos sites
// que declaram o seu próprio auth_login() lhe poder chegar sem incluir este
// ficheiro. Aqui fica só o nome que o resto do módulo já chama.
return platform_client_ip();
}
// ─── Login / logout ─────────────────────────────────────────────────────────
/**
* Look up an active account by its login identifier and verify the password.
*
* The identifier is the `email` column unless the site overrode `login` in
* auth_schema() -- quack signs in with `username`.
*
* Returns the user row, or null. Deliberately does not distinguish "no such
* account" from "wrong password" to the caller, so a login form cannot be used
* to enumerate which identifiers have accounts.
*/
function auth_attempt(PDO $pdo, string $identifier, string $password): ?array {
$s = auth_schema();
$col = $s['login'] ?: $s['email'];
$st = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$col}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$st->execute([mb_strtolower(trim($identifier))]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) {
// Spend roughly the time a real verify would, so response timing does
// not reveal whether the address exists.
password_verify($password, '$2y$12$usesomesillystringfoeXiOjbGRRjfxSTvlOKz.OKB0PfSUcnu');
return null;
}
return auth_password_verify($pdo, $user, $password) ? $user : null;
}
/** Establishes the session for a verified user. */
function auth_login(PDO $pdo, array $user): void {
$s = auth_schema();
auth_session_regenerate();
$_SESSION['user_id'] = (int) $user[$s['id']];
$_SESSION['user'] = [
'id' => (int) $user[$s['id']],
'email' => (string) ($user[$s['email']] ?? ''),
'name' => (string) ($user[$s['name']] ?? ''),
'role' => (string) ($user[$s['role']] ?? AUTH_ROLE_USER),
];
$_SESSION['_auth_seen'] = time();
if (function_exists('platform_audit')) {
platform_audit($pdo, 'auth.login', ['target_type' => 'user', 'target_id' => (int) $user[$s['id']],
'target_label' => (string) ($user[$s['email']] ?? '')]);
}
$sets = []; $params = [];
if ($s['last_login']) { $sets[] = "`{$s['last_login']}` = NOW()"; }
if ($s['last_ip']) { $sets[] = "`{$s['last_ip']}` = ?"; $params[] = auth_client_ip(); }
if ($sets) {
$params[] = $user[$s['id']];
try {
$pdo->prepare("UPDATE `{$s['table']}` SET " . implode(', ', $sets) . " WHERE `{$s['id']}` = ?")
->execute($params);
} catch (Throwable $e) {
error_log('[auth] last-login stamp failed: ' . $e->getMessage());
}
}
}
function auth_logout(bool $destroy = true): void {
// Antes de limpar a sessão: depois já não se sabe quem saiu.
if (function_exists('platform_audit') && !empty($_SESSION['user']['id'])) {
platform_audit(null, 'auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user']['id']]);
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
if ($destroy && session_status() === PHP_SESSION_ACTIVE) session_destroy();
}
// ─── Guards ─────────────────────────────────────────────────────────────────
function auth_user(): ?array { return $_SESSION['user'] ?? null; }
function auth_id(): int { return (int) ($_SESSION['user']['id'] ?? 0); }
function auth_check(): bool { return auth_id() > 0; }
function auth_role(): string { return (string) ($_SESSION['user']['role'] ?? ''); }
/** Exact role match. Use auth_at_least() for "this role or better". */
function auth_is(string $role): bool { return auth_role() === $role; }
/** True when the current role ranks at or above $role. */
function auth_at_least(string $role): bool {
return auth_check() && auth_role_rank(auth_role()) >= auth_role_rank($role);
}
function auth_is_admin(): bool { return auth_at_least(AUTH_ROLE_ADMIN); }
function auth_is_sysadmin(): bool { return auth_is(AUTH_ROLE_SYSADMIN); }
/** Any one of the listed roles, exactly. For app roles that are peers. */
function auth_is_any(array $roles): bool {
return auth_check() && in_array(auth_role(), $roles, true);
}
/**
* Guards redirect rather than returning, and take the destination from the
* caller because every site names its login page differently.
*/
function auth_require_login(string $loginUrl): array {
if (!auth_check()) {
$_SESSION['auth_intended'] = $_SERVER['REQUEST_URI'] ?? null;
header('Location: ' . $loginUrl);
exit;
}
return auth_user();
}
function auth_require_at_least(string $role, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_at_least($role)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
function auth_require_any(array $roles, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_is_any($roles)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
/**
* Where to send the user after login: back where they were headed, if that
* destination is local. An open redirect here would turn the login page into a
* phishing hop, so anything absolute is discarded.
*/
function auth_intended_url(string $fallback): string {
$raw = $_SESSION['auth_intended'] ?? null;
unset($_SESSION['auth_intended']);
if (!is_string($raw) || $raw === '') return $fallback;
if (str_starts_with($raw, '//') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $raw)) return $fallback;
return str_starts_with($raw, '/') ? $raw : $fallback;
}