- 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>
422 lines
18 KiB
PHP
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;
|
|
}
|