dev
This commit is contained in:
parent
858e574e60
commit
bbeb36b2e8
1
.gitignore
vendored
1
.gitignore
vendored
@ -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/
|
||||||
|
|
||||||
|
|||||||
@ -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: проверка прав администратора, когда появится логин.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
App/Controller/LoginController.php
Normal file
62
App/Controller/LoginController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
34
App/view/Login/login.html
Normal 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>
|
||||||
68
CLAUDE.md
68
CLAUDE.md
@ -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
62
Services/Auth.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Services/Auth/AuthDriver.php
Normal file
57
Services/Auth/AuthDriver.php
Normal 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;
|
||||||
|
}
|
||||||
133
Services/Auth/FileAuthDriver.php
Normal file
133
Services/Auth/FileAuthDriver.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
42
tests/Unit/AuthTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
114
tests/Unit/FileAuthDriverTest.php
Normal file
114
tests/Unit/FileAuthDriverTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user