Files
fitme3070.pt/config/functions_user.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

298 lines
14 KiB
PHP

<?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.
//
// 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
// — not redefined here.
// ─── Lookup ──────────────────────────────────────────────────────────────────
// Active user by email, or false. Used by the login and reset-request flows,
// which must never act on a deactivated account.
function user_find_by_email(PDO $pdo, string $email): array|false {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND is_active = 1 LIMIT 1");
$stmt->execute([strtolower(trim($email))]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
// True once any sysadmin/admin row exists — gates views/auth/setup.php.
function admin_exists(PDO $pdo): bool {
return (bool) $pdo->query(
"SELECT 1 FROM users WHERE role IN ('sysadmin','admin') LIMIT 1"
)->fetchColumn();
}
// ─── Authentication ──────────────────────────────────────────────────────────
// 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();
$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;
}
// Writes a new password hash. Shared by the reset flow, the profile page and
// the admin override in views/user/set_password.php. Any outstanding reset
// token is burned so an old "set your password" email can't be replayed to
// 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]);
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
audit($self ? 'auth.password_change' : 'user.password_set', [
'target_type' => 'user', 'target_id' => $userId,
'summary' => $self ? 'O próprio alterou a palavra-passe' : 'Palavra-passe definida por outra pessoa ou por link de recuperação',
]);
// Best-effort: the password itself is already saved, so a missing
// `password_resets` table (migration not applied yet) must not turn a
// successful change into a 500 that makes the caller think it failed.
try {
$pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL")
->execute([$userId]);
} catch (PDOException $e) {
error_log('[auth] could not burn reset tokens (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
}
}
// ─── Registration ────────────────────────────────────────────────────────────
// Self-registration for clients (views/auth/register.php).
//
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
//
// A row with no password — left behind by an admin who created the account, or
// by the SSO era, when client rows were provisioned from the hub with
// password_hash NULL — is *claimable* by whoever registers with that address,
// but only when it is a plain client. A passwordless admin/staff/sysadmin row
// must not be claimable through the public form: that would let anyone who
// knows a staff email self-assign the role. Those report as taken and have to
// be set up from the backoffice.
function auth_register_client(PDO $pdo, array $data): array {
$email = strtolower(trim($data['email']));
$name = trim($data['name']);
$phone = trim($data['phone'] ?? '') ?: null;
$stmt = $pdo->prepare("SELECT id, role, password_hash FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
if (!empty($row['password_hash']) || $row['role'] !== 'client') {
return ['error' => 'email_taken'];
}
$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']]);
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']];
}
// UNIQUE(email) is the real guard — the SELECT above can lose a race with a
// concurrent signup, so the insert is what decides.
try {
$pdo->prepare("
INSERT INTO users (name, email, phone, password_hash, role, marketing_consent, is_active)
VALUES (?, ?, ?, ?, 'client', ?, 1)
")->execute([
$name,
$email,
$phone,
password_hash($data['password'], PASSWORD_DEFAULT),
!empty($data['marketing_consent']) ? 1 : 0,
]);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
throw $e;
}
$newId = (int) $pdo->lastInsertId();
audit('user.register', ['target_type' => 'user', 'target_id' => $newId, 'target_label' => $name,
'summary' => 'Novo cliente registado no site', 'actor' => null]);
return ['id' => $newId];
}
// ─── Backoffice user creation ────────────────────────────────────────────────
// Creates a sysadmin (full access incl. backups + platform update). Used once,
// by views/auth/setup.php, on a fresh install.
function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
$stmt = $pdo->prepare("
INSERT INTO users (name, email, password_hash, role)
VALUES (?, ?, ?, 'sysadmin')
");
$stmt->execute([
$name,
strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT),
]);
$id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
'summary' => 'Sysadmin criado pelo setup inicial', 'actor' => null]);
return $id;
}
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
function auth_create_admin(PDO $pdo, string $name, string $email, string $phone, string $password): array {
return _create_backoffice_user($pdo, 'admin', $name, $email, $phone, $password);
}
function auth_create_staff(PDO $pdo, string $name, string $email, string $phone, string $password): array {
return _create_backoffice_user($pdo, 'staff', $name, $email, $phone, $password);
}
function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
if (!in_array($role, ['admin', 'staff'], true)) {
throw new InvalidArgumentException("Invalid backoffice role: {$role}");
}
try {
$pdo->prepare("
INSERT INTO users (name, email, phone, password_hash, role)
VALUES (?, ?, ?, ?, ?)
")->execute([
$name,
strtolower(trim($email)),
$phone ?: null,
password_hash($password, PASSWORD_DEFAULT),
$role,
]);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
throw $e;
}
$id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
'changes' => ['role' => ['antes' => null, 'depois' => $role]]]);
return ['id' => $id];
}
// ─── Password reset ──────────────────────────────────────────────────────────
// Single-use tokens in `password_resets`, valid for 2 hours. Minting a new one
// invalidates any outstanding token for that user.
function auth_create_reset_token(PDO $pdo, int $userId): string {
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL")
->execute([$userId]);
$token = bin2hex(random_bytes(32));
$pdo->prepare(
"INSERT INTO password_resets (user_id, token, expires_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))"
)->execute([$userId, $token]);
return $token;
}
function auth_get_valid_reset_token(PDO $pdo, string $token): array|false {
$stmt = $pdo->prepare("
SELECT pr.user_id, pr.token, u.name, u.email, u.role
FROM password_resets pr
JOIN users u ON u.id = pr.user_id
WHERE pr.token = ?
AND pr.used_at IS NULL
AND pr.expires_at > NOW()
AND u.is_active = 1
LIMIT 1
");
$stmt->execute([$token]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword): bool {
$reset = auth_get_valid_reset_token($pdo, $token);
if (!$reset) return false;
// auth_set_password() burns every outstanding token for this user, this one
// included, so the link can't be replayed.
auth_set_password($pdo, (int) $reset['user_id'], $newPassword);
return true;
}
// ─── Email: password reset / first-password invitation ───────────────────────
// Delivery is centralized at the hub's /notify endpoint — see
// config/functions_queue.php (enqueue_email + flush_message).
// $flush=false deixa a mensagem na fila para o cron (≤5 min). O pedido de
// recuperação usa-o: um envio SMTP síncrono só quando a conta existe fazia a
// resposta demorar segundos apenas nesse caso — um oráculo de contas.
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null, bool $flush = true): bool {
$body = load_email_template('password_reset', [
'user_name' => $name,
'reset_url' => $resetUrl,
'app_name' => APP_NAME,
]);
$id = enqueue_email([
'to_user_id' => $userId,
'to_address' => $email,
'to_name' => $name,
'subject' => 'Redefinir palavra-passe',
'body' => $body,
'alt_body' => "Olá {$name},\n\nPara definir uma nova palavra-passe aceda a:\n{$resetUrl}\n\nO link é válido durante 2 horas.",
'context_type' => 'password_reset',
'context_id' => $userId,
]);
return $flush ? flush_message($id) : $id > 0;
}
// An account created from the backoffice has no password, so the person has no
// way of knowing it exists. Invites them to set one, reusing the reset token —
// same mechanism, different copy.
function send_password_invite_email(PDO $pdo, array $user): bool {
if (empty($user['email'])) return false;
audit('user.invite', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) ($user['name'] ?? '')]);
$token = auth_create_reset_token($pdo, (int) $user['id']);
$url = base_url('views/auth/reset_password.php?token=' . $token);
$name = (string) ($user['name'] ?? '');
$body = load_email_template('password_invite', [
'user_name' => $name,
'reset_url' => $url,
'app_name' => APP_NAME,
]);
$id = enqueue_email([
'to_user_id' => (int) $user['id'],
'to_address' => $user['email'],
'to_name' => $name,
'subject' => 'A sua conta ' . APP_NAME,
'body' => $body,
'alt_body' => "Olá {$name},\n\nFoi criada uma conta para si em " . APP_NAME . ".\nDefina a sua palavra-passe em:\n{$url}\n\nO link é válido durante 2 horas.",
'context_type' => 'password_invite',
'context_id' => (int) $user['id'],
]);
return flush_message($id);
}