'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'; // ─── 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. function auth_schema(?array $map = null): array { static $schema = [ 'table' => 'users', 'id' => 'id', 'email' => 'email', // The column the login form matches on. null => use `email`, which is // what five of the six sites do. quack authenticates by `username` // instead, and still needs `email` to stay the address password // recovery mails to -- so the two cannot be the same key. 'login' => null, 'password' => 'password_hash', 'name' => 'name', 'role' => 'role', 'active' => 'is_active', // null => no active flag on this site 'deleted_at' => null, // set when the site soft-deletes instead 'last_login' => 'last_login_at', 'last_ip' => 'last_login_ip', ]; 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; 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()); } } 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(); $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 { $_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; }