This commit is contained in:
Egor Isaev 2026-08-06 16:52:19 +03:00
parent 858e574e60
commit bbeb36b2e8
13 changed files with 626 additions and 33 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@
config.json config.json
App/config/config.local.php App/config/config.local.php
App/config/auth_users.php
/App/logs/ /App/logs/

View File

@ -9,24 +9,19 @@
namespace App\Controller\Admin; namespace App\Controller\Admin;
use System\Classes\Controller; use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
/** /**
* Базовый контроллер админки. Рендерится в общем layout сайта * Базовый контроллер админки. Рендерится в общем layout сайта
* (App/view/layout.html), своего отдельного шаблона не имеет. * (App/view/layout.html), своего отдельного шаблона не имеет.
* Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) —
* неавторизованный редиректится на /login, авторизованный без роли admin получает 403.
* Конкретные страницы (Logs и т.п.) наследуют его. * Конкретные страницы (Logs и т.п.) наследуют его.
*/ */
abstract class AdminController extends Controller abstract class AdminController extends Controller
{ {
/** /** @var bool Доступ в админку только авторизованным пользователям */
* Точка для проверки прав администратора. protected bool $_auth_protection = true;
*
* @return void /** @var array<int,string> В админку пускаем только роль admin */
* @throws HTTPException protected array $_auth_roles = ['admin'];
*/
protected function before(): void
{
parent::before(); // авто-CSRF на небезопасных методах
// TODO: проверка прав администратора, когда появится логин.
}
} }

View File

@ -0,0 +1,62 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description LoginController.php
* @copyright (c) 06/08/2026
*/
namespace App\Controller;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest;
use System\Classes\Request;
use System\Classes\Validation;
/**
* Вход/выход. URL: /login (форма + обработка), /login/logout.
*/
class LoginController extends Controller
{
/**
* Показывает форму входа (GET) и обрабатывает её (POST).
*
* @return string
* @throws \System\Classes\MyException
*/
public function indexAction(): string
{
$request = Request::$current;
$error = null;
if ($request->method() === HTTPRequest::POST) {
$validation = Validation::factory($request->post())
->label('login', 'Логин')
->label('password', 'Пароль')
->rule('login', 'required')
->rule('password', 'required');
if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) {
HTTPException::factory(302, '/admin/logs')->getResponse();
}
$error = 'Неверный логин или пароль';
}
return $this->render('login', ['error' => $error]);
}
/**
* Разлогинивает пользователя и возвращает на главную.
*
* @return string
*/
public function logoutAction(): string
{
Auth::instance()->logout();
HTTPException::factory(302, '/')->getResponse();
}
}

View File

@ -32,4 +32,10 @@ return [
'threshold' => 'debug', 'threshold' => 'debug',
], ],
'auth' => [
'driver' => 'file',
'users_file' => APPPATH . '/config/auth_users.php',
'session_key' => 'auth_user',
],
]; ];

34
App/view/Login/login.html Normal file
View File

@ -0,0 +1,34 @@
<?php
/**
* @package Bicycle
* @description login.html — форма входа
*
* @var string|null $error Сообщение об ошибке (неверные данные)
*/
use System\Classes\CSRF;
?>
<h1 class="h3 mb-3">Вход</h1>
<?php if ($error !== null): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/login" class="col-md-4">
<?= CSRF::field() ?>
<div class="mb-3">
<label class="form-label">Логин
<input type="text" name="login" class="form-control" autofocus>
</label>
</div>
<div class="mb-3">
<label class="form-label">Пароль
<input type="password" name="password" class="form-control">
</label>
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>

View File

@ -41,13 +41,14 @@ App/Controller/ — контроллеры приложени
App/config/ — конфиги (config.php; config.local.php — в .gitignore) App/config/ — конфиги (config.php; config.local.php — в .gitignore)
App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
App/media/ — статические ресурсы (js, css, img) App/media/ — статические ресурсы (js, css, img)
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет
System/view/ — системные шаблоны (ошибки 404/500/403, exception) System/view/ — системные шаблоны (ошибки 404/500/403, exception)
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore) tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
tests/ — PHPUnit тесты tests/ — PHPUnit тесты
``` ```
> Каталоги `App/Classes/` и наполнение `Services/*` ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. > Каталог `App/Classes/` и часть `Services/*` (DataBase, Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы.
### Autoload (composer.json) ### Autoload (composer.json)
@ -100,6 +101,9 @@ PhpStorm может показывать предупреждение «Namespac
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL | | `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
| `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` | | `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` |
| `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` | | `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` |
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
### Request lifecycle ### Request lifecycle
@ -243,6 +247,52 @@ if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
- `field()` — готовый `<input type="hidden">` с токеном. - `field()` — готовый `<input type="hidden">` с токеном.
- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`). - `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`).
### Auth (Services\Auth)
Авторизация через сменный драйвер (`Services\Auth\AuthDriver`), как в Kohana Auth: по умолчанию
`FileAuthDriver` (логины/пароли из PHP-файла), позже — БД/LDAP/Keycloak как новые классы
`implements AuthDriver` без изменения остального кода (тот же принцип, что и `LogReader`/`FileLogReader`).
- `Services\Auth::instance(?string $name = null)` — драйвер по имени (кэшируется); без аргумента —
драйвер из `Config::get('auth', 'driver')` (по умолчанию `'file'`).
- `AuthDriver::login($login, $password)` — проверяет и, если верно, авторизует (пишет в `Session`).
- `AuthDriver::logout()` / `loggedIn()` / `getUser()` (без пароля) / `checkPassword($password)`
(сверка пароля с текущим авторизованным пользователем, например перед сменой настроек).
**Файл пользователей** — `App/config/auth_users.php` (в `.gitignore`, как `config.local.php`):
```php
return [
'admin' => [
'password' => '<password_hash(...)>',
'full_name' => 'ФИО',
'email' => '...',
'role' => 'admin', // 'admin' | 'manager' | 'user' | ... — своё для каждого проекта
],
];
```
Путь берётся из `Config::get('auth', 'users_file')`. Поля, кроме `password`, произвольные —
`AuthDriver::getUser()` отдаёт их как есть (без `password`); `role` — единственное поле, которое
понимает framework-код (`Controller::$_auth_roles`), остальное (`full_name`, `email`, …) — просто
проброс для шаблонов/логов.
**Контроллеры** включают проверку флагом `$_auth_protection = true` (по умолчанию `false`,
как `$_csrf_protection`, но с обратной полярностью — авторизация не обязательна по умолчанию).
`$_auth_driver` — имя конкретного драйвера, если контроллеру нужен не дефолтный (`null` = дефолт
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`:
неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`,
поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама
делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`.
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
для всей админки.
**Вход/выход** — `App/Controller/LoginController.php` (`/login` — форма и обработка,
`/login/logout`), шаблон `App/view/Login/login.html`. Меню сайта (`Controller::menu()`) показывает
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
только при `role === 'admin'`.
### Log (System\Classes\Log) ### Log (System\Classes\Log)
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой: Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
@ -262,7 +312,8 @@ Log::debug(...); Log::warning(...);
- Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`. - Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`.
- Каталог — `Log::$directory` → `Config::get('log','path')` → `APPPATH/logs`. Создаётся на лету. - Каталог — `Log::$directory` → `Config::get('log','path')` → `APPPATH/logs`. Создаётся на лету.
- `Log::requestInfo()` — строка контекста текущего запроса (`METHOD /uri Controller::action - `Log::requestInfo()` — строка контекста текущего запроса (`METHOD /uri Controller::action
params=… query=… post=…`) из `Request::$current`; чувствительные ключи (`Log::$mask_keys`: user=login params=… query=… post=…`) из `Request::$current`; `user` — логин из `Auth::provider()->user()`
(`'guest'`, если не авторизован); чувствительные ключи (`Log::$mask_keys`:
`password`, `pass`, `csrf_token`, `token`) маскируются `***`. `password`, `pass`, `csrf_token`, `token`) маскируются `***`.
**Интеграция (автоматически):** **Интеграция (автоматически):**
@ -307,8 +358,7 @@ $response = HTTPException::factory(403)->getResponse();
### View path convention ### View path convention
- Шаблон контента: `App/view/{Controller}/{template}.html` - Шаблон контента: `App/view/{Controller}/{template}.html`
- Layout: `App/view/views/{layout}.html` (app-специфичные шаблоны — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`) - Layout: `App/view/{layout}.html` — единый шаблон на весь сайт (app-специфичный — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`)
- Общие CSS/JS-подключения (Bootstrap, Bootstrap Icons, jQuery) вынесены в партиалы `App/view/views/_styles.html` и `_scripts.html` — layout'ы (`layout.html`, `admin.html`) инклудят их через `(new View('_styles', 'view/views'))->render()`, чтобы не дублировать теги при появлении новых layout'ов
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html` - `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
- `$_paths` инициализируется лениво при первом вызове `findFile()` - `$_paths` инициализируется лениво при первом вызове `findFile()`
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
@ -318,13 +368,13 @@ $response = HTTPException::factory(403)->getResponse();
Расстановка имён — как в проекте eoffice_v3: Расстановка имён — как в проекте eoffice_v3:
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API. - `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout по умолчанию — `App/view/views/layout.html` (Bootstrap-навбар сверху, `$content` внутри `<main>`). **Контроллеры приложения наследуют его.** - `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout — `App/view/layout.html` (Bootstrap-навбар сверху с меню сайта, `$content` внутри `<main>`; CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`). Единый для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.**
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт свой layout `admin` (боковое меню, `App/view/views/admin.html`, Bootstrap из `/vendor/`) и переопределяет `layoutData()`/`menu()` под собственный список пунктов (пока статичный, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`. **Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout/меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт (в меню есть пункт «Админка»). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`.
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет). `Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.
### Environment ### Environment
@ -361,8 +411,10 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки | | `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` | | `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` | | `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
**162 теста, 270 assertion — все проходят.** **177 тестов, 290 assertion — все проходят.**
### Frontend dependencies (через Composer) ### Frontend dependencies (через Composer)

62
Services/Auth.php Normal file
View File

@ -0,0 +1,62 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Auth.php
* @copyright (c) 06/08/2026
*/
namespace Services;
use Services\Auth\AuthDriver;
use Services\Auth\FileAuthDriver;
use System\Classes\Config;
use System\Classes\MyException;
/**
* Точка входа в авторизацию. Драйвер по умолчанию — Config::get('auth', 'driver')
* (по умолчанию 'file'); конкретный контроллер может запросить другой явно
* (Auth::instance('ldap') и т.п., когда такой драйвер появится) — без
* изменения остального кода.
*
* Auth::instance()->login($login, $password);
* Auth::instance()->loggedIn();
* Auth::instance()->getUser();
* Auth::instance()->logout();
*/
class Auth
{
/** @var array<string,class-string<AuthDriver>> Реализации по имени драйвера */
protected static array $_drivers = [
'file' => FileAuthDriver::class,
// 'db' => \Services\Auth\DbAuthDriver::class, // позже
// 'ldap' => \Services\Auth\LdapAuthDriver::class, // позже
// 'keycloak' => \Services\Auth\KeycloakAuthDriver::class, // позже
];
/** @var array<string,AuthDriver> Кэш созданных драйверов по имени */
protected static array $_instances = [];
/**
* Возвращает драйвер авторизации по имени (кэшируется).
*
* @param string|null $name Имя драйвера; null — значение по умолчанию из конфига
* @return AuthDriver
* @throws MyException Если драйвер не зарегистрирован
*/
public static function instance(?string $name = null): AuthDriver
{
$name ??= Config::get('auth', 'driver') ?? 'file';
if (!isset(self::$_instances[$name])) {
if (!isset(self::$_drivers[$name])) {
throw new MyException('Неизвестный драйвер авторизации: :name', [':name' => $name]);
}
$class = self::$_drivers[$name];
self::$_instances[$name] = new $class();
}
return self::$_instances[$name];
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description AuthDriver.php
* @copyright (c) 06/08/2026
*/
namespace Services\Auth;
/**
* Общий интерфейс драйвера авторизации. Реализации: {@see FileAuthDriver}
* (по умолчанию), позже — БД/LDAP/Keycloak — без изменения кода, который
* работает через этот интерфейс.
*/
interface AuthDriver
{
/**
* Проверяет логин/пароль и, если верны, авторизует пользователя
* (сохраняет состояние в сессии).
*
* @param string $login
* @param string $password
* @return bool
*/
public function login(string $login, string $password): bool;
/**
* Разлогинивает пользователя (очищает состояние сессии).
*
* @return void
*/
public function logout(): void;
/**
* Авторизован ли пользователь в текущей сессии.
*
* @return bool
*/
public function loggedIn(): bool;
/**
* Данные текущего авторизованного пользователя.
*
* @return array|null null, если не авторизован
*/
public function getUser(): ?array;
/**
* Проверяет пароль против сохранённого хэша текущего авторизованного
* пользователя (например, для подтверждения перед сменой настроек).
*
* @param string $password
* @return bool false, если пользователь не авторизован или пароль не совпал
*/
public function checkPassword(string $password): bool;
}

View File

@ -0,0 +1,133 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description FileAuthDriver.php
* @copyright (c) 06/08/2026
*/
namespace Services\Auth;
use System\Classes\Config;
use System\Classes\Session;
/**
* Авторизация по списку пользователей из PHP-файла:
* return ['login' => ['password' => '<password_hash>', ...доп. поля], ...];
*
* Путь к файлу — Config::get('auth', 'users_file').
* Состояние авторизации хранится в сессии (логин текущего пользователя),
* ключ сессии — Config::get('auth', 'session_key') (по умолчанию 'auth_user').
*/
class FileAuthDriver implements AuthDriver
{
/** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */
protected ?string $_users_file;
/** @var string Ключ сессии, под которым хранится логин авторизованного пользователя */
protected string $_session_key;
/**
* @param string|null $users_file Путь к файлу пользователей (для тестов/переопределения)
* @param string|null $session_key Ключ сессии (для тестов/переопределения)
*/
public function __construct(?string $users_file = null, ?string $session_key = null)
{
$this->_users_file = $users_file ?? Config::get('auth', 'users_file') ?? null;
$this->_session_key = $session_key ?? Config::get('auth', 'session_key') ?? 'auth_user';
}
/**
* Загружает список пользователей из файла (с полем 'password').
*
* @return array<string,array>
*/
protected function users(): array
{
if ($this->_users_file === null || !file_exists($this->_users_file)) {
return [];
}
return (array)(include $this->_users_file);
}
/**
* Логин текущего авторизованного пользователя из сессии.
*
* @return string|null
*/
protected function currentLogin(): ?string
{
return Session::instance()->get($this->_session_key);
}
/**
* @inheritDoc
*/
public function login(string $login, string $password): bool
{
$user = $this->users()[$login] ?? null;
if ($user === null || !password_verify($password, $user['password'] ?? '')) {
return false;
}
Session::instance()->set($this->_session_key, $login);
return true;
}
/**
* @inheritDoc
*/
public function logout(): void
{
Session::instance()->delete($this->_session_key);
}
/**
* @inheritDoc
*/
public function loggedIn(): bool
{
return $this->getUser() !== null;
}
/**
* @inheritDoc
*/
public function getUser(): ?array
{
$login = $this->currentLogin();
if ($login === null) {
return null;
}
$user = $this->users()[$login] ?? null;
if ($user === null) {
return null;
}
unset($user['password']);
return ['login' => $login] + $user;
}
/**
* @inheritDoc
*/
public function checkPassword(string $password): bool
{
$login = $this->currentLogin();
if ($login === null) {
return false;
}
$hash = $this->users()[$login]['password'] ?? null;
return $hash !== null && password_verify($password, $hash);
}
}

View File

@ -8,6 +8,7 @@
namespace System\Classes; namespace System\Classes;
use Services\Auth;
use System\Classes\HTTP\HTTPException; use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest; use System\Classes\HTTP\Request as HTTPRequest;
@ -23,25 +24,48 @@ class Controller extends BaseController
/** @var bool Проверять ли CSRF-токен на небезопасных методах */ /** @var bool Проверять ли CSRF-токен на небезопасных методах */
protected bool $_csrf_protection = true; protected bool $_csrf_protection = true;
/** @var bool Требовать ли авторизацию (см. {@see Auth}) для всех экшенов контроллера */
protected bool $_auth_protection = false;
/** @var string|null Имя драйвера авторизации; null — значение по умолчанию из конфига */
protected ?string $_auth_driver = null;
/** @var array<int,string> Роли, которым разрешён доступ; пустой массив — любой авторизованный */
protected array $_auth_roles = [];
/** /**
* Проверяет CSRF-токен на POST/PUT/PATCH/DELETE. * Проверяет CSRF-токен на POST/PUT/PATCH/DELETE и, если включено,
* Отключается флагом $_csrf_protection (например, для API/вебхуков). * авторизацию + роль (редирект на /login при отсутствии авторизации,
* 403 — при недостаточной роли).
* *
* @return void * @return void
* @throws HTTPException 403, если токен не прошёл * @throws HTTPException 403, если CSRF-токен не прошёл или роль не подходит
*/ */
protected function before(): void protected function before(): void
{ {
if (!$this->_csrf_protection) { if ($this->_csrf_protection) {
return; $request = Request::$current;
$method = $request?->method() ?? HTTPRequest::GET;
$unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE];
if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) {
throw HTTPException::factory(403);
}
} }
$request = Request::$current; if ($this->_auth_protection) {
$method = $request?->method() ?? HTTPRequest::GET; $user = Auth::instance($this->_auth_driver)->getUser();
$unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE];
if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) { if ($user === null) {
throw HTTPException::factory(403); // getResponse() 302 сам делает Location + exit — throw здесь не подходит:
// неперехваченные исключения уходят в MyException::handler(), который
// getResponse() не вызывает и настоящий редирект не отправит.
HTTPException::factory(302, '/login')->getResponse();
}
if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) {
throw HTTPException::factory(403);
}
} }
} }
@ -88,12 +112,20 @@ class Controller extends BaseController
protected function menu(): array protected function menu(): array
{ {
$uri = Request::$current?->uri() ?? ''; $uri = Request::$current?->uri() ?? '';
$user = Auth::instance()->getUser();
$items = [ $items = [
['title' => 'Главная', 'url' => '/'], ['title' => 'Главная', 'url' => '/'],
['title' => 'Обратная связь', 'url' => '/feedback'], ['title' => 'Обратная связь', 'url' => '/feedback'],
['title' => 'Админка', 'url' => '/admin/logs'],
]; ];
if (($user['role'] ?? null) === 'admin') {
$items[] = ['title' => 'Админка', 'url' => '/admin/logs'];
}
$items[] = $user
? ['title' => 'Выйти (' . $user['login'] . ')', 'url' => '/login/logout']
: ['title' => 'Войти', 'url' => '/login'];
foreach ($items as &$item) { foreach ($items as &$item) {
$item['active'] = trim($item['url'], '/') === $uri; $item['active'] = trim($item['url'], '/') === $uri;
} }

View File

@ -8,6 +8,8 @@
namespace System\Classes; namespace System\Classes;
use Services\Auth;
/** /**
* Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню. * Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню.
* Используется для аудита запросов (info) и логирования ошибок (error). * Используется для аудита запросов (info) и логирования ошибок (error).
@ -132,11 +134,12 @@ class Log
$params = json_encode($request->params(), $flags); $params = json_encode($request->params(), $flags);
$query = json_encode(self::mask($request->query()), $flags); $query = json_encode(self::mask($request->query()), $flags);
$post = json_encode(self::mask($request->post()), $flags); $post = json_encode(self::mask($request->post()), $flags);
$user = Auth::instance()->getUser()['login'] ?? 'guest';
return sprintf( return sprintf(
'%s /%s %s::%s params=%s query=%s post=%s', '%s /%s %s::%s user=%s params=%s query=%s post=%s',
$request->method(), $request->uri(), $request->controller(), $request->action(), $request->method(), $request->uri(), $request->controller(), $request->action(),
$params, $query, $post $user, $params, $query, $post
); );
} }

42
tests/Unit/AuthTest.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Auth;
use Services\Auth\FileAuthDriver;
use System\Classes\MyException;
class AuthTest extends TestCase
{
protected function tearDown(): void
{
// Сбрасываем кэш инстансов драйверов, чтобы тесты не влияли друг на друга.
$ref = new ReflectionClass(Auth::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testDefaultDriverIsFile(): void
{
$this->assertInstanceOf(FileAuthDriver::class, Auth::instance());
}
public function testExplicitFileDriver(): void
{
$this->assertInstanceOf(FileAuthDriver::class, Auth::instance('file'));
}
public function testInstanceIsCachedPerDriver(): void
{
$this->assertSame(Auth::instance('file'), Auth::instance('file'));
}
public function testUnknownDriverThrows(): void
{
$this->expectException(MyException::class);
Auth::instance('ldap');
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Auth\FileAuthDriver;
use System\Classes\Session;
class FileAuthDriverTest extends TestCase
{
private string $file;
protected function setUp(): void
{
$this->file = sys_get_temp_dir() . '/bicycle_auth_users_' . uniqid() . '.php';
file_put_contents($this->file, '<?php return ' . var_export([
'bob' => [
'password' => password_hash('secret', PASSWORD_DEFAULT),
'full_name' => 'Bob Smith',
'email' => 'bob@example.com',
'role' => 'manager',
],
], true) . ';');
}
protected function tearDown(): void
{
unlink($this->file);
Session::instance()->destroy();
$ref = new ReflectionClass(Session::class);
$prop = $ref->getProperty('instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testLoginWithCorrectCredentialsSucceeds(): void
{
$this->assertTrue((new FileAuthDriver($this->file))->login('bob', 'secret'));
}
public function testLoginWithWrongPasswordFails(): void
{
$this->assertFalse((new FileAuthDriver($this->file))->login('bob', 'wrong'));
}
public function testLoginWithUnknownLoginFails(): void
{
$this->assertFalse((new FileAuthDriver($this->file))->login('nobody', 'secret'));
}
public function testLoggedInIsFalseBeforeLogin(): void
{
$this->assertFalse((new FileAuthDriver($this->file))->loggedIn());
}
public function testLoggedInIsTrueAfterSuccessfulLogin(): void
{
$driver = new FileAuthDriver($this->file);
$driver->login('bob', 'secret');
$this->assertTrue($driver->loggedIn());
}
public function testGetUserReturnsDataWithoutPassword(): void
{
$driver = new FileAuthDriver($this->file);
$driver->login('bob', 'secret');
$user = $driver->getUser();
$this->assertSame('bob', $user['login']);
$this->assertSame('Bob Smith', $user['full_name']);
$this->assertSame('bob@example.com', $user['email']);
$this->assertSame('manager', $user['role']);
$this->assertArrayNotHasKey('password', $user);
}
public function testGetUserIsNullWhenNotAuthorized(): void
{
$this->assertNull((new FileAuthDriver($this->file))->getUser());
}
public function testLogoutClearsAuthorization(): void
{
$driver = new FileAuthDriver($this->file);
$driver->login('bob', 'secret');
$driver->logout();
$this->assertFalse($driver->loggedIn());
}
public function testCheckPasswordAgainstLoggedInUser(): void
{
$driver = new FileAuthDriver($this->file);
$driver->login('bob', 'secret');
$this->assertTrue($driver->checkPassword('secret'));
$this->assertFalse($driver->checkPassword('wrong'));
}
public function testCheckPasswordIsFalseWhenNotAuthorized(): void
{
$this->assertFalse((new FileAuthDriver($this->file))->checkPassword('secret'));
}
public function testMissingUsersFileBehavesAsEmptyList(): void
{
$driver = new FileAuthDriver(sys_get_temp_dir() . '/does_not_exist_' . uniqid() . '.php');
$this->assertFalse($driver->login('bob', 'secret'));
}
}