Files
tinker_tickets/models/UserModel.php
T
jaredandClaude Sonnet 5 7c1c1b61cc Fix race in first-time login user creation (#96)
syncUserFromAuthelia() did a plain check-then-insert with no
transaction, so two simultaneous first-visit requests for the same
brand-new user (e.g. two tabs opened right after SSO login) could
race: the second INSERT hits users.username's UNIQUE KEY, which
mysqli throws on (uncaught, PHP 8.1+ default report mode) rather than
returning false. Switched to INSERT ... ON DUPLICATE KEY UPDATE
followed by a re-fetch by username, so the losing request updates the
winner's row instead of throwing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:41:30 -04:00

345 lines
10 KiB
PHP

<?php
/**
* UserModel - Handles user authentication and management
*/
class UserModel
{
private mysqli $conn;
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
private static int $cacheTTL = 300; // 5 minutes
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
/**
* Get cached user data if not expired
*/
private static function getCached(string $key): ?array
{
if (isset(self::$userCache[$key])) {
$cached = self::$userCache[$key];
if ($cached['expires'] > time()) {
return $cached['data'];
}
// Expired - remove from cache
unset(self::$userCache[$key]);
}
return null;
}
/**
* Store user data in cache with expiration
*/
private static function setCached(string $key, array $data): void
{
self::$userCache[$key] = [
'data' => $data,
'expires' => time() + self::$cacheTTL
];
}
/**
* Invalidate specific user cache entry
*/
public static function invalidateCache(?int $userId = null, ?string $username = null): void
{
if ($userId !== null) {
unset(self::$userCache["user_id_$userId"]);
}
if ($username !== null) {
unset(self::$userCache["user_$username"]);
}
}
/**
* Sync user from Authelia headers (create or update)
*
* @param string $username Username from Remote-User header
* @param string $displayName Display name from Remote-Name header
* @param string $email Email from Remote-Email header
* @param string $groups Comma-separated groups from Remote-Groups header
* @return array User data array
*/
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array
{
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
// Determine if user is admin based on groups
$isAdmin = $this->checkAdminStatus($groups);
// Try to find existing user
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
// Update existing user
$user = $result->fetch_assoc();
$updateStmt = $this->conn->prepare(
"UPDATE users SET display_name = ?, email = ?, `groups` = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
);
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
$updateStmt->execute();
$updateStmt->close();
// Refresh user data
$user['display_name'] = $displayName;
$user['email'] = $email;
$user['groups'] = $groups;
$user['is_admin'] = $isAdmin;
} else {
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
// a plain INSERT) so two concurrent first-visit requests for the same
// brand-new username can't race: the losing request updates the row the
// winner just created instead of throwing an uncaught duplicate-key
// exception (users.username has a UNIQUE KEY, and mysqli throws on
// constraint violation under PHP 8.1+'s default report mode).
$insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
email = VALUES(email),
`groups` = VALUES(groups),
is_admin = VALUES(is_admin),
last_login = NOW()"
);
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute();
$insertStmt->close();
// Re-fetch by username — works whether this request won the insert or
// lost the race and only updated the winner's row.
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
}
$stmt->close();
// Cache user with TTL
self::setCached($cacheKey, $user);
return $user;
}
/**
* Get system user (for hwmonDaemon)
*
* @return array|null System user data or null if not found
*/
public function getSystemUser(): ?array
{
// Check cache first
$cached = self::getCached('system');
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached('system', $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Get user by ID
*
* @param int $userId User ID
* @return array|null User data or null if not found
*/
public function getUserById(int $userId): ?array
{
// Check cache first
$cacheKey = "user_id_$userId";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached($cacheKey, $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Get user by username
*
* @param string $username Username
* @return array|null User data or null if not found
*/
public function getUserByUsername(string $username): ?array
{
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached($cacheKey, $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Check if user has admin privileges based on groups
*
* @param string $groups Comma-separated group names
* @return bool True if user is in admin group
*/
private function checkAdminStatus(string $groups): bool
{
if (empty($groups)) {
return false;
}
// Split groups by comma and check for 'admin' group
$groupArray = array_map('trim', explode(',', strtolower($groups)));
return in_array('admin', $groupArray);
}
/**
* Check if user is admin
*
* @param array $user User data array
* @return bool True if user is admin
*/
public function isAdmin(array $user): bool
{
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
}
/**
* Check if user has required group membership
*
* @param array $user User data array
* @param array $requiredGroups Array of required group names
* @return bool True if user is in at least one required group
*/
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool
{
if (empty($user['groups'])) {
return false;
}
$userGroups = array_map('trim', explode(',', strtolower($user['groups'])));
$requiredGroups = array_map('strtolower', $requiredGroups);
return !empty(array_intersect($userGroups, $requiredGroups));
}
/**
* Get all users (for admin panel)
*
* @return array Array of user records
*/
public function getAllUsers(): array
{
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
$stmt->execute();
$result = $stmt->get_result();
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
/**
* Get all distinct groups from all users
* Used for visibility group selection UI
*
* Results are cached for 5 minutes to reduce database load
* since group changes are infrequent.
*
* @return array Array of unique group names
*/
public function getAllGroups(): array
{
$cacheKey = 'all_groups';
// Check cache first
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT DISTINCT `groups` FROM users WHERE `groups` IS NOT NULL AND `groups` != ''");
$stmt->execute();
$result = $stmt->get_result();
$allGroups = [];
while ($row = $result->fetch_assoc()) {
$userGroups = array_filter(array_map('trim', explode(',', $row['groups'])));
$allGroups = array_merge($allGroups, $userGroups);
}
$stmt->close();
// Return unique groups sorted alphabetically
$uniqueGroups = array_unique($allGroups);
sort($uniqueGroups);
// Cache the result
self::setCached($cacheKey, $uniqueGroups);
return $uniqueGroups;
}
/**
* Invalidate the groups cache
* Call this when user groups are modified
*/
public static function invalidateGroupsCache(): void
{
unset(self::$userCache['all_groups']);
}
}