Системы контроля доступа: от сессий до 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)

Управление доступом в PHP - comments

En
доступ php (php)