Системы контроля доступа: от сессий до RBAC
Основной подход: управление доступом на основе ролей (RBAC) с использованием сессий
После успешной аутентификации пользователь получает роль, которая хранится в сессии. Для каждого защищённого ресурса выполняется проверка роли. Наиболее эффективное решение - создать единый класс AccessManager, который инкапсулирует логику проверки.
// AccessManager.php
class AccessManager {
private array $rolesHierarchy = [
'admin' => ['editor', 'user'],
'editor' => ['user'],
'user' => []
];
public function hasRole(string $requiredRole, string $userRole): bool {
if ($userRole === $requiredRole) return true;
if (isset($this->rolesHierarchy[$userRole])) {
return in_array($requiredRole, $this->rolesHierarchy[$userRole]);
}
return false;
}
public function checkAccess(string $requiredRole): void {
if (session_status() === PHP_SESSION_NONE) session_start();
$userRole = $_SESSION['user_role'] ?? null;
if (!$userRole || !$this->hasRole($requiredRole, $userRole)) {
http_response_code(403);
die('Доступ запрещён');
}
}
}
// Использование в скрипте:
require 'AccessManager.php';
$access = new AccessManager();
$access->checkAccess('editor');
Этот класс поддерживает иерархию ролей: администратор может выполнять действия редактора и пользователя. Проверка происходит централизованно.
Типичные проблемы:
- Роль пользователя может устареть, если в БД произошли изменения. Решение: проверять роль при каждом запросе из надёжного источника (например, кэш с коротким TTL).
- Забыли вызвать session_start() - сессия не будет считана. Всегда проверять статус сессии.
- Использование жёстко закодированных ролей затрудняет расширение. Лучше вынести роли в конфиг или БД.
Как быстро ограничить доступ к странице на основе роли?
Самый простой способ - вставить проверку в начало каждого скрипта.
if ($_SESSION['user_role'] !== 'admin') {
header('HTTP/1.0 403 Forbidden');
exit('Доступ только для администратора');
}
Этот вариант подходит для небольших проектов с одной ролью.
Проблемы:
- Дублирование кода в каждом файле.
- Если роль хранится в сессии, её можно подменить (если сессия уязвима). Всегда проверять сессию на серверной стороне.
- Отсутствие гибкости при изменении иерархии.
Как организовать централизованную проверку доступа с помощью middleware?
Middleware - прослойка, которая обрабатывает запрос до контроллера. Пример реализации на чистом PHP.
class MiddlewarePipeline {
private array $middlewares = [];
public function add(callable $middleware): void {
$this->middlewares[] = $middleware;
}
public function run(callable $handler): void {
$pipeline = array_reduce(
array_reverse($this->middlewares),
fn($next, $middleware) => fn() => $middleware($next),
$handler
);
$pipeline();
}
}
// Middleware проверки роли
$authMiddleware = function($next) {
if (($_SESSION['user_role'] ?? '') !== 'admin') {
http_response_code(403);
echo 'Доступ запрещён';
return;
}
$next();
};
// Использование
$pipeline = new MiddlewarePipeline();
$pipeline->add($authMiddleware);
$pipeline->add(function($next) {
echo 'Основной контент';
$next();
});
$pipeline->run(function() { /* заглушка */ });
Такой подход позволяет легко добавлять новые проверки (логирование, CSRF).
Типичные ошибки:
- Middleware не останавливает выполнение после запрета - нужно явно выходить.
- Порядок middleware имеет значение.
- Сложность отладки при большом количестве слоёв.
Как реализовать гибкую систему разрешений (ACL) с базой данных?
ACL связывает пользователя, роль и конкретное разрешение (например, 'article.edit').
// Таблицы: users, roles, permissions, role_permissions
$stmt = $pdo->prepare('SELECT p.name
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = ? AND p.name = ?');
$stmt->execute([$userId, 'article.edit']);
if (!$stmt->fetch()) {
throw new \RuntimeException('Нет прав на редактирование статьи');
}
Такой подход масштабируется для больших систем.
Проблемы:
- Большое количество запросов к БД. Решение: кэширование прав пользователя в сессии на время сессии.
- Ошибки в SQL-запросах (инъекции) - обязательно использовать подготовленные выражения.
- Сложность управления множеством разрешений.
Как упростить управление доступом с помощью готовых библиотек?
Фреймворки Laravel, Symfony, Yii предоставляют встроенные механизмы. Например, в Laravel - Gate и Policies.
// Определение в App\Providers\AuthServiceProvider
Gate::define('edit-article', function ($user, $article) {
return $user->id === $article->user_id || $user->hasRole('admin');
});
// В контроллере
$this->authorize('edit-article', $article);
Готовые решения экономят время, но привязывают к фреймворку.
Типичные ошибки:
- Неправильное определение политики - проверка не срабатывает.
- Забывают зарегистрировать политики в сервис-провайдере.
- Использование устаревшей документации.
Расширенные примеры реализации управления доступом
Пример 1: RBAC с кэшированием разрешений в сессии
После аутентификации загружаем все разрешения пользователя и храним их в сессии. Проверка происходит без обращения к БД.
class CachedAccessManager {
private \PDO $pdo;
public function __construct(\PDO $pdo) {
$this->pdo = $pdo;
}
public function loadPermissions(int $userId): void {
$stmt = $this->pdo->prepare('SELECT p.name
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = ?');
$stmt->execute([$userId]);
$permissions = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$_SESSION['user_permissions'] = $permissions;
}
public function can(string $permission): bool {
return in_array($permission, $_SESSION['user_permissions'] ?? []);
}
}
// Использование
session_start();
$manager = new CachedAccessManager($pdo);
$manager->loadPermissions(42);
if ($manager->can('article.publish')) {
echo 'Разрешено публиковать статью';
} else {
echo 'Доступ запрещён';
}
Вывод: Разрешено публиковать статью (если пользователь имеет право)
Пример 2: Middleware с поддержкой маршрутизации
Используем простую реализацию middleware, которая проверяет роль для конкретного маршрута.
class Router {
private array $routes = [];
public function add(string $path, callable $handler, array $middlewares = []): void {
$this->routes[$path] = ['handler' => $handler, 'middlewares' => $middlewares];
}
public function dispatch(string $uri): void {
if (!isset($this->routes[$uri])) {
http_response_code(404);
echo 'Не найдено';
return;
}
$route = $this->routes[$uri];
$pipeline = array_reduce(
array_reverse($route['middlewares']),
fn($next, $mw) => fn() => $mw($next, $uri),
$route['handler']
);
$pipeline();
}
}
$router = new Router();
$adminMiddleware = function($next, $uri) {
if (($_SESSION['user_role'] ?? '') !== 'admin') {
http_response_code(403);
echo "Доступ к $uri запрещён";
return;
}
$next();
};
$router->add('/admin/dashboard', function() {
echo 'Панель администратора';
}, [$adminMiddleware]);
$router->add('/public', function() {
echo 'Публичная страница';
});
// Запрос
session_start();
$_SESSION['user_role'] = 'editor';
$router->dispatch('/admin/dashboard');
Вывод: Доступ к /admin/dashboard запрещён (так как роль editor, а требуется admin)
Пример 3: ACL с использованием битовых масок
Для экономии места можно использовать битовые маски. Каждому разрешению соответствует степень двойки.
define('PERM_READ', 1);
define('PERM_WRITE', 2);
define('PERM_DELETE', 4);
define('PERM_ADMIN', 8);
class BitwiseAcl {
private array $users = []; // userId => mask
public function setPermissions(int $userId, int $mask): void {
$this->users[$userId] = $mask;
}
public function hasPermission(int $userId, int $perm): bool {
return ($this->users[$userId] ?? 0) & $perm;
}
}
$acl = new BitwiseAcl();
$acl->setPermissions(1, PERM_READ | PERM_WRITE); // 3
echo $acl->hasPermission(1, PERM_DELETE) ? 'Да' : 'Нет'; // Нет
echo $acl->hasPermission(1, PERM_READ) ? 'Да' : 'Нет'; // Да
Вывод: Нет Да
Пример 4: Enum ролей в PHP 8.1
Использование перечислений для строгой типизации ролей.
enum Role: string {
case User = 'user';
case Editor = 'editor';
case Admin = 'admin';
public function canAccess(Role $required): bool {
return match ($this) {
self::Admin => true,
self::Editor => $required !== self::Admin,
self::User => $required === self::User,
};
}
}
$current = Role::Editor;
if ($current->canAccess(Role::Admin)) {
echo 'Доступ к админке разрешён';
} else {
echo 'Недостаточно прав';
}
Вывод: Недостаточно прав
Пример 5: Проверка доступа к API с помощью токена
При REST API токен передаётся в заголовке Authorization. Проверяем его валидность и права.
function authenticateToken(string $token): ?array {
// Поиск токена в БД
global $pdo;
$stmt = $pdo->prepare('SELECT user_id, role FROM tokens WHERE token = ? AND expires_at > NOW()');
$stmt->execute([$token]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function requireRole(string $requiredRole): void {
$headers = getallheaders();
$token = $headers['Authorization'] ?? '';
$user = authenticateToken(str_replace('Bearer ', '', $token));
if (!$user || $user['role'] !== $requiredRole) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
// Использование
requireRole('admin');
echo json_encode(['data' => 'secret']);
При отсутствии токена: {"error":"Unauthorized"} (HTTP 401)