time() + 60 * 60 * AUTH_TOKEN_HOURS, 'path' => '/', 'secure' => isSecureRequest(), 'httponly' => false, 'samesite' => 'Lax', ]); $_COOKIE[API_TOKEN_COOKIE_NAME] = $token; } function deleteApiTokenCookie(): void { setcookie(API_TOKEN_COOKIE_NAME, '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => isSecureRequest(), 'httponly' => false, 'samesite' => 'Lax', ]); unset($_COOKIE[API_TOKEN_COOKIE_NAME]); } function login(string $email, string $password, bool $remember = false): ?string { $email = strtolower(trim($email)); $stmt = db()->prepare('SELECT * FROM users WHERE email = ? LIMIT 1'); $stmt->execute([$email]); $user = $stmt->fetch(); if (!$user || !password_verify($password, $user['passwd'])) { return null; } session_regenerate_id(true); $_SESSION['user_id'] = (int)$user['id']; if ($remember) { createRememberToken((int)$user['id']); } $token = createAuthToken((int)$user['id']); setApiTokenCookie($token); return $token; } function logout(): void { $token = getBearerToken(); if ($token) { revokeAuthToken($token); } if (isset($_SESSION['user_id'])) { revokeUserAuthTokens((int)$_SESSION['user_id']); } deleteRememberCookie(); deleteApiTokenCookie(); $_SESSION = []; if (session_status() === PHP_SESSION_ACTIVE) { session_destroy(); } } function currentUser(): ?array { if (!isset($_SESSION['user_id'])) { tryRememberLogin(); } if (isset($_SESSION['user_id'])) { $stmt = db()->prepare( 'SELECT id, name, email, picture FROM users WHERE id = ? LIMIT 1' ); $stmt->execute([$_SESSION['user_id']]); return $stmt->fetch() ?: null; } $token = getBearerToken(); if ($token) { return authenticateApiToken($token); } return null; } function requireLogin(): array { $user = currentUser(); if (!$user) { header('Location: ?page=login'); exit; } return $user; } function requireGuest(): void { if (currentUser()) { header('Location: ?page=home'); exit; } } function createRememberToken(int $userId): void { $selector = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); $expires = date('Y-m-d H:i:s', time() + 60 * 60 * 24 * REMEMBER_DAYS); $stmt = db()->prepare( 'INSERT INTO remember_tokens (user_id, selector, token_hash, expires_at) VALUES (?, ?, ?, ?)' ); $stmt->execute([$userId, $selector, $tokenHash, $expires]); setcookie(REMEMBER_COOKIE_NAME, $selector . ':' . $token, [ 'expires' => time() + 60 * 60 * 24 * REMEMBER_DAYS, 'path' => '/', 'secure' => isSecureRequest(), 'httponly' => true, 'samesite' => 'Lax', ]); } function tryRememberLogin(): void { if (empty($_COOKIE[REMEMBER_COOKIE_NAME])) { return; } $parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME]); if (count($parts) !== 2) { deleteRememberCookie(); return; } [$selector, $token] = $parts; $tokenHash = hash('sha256', $token); $stmt = db()->prepare( 'SELECT * FROM remember_tokens WHERE selector = ? AND expires_at > NOW() LIMIT 1' ); $stmt->execute([$selector]); $row = $stmt->fetch(); if (!$row || !hash_equals($row['token_hash'], $tokenHash)) { deleteRememberCookie(); return; } session_regenerate_id(true); $_SESSION['user_id'] = (int)$row['user_id']; db()->prepare('DELETE FROM remember_tokens WHERE id = ?')->execute([$row['id']]); createRememberToken((int)$row['user_id']); $apiToken = createAuthToken((int)$row['user_id']); setApiTokenCookie($apiToken); } function deleteRememberCookie(): void { if (!empty($_COOKIE[REMEMBER_COOKIE_NAME])) { $parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME]); if (count($parts) === 2) { db()->prepare( 'DELETE FROM remember_tokens WHERE selector = ?' )->execute([$parts[0]]); } } setcookie(REMEMBER_COOKIE_NAME, '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => isSecureRequest(), 'httponly' => true, 'samesite' => 'Lax', ]); unset($_COOKIE[REMEMBER_COOKIE_NAME]); } function createAuthToken(int $userId): string { $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); $expires = date('Y-m-d H:i:s', time() + 60 * 60 * AUTH_TOKEN_HOURS); $stmt = db()->prepare( 'INSERT INTO auth_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)' ); $stmt->execute([ $userId, $tokenHash, $expires ]); return $token; } function authenticateApiToken(string $token): ?array { $token = trim($token); if ($token === '') { return null; } $tokenHash = hash('sha256', $token); $stmt = db()->prepare( 'SELECT users.id, users.name, users.email, users.picture FROM auth_tokens JOIN users ON users.id = auth_tokens.user_id WHERE auth_tokens.token_hash = ? AND auth_tokens.expires_at > NOW() AND auth_tokens.revoked_at IS NULL LIMIT 1' ); $stmt->execute([$tokenHash]); $user = $stmt->fetch(); if (!$user) { return null; } db()->prepare( 'UPDATE auth_tokens SET last_used_at = NOW() WHERE token_hash = ?' )->execute([$tokenHash]); return $user; } function getBearerToken(): ?string { $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; if (preg_match('/Bearer\s+(.+)/i', $header, $matches)) { return trim($matches[1]); } if (!empty($_COOKIE[API_TOKEN_COOKIE_NAME])) { return trim($_COOKIE[API_TOKEN_COOKIE_NAME]); } return null; } function jsonAuthError(string $message, int $status): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'success' => false, 'error' => $message ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } function userHasRight(int $userId, string $rightName): bool { try { $stmt = db()->prepare( 'SELECT rights.id FROM user_rights INNER JOIN rights ON rights.id = user_rights.right_id WHERE user_rights.user_id = ? AND rights.name = ? LIMIT 1' ); $stmt->execute([$userId, $rightName]); return (bool)$stmt->fetch(); } catch (Throwable $e) { return false; } } function userIsAdmin(int $userId): bool { return userHasRight($userId, 'Admin'); } function requireUserRight(int $userId, string $rightName): void { if (userIsAdmin($userId) || userHasRight($userId, $rightName)) { return; } jsonAuthError("Missing right: {$rightName}.", 403); } function projectExists(string $projectId): bool { $stmt = db()->prepare('SELECT id FROM projects WHERE id = ? LIMIT 1'); $stmt->execute([$projectId]); return (bool)$stmt->fetch(); } function userCanAccessProject(int $userId, string $projectId): bool { if (userIsAdmin($userId)) { return true; } $stmt = db()->prepare( 'SELECT projects.id FROM projects LEFT JOIN user_access ON user_access.project_id = projects.id AND user_access.user_id = ? WHERE projects.id = ? AND (projects.owner = ? OR user_access.id IS NOT NULL) LIMIT 1' ); $stmt->execute([$userId, $projectId, $userId]); return (bool)$stmt->fetch(); } function requireProjectAccessForUser(int $userId, string $projectId): void { if (!projectExists($projectId)) { jsonAuthError('Project not found.', 404); } if (!userCanAccessProject($userId, $projectId)) { jsonAuthError('No access to this project.', 403); } } function requireProjectRight(int $userId, string $projectId, string $rightName): void { requireProjectAccessForUser($userId, $projectId); requireUserRight($userId, $rightName); } function requireApiAuth(): array { $token = getBearerToken(); if (!$token) { jsonAuthError('Missing API token.', 401); } $user = authenticateApiToken($token); if (!$user) { jsonAuthError('Invalid or expired API token.', 401); } return $user; } function revokeAuthToken(string $token): void { $tokenHash = hash('sha256', $token); db()->prepare( 'UPDATE auth_tokens SET revoked_at = NOW() WHERE token_hash = ? AND revoked_at IS NULL' )->execute([$tokenHash]); } function revokeUserAuthTokens(int $userId): void { db()->prepare( 'UPDATE auth_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL' )->execute([$userId]); } function cleanupExpiredAuthData(): void { db()->exec('DELETE FROM remember_tokens WHERE expires_at < NOW()'); db()->exec('DELETE FROM auth_tokens WHERE expires_at < NOW() OR revoked_at IS NOT NULL'); } function register(string $username, string $email, string $password): ?int { $username = trim($username); $email = strtolower(trim($email)); if ($username === '') { return null; } if ($email === '') { return null; } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return null; } if ($password === '') { return null; } if (strlen($password) < 8) { return null; } $passwordHash = password_hash($password, PASSWORD_ARGON2ID); if ($passwordHash === false) { $passwordHash = password_hash($password, PASSWORD_DEFAULT); } try { $stmt = db()->prepare( 'INSERT INTO users (name, email, passwd) VALUES (?, ?, ?)' ); $stmt->execute([ $username, $email, $passwordHash ]); return (int)db()->lastInsertId(); } catch (PDOException $e) { return null; } }