Управление доступом в PHP: от основ до продвинутой защиты
Управление правами доступа в PHP
Как построить гибкую систему прав на основе ролей и разрешений с кэшированием?
Наиболее эффективное решение для большинства проектов — ролевая модель (RBAC) с хранением связей между пользователями, ролями и разрешениями в базе данных и кэшированием результатов для снижения нагрузки. Такой подход позволяет легко масштабировать систему, добавлять новые роли и права без изменения кода, а также проводить аудит.
Цель: предоставить каждому пользователю ровно те возможности, которые разрешены его ролями, без дублирования логики. Случаи использования: интернет-магазины, панели администрирования, корпоративные порталы.
// Пример сервиса проверки прав (фрагмент)
class AccessService {
private $cache;
private $db;
public function __construct(PDO $db, CacheInterface $cache) {
$this->db = $db;
$this->cache = $cache;
}
public function hasPermission(int $userId, string $permission): bool {
$key = "user_permissions_$userId";
$permissions = $this->cache->get($key);
if ($permissions === null) {
$stmt = $this->db->prepare('
SELECT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = :userId
');
$stmt->execute([':userId' => $userId]);
$permissions = array_column($stmt->fetchAll(), 'name');
$this->cache->set($key, $permissions, 3600);
}
return in_array($permission, $permissions, true);
}
}
Пояснение
Сервис кэширует список разрешений пользователя на 1 час, чтобы избежать повторных запросов к БД. При каждом вызове hasPermission проверка происходит в памяти, что ускоряет работу.
Типичные ошибки:
- Забыть сбросить кэш после изменения прав пользователя. Решение: при каждом обновлении прав удалять соответствующий ключ из кэша.
- Использовать только одну таблицу ролей без разрешений — негибко.
- Хранить права в сессии, что приводит к устареванию данных при длительном сеансе.
Как сделать простую проверку роли через глобальную сессию?
Для небольших сайтов, где количество ролей ограничено (например, admin, user), можно хранить роль непосредственно в сессии после аутентификации. Это быстро и не требует дополнительных запросов к БД.
Цель: минимальная реализация без сложной инфраструктуры. Случай использования: личный блог или простая админка.
// После входа пользователя
$_SESSION['role'] = 'admin';
// При проверке доступа
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
// разрешить действие
}
Проблемы:
- Невозможность гранулярного управления — каждое действие проверяется отдельно.
- При смене роли администратором данные в сессии остаются старыми до выхода пользователя.
- Отсутствие логирования попыток доступа.
Как использовать ACL (Access Control Lists) с явными разрешениями для каждого пользователя?
ACL — модель, где каждому пользователю напрямую сопоставляется список разрешённых объектов и операций. Это даёт максимальную гибкость, но требует много записей в БД.
Цель: точная настройка прав для каждого отдельного пользователя, например, доступ к определённым документам. Случай использования: системы документооборота.
// Таблица acl: user_id, resource, action, allowed
function checkAcl(PDO $db, int $userId, string $resource, string $action): bool {
$stmt = $db->prepare('
SELECT allowed FROM acl
WHERE user_id = :uid AND resource = :res AND action = :act
');
$stmt->execute([':uid' => $userId, ':res' => $resource, ':act' => $action]);
$row = $stmt->fetch();
return $row ? (bool)$row['allowed'] : false; // по умолчанию запрещено
}
Ошибка: забыть добавить запись для нового ресурса — доступ будет запрещён всегда. Рекомендуется включить наследование от роли.
Как внедрить middleware для проверки прав в Laravel?
Современные фреймворки предлагают встроенные механизмы. В Laravel можно создать middleware, который перехватывает запрос и вызывает проверку разрешения через Gate или Policy.
Цель: централизованная фильтрация запросов без дублирования кода. Случай использования: любой проект на Laravel.
// Middleware: app/Http/Middleware/CheckPermission.php
public function handle($request, Closure $next, $permission) {
if (Gate::denies($permission)) {
abort(403);
}
return $next($request);
}
// В маршруте:
Route::middleware('permission:edit-posts')->group(function () {
Route::resource('posts', 'PostController');
});
Проблема: неправильное имя разрешения в маршруте — нужно синхронизировать с определением Gate. Решение: использовать константы или перечисления.
Расширенные примеры реализации
Пример 1. Полная реализация RBAC на чистом PHP с PDO
Ниже приведён код, который демонстрирует загрузку ролей и разрешений из базы данных, кэширование в массиве и проверку нескольких разрешений.
<?php
class RbacManager {
private $pdo;
private $permissionsCache = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function loadPermissions(int $userId): array {
if (!isset($this->permissionsCache[$userId])) {
$stmt = $this->pdo->prepare('
SELECT DISTINCT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = :uid
');
$stmt->execute([':uid' => $userId]);
$this->permissionsCache[$userId] = array_column($stmt->fetchAll(), 'name');
}
return $this->permissionsCache[$userId];
}
public function hasPermission(int $userId, string $permission): bool {
return in_array($permission, $this->loadPermissions($userId), true);
}
public function hasAnyPermission(int $userId, array $permissions): bool {
$userPerms = $this->loadPermissions($userId);
return !empty(array_intersect($permissions, $userPerms));
}
}
// Использование:
$manager = new RbacManager($pdo);
if ($manager->hasPermission(42, 'delete-post')) {
echo 'Пользователь может удалять посты';
}
?>
Пользователь может удалять посты
Пояснение:
Метод loadPermissions кэширует разрешения в свойстве класса, поэтому при повторных вызовах в рамках одного запроса база не опрашивается. hasAnyPermission позволяет проверить наличие хотя бы одного из списка прав.
Пример 2. Middleware для Laravel с поддержкой нескольких ролей
Создадим middleware, который проверяет, имеет ли пользователь одну из указанных ролей.
// app/Http/Middleware/CheckRole.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
class CheckRole
{
public function handle($request, Closure $next, ...$roles)
{
$user = Auth::user();
if (!$user || !$user->hasAnyRole($roles)) {
abort(403, 'Доступ запрещён');
}
return $next($request);
}
}
// В User.php модели
public function hasAnyRole(array $roles): bool {
return $this->roles()->whereIn('name', $roles)->exists();
}
// Регистрация в kernel.php и использование:
Route::middleware('role:admin,moderator')->get('/admin', function () {
return view('admin');
});
При попытке доступа пользователем без роли admin или moderator возвращается 403
Пояснение:
Middleware принимает список ролей как аргументы через двоеточие. Метод hasAnyRole делегирует проверку связи many-to-many с таблицей ролей.
Пример 3. Использование битовых масок для хранения прав
Для очень распространённых проектов можно хранить права в виде битовой маски в одном целом числе, например, каждая операция — степень двойки.
// Определение прав
define('READ', 1); // 1
define('WRITE', 2); // 10
define('DELETE', 4); // 100
define('ADMIN', 8); // 1000
// Права пользователя: read + write
$rights = READ | WRITE; // 3
// Проверка
if ($rights & READ) {
echo 'Есть право на чтение';
}
if ($rights & DELETE) {
echo 'Есть право на удаление';
} else {
echo 'Нет права на удаление';
}
Есть право на чтение Нет права на удаление
Пояснение:
Битовые маски очень компактны (одно целое число вместо массива), но неудобны для крупных наборов прав (ограничение размера int). Часто используются для базовых CRUD-операций.
Пример 4. Кэширование разрешений в Redis
Если приложение работает на нескольких серверах, общий кэш (Redis) позволяет синхронизировать данные о правах между ними.
use Predis\Client as Redis;
class RedisRbac {
private $redis;
private $pdo;
public function __construct(Redis $redis, PDO $pdo) {
$this->redis = $redis;
$this->pdo = $pdo;
}
public function check(int $userId, string $perm): bool {
$key = "rbac:$userId";
$permissions = $this->redis->smembers($key);
if (empty($permissions)) {
$stmt = $this->pdo->prepare('SELECT p.name FROM permissions p ...'); // как в примере 1
$stmt->execute([':uid' => $userId]);
$permissions = $stmt->fetchAll(\PDO::FETCH_COLUMN);
if ($permissions) {
$this->redis->sadd($key, ...$permissions);
$this->redis->expire($key, 3600);
}
}
return $this->redis->sismember($key, $perm);
}
public function invalidate(int $userId): void {
$this->redis->del("rbac:$userId");
}
}
(без вывода, но метод check возвращает true/false)
Пояснение:
В Redis используются множества (sets) для хранения разрешений пользователя. Транзакция sadd и проверка sismember очень быстры. Метод invalidate сбрасывает кэш при изменении прав.