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 ────────────────────────────────────────────────────────── // // 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. // 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; 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 // 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([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. 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, '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, 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']]; } // 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, auth_password_hash($data['password']), !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)), auth_password_hash($password), ]); $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, auth_password_hash($password), $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); }