Аутентификация и ограничение частоты¶
Пространства имён Keel\Auth и Keel\RateLimit. Оба работают через атрибуты маршрута, которые
сами себя применяют: #[Auth] добавляет к маршруту свой guard-middleware, #[RateLimit] тоже,
так что маршрут не может быть защищён на бумаге и открыт в рантайме. Генератор OpenAPI читает те же
атрибуты.
Principal¶
Тот, от чьего имени аутентифицирован запрос, реализует Principal (id(), roles(),
hasRole()). Identity — простая реализация: id, роли и произвольные атрибуты. Хэндлеры получают
principal по типу, как и сессию:
#[Get('/me')]
#[Auth]
public function me(Principal $user): array { return ['id' => $user->id()]; }
#[Get('/feed')]
public function feed(?Principal $user = null): array { /* персонализация, если вошёл */ }
Аутентификаторы¶
Authenticator распознаёт одну схему учётных данных: возвращает null, если в запросе нет
учётных данных его вида, Principal, если они верны, и бросает UnauthorizedException, если они
есть, но неверны (плохой токен никогда не считается анонимом). Встроенные:
| Аутентификатор | Читает | Требует |
|---|---|---|
BearerTokenAuthenticator |
Authorization: Bearer <token> |
PrincipalResolver |
ApiKeyAuthenticator |
X-Api-Key: <key> (заголовок настраивается) |
PrincipalResolver |
HmacAuthenticator |
Authorization: HMAC <keyId>:<timestamp>:<signature> |
HmacSecrets |
Jwt\JwtAuthenticator |
Authorization: Bearer <JWT> |
JwtConfig: секрет, публичные ключи или JWKS URL |
PrincipalResolver::resolve(string $credential): ?Principal и
HmacSecrets::secretFor(string $keyId): ?string реализуете вы (база, кэш, статическая карта);
регистрируйте их под интерфейсом через #[Alias] или в провайдере:
#[Alias(PrincipalResolver::class)]
final class ApiTokens implements PrincipalResolver
{
public function __construct(private readonly TokenRepository $tokens) {}
public function resolve(string $credential): ?Principal
{
$row = $this->tokens->find(hash('sha256', $credential));
return $row === null || $row->expired() ? null : new Identity($row->userId, $row->roles);
}
}
config/auth.php перечисляет аутентификаторы по порядку:
return new AuthConfig([BearerTokenAuthenticator::class, HmacAuthenticator::class]);
Каждый — сервис контейнера, конструктор автовайрится; чтобы поменять realm, имя заголовка или допуск HMAC, опишите сервис с аргументами в провайдере.
Подпись запросов HMAC¶
Для машинных клиентов, которым не стоит держать bearer-токен. Клиент подписывает строку
"METHOD\n/path?query\n<unix timestamp>\n" . sha256(body) алгоритмом HMAC-SHA256 и шлёт
Authorization: HMAC <keyId>:<timestamp>:<hex-подпись>. Сервер принимает метки времени в пределах
$tolerance секунд (по умолчанию 300), что ограничивает повтор; повтор с другим телом, путём или
методом не пройдёт, потому что они входят в подписываемую строку. Заголовок собирает
HmacAuthenticator::sign($keyId, $secret, $method, $target, $body, time()); principal в
результате — Identity с id, равным key id. sign() отвергает key id, который не поместится в
заголовок (пустой, длиннее 128 байт, с двоеточием или пробелом).
Идентичный запрос, повторённый в пределах допуска, принимается, если у аутентификатора нет
CounterStorage (четвёртый аргумент конструктора; автовайрится, как только хранилище
зарегистрировано — то есть сразу после настройки ограничения частоты): тогда каждая подпись
принимается один раз, а повтор получает 401 «Replayed signature». Клиент, повторяющий запрос
после потерянного ответа, должен подписать его заново со свежей меткой времени.
Неверная подпись и неизвестный key id получают один и тот же ответ «Invalid signature» после
одинакового объёма работы, так что перебрать key id нельзя. Host и Content-Type в
подписываемую строку не входят: подпись действительна на любом хосте с тем же секретом, поэтому
никогда не используйте один HMAC-секрет в разных окружениях.
JWT¶
JwtAuthenticator проверяет JSON Web Token без сторонних библиотек: HS256/384/512,
RS256/384/512, ES256/384/512 и EdDSA (Ed25519); RSA-PSS не поддерживается.
// config/jwt.php: токены провайдера (Auth0, Keycloak, Cognito, Entra ID, ...)
return static fn(Env $env): JwtConfig => new JwtConfig(
algorithms: ['RS256'],
jwksUrl: 'https://login.example.com/.well-known/jwks.json',
issuer: 'https://login.example.com/',
audience: 'orders-api',
rolesClaim: 'realm_access.roles',
);
// токены, которые вы выпускаете сами
return static fn(Env $env): JwtConfig => new JwtConfig(algorithms: ['HS256'], secret: $env->require('JWT_SECRET'), issuer: 'orders-api');
$token = Jwt::encode(['sub' => $user->id, 'roles' => $user->roles, 'exp' => time() + 900, 'iss' => 'orders-api'], $secret);
// config/auth.php
return new AuthConfig([JwtAuthenticator::class]);
Токен принимается, если его alg есть в algorithms, подпись сходится с ключом подходящего
типа (при kid — только с этим ключом; RSA-ключ никогда не проверяет HS256, что закрывает атаку
подмены алгоритма; none не принимается никогда), заголовка crit нет, exp в будущем, а
nbf/iat не слишком далеко впереди (с допуском leeway, по умолчанию 60 секунд), и iss,
aud и requiredClaims совпадают с конфигом. Токены без exp отклоняются, если не задано
requireExpiration: false. HMAC-секрет — не короче 32 байт, RSA-ключ — от 2048 бит.
Principal — Identity: id из subjectClaim (sub), роли из rolesClaim (путь через точку;
список или строка через пробел) — их проверяет #[Auth('admin')], и все claims как атрибуты,
плюс scopes списком из scope или scp: $user->get('scopes'), $user->get('tenant').
Плохой токен получает 401 с WWW-Authenticate: Bearer realm="api", error="invalid_token";
причина (истёк, не та аудитория, неизвестный key id) пишется в лог на уровне info, клиенту не
сообщается.
С jwksUrl ключи загружаются через PSR-18 клиент (подключите HttpClientProvider), хранятся в
воркере jwksTtl секунд (3600) и, если зарегистрирован PSR-16 кэш, делятся с остальными
воркерами. Токен с неизвестным kid вызывает повторную загрузку не чаще раза в минуту: ротация
ключей подхватывается, а поддельные key id не заваливают провайдера запросами. Если повторная
загрузка упала, используются уже известные ключи; без них запрос падает с 500, а не 401.
publicKeys (PEM по kid) и secret можно сочетать с jwksUrl.
$authenticator->verifier()->verify($token) проверяет токен вне запроса и возвращает claims.
keel:install jwt пишет config/jwt.php и config/auth.php.
Требование аутентификации¶
#[Auth] на методе или классе (все маршруты контроллера) требует principal; с ролями — любого,
у кого есть хотя бы одна:
#[Delete('/users/{id}')]
#[Auth('admin', 'support')]
public function delete(int $id): void { /* ... */ }
Атрибут добавляет к маршруту AuthGuard. Guard берёт principal, который оставил на запросе
AuthMiddleware, либо аутентифицирует сам, если того middleware нет в конвейере; без principal
отвечает 401 с заголовком WWW-Authenticate, перечисляющим challenge каждой схемы, с principal
без нужных ролей — 403. Оба ответа — Problem Details.
AuthMiddleware — необязательный глобальный middleware (HttpConfig): аутентифицирует каждый
запрос с учётными данными и прикрепляет principal, так что публичные маршруты могут
персонализировать ответ, а неверные учётные данные отклоняются везде, не только на защищённых
маршрутах.
Authentication — сервис за обоими: authenticate($request) прогоняет настроенные
аутентификаторы, principal($request) читает атрибут, unauthorized() строит 401.
Политики¶
Роли отвечают на «кто ты»; политики — на «можно ли тебе сделать это с тем». Политика — сервис с тегом, решающий именованные способности (abilities) для principal и объекта:
#[Tag(Policy::TAG)]
final class PostPolicy implements Policy
{
public function abilities(): array
{
return ['post.view', 'post.update', 'post.delete'];
}
public function allows(Principal $principal, string $ability, mixed $subject): bool
{
return match ($ability) {
'post.view' => true,
'post.update' => $subject instanceof Post && $subject->authorId === $principal->id(),
'post.delete' => $principal->hasRole('admin'),
default => false,
};
}
}
keel:make:policy Post post.update post.delete сгенерирует такую. Discovery регистрирует её по
тегу; Gate собирает все политики с тегом и спрашивает ту, что объявила способность:
allows($user, 'post.update', $post), denies(...), authorize(...) (бросает 403
ForbiddenException при отказе) и abilities(). Способность, которую не объявила ни одна
политика, — LogicException, то есть ошибка конфигурации, а не молчаливый отказ; две политики с
одной способностью отклоняются при сборке gate.
В хэндлере внедрите Gate и principal и проверьте, когда сущность загружена:
#[Patch('/posts/{id}')]
#[Auth]
public function update(int $id, Principal $user, Gate $gate, #[FromBody] UpdatePost $cmd): PostDto
{
$post = $this->posts->get($id);
$gate->authorize($user, 'post.update', $post);
// ...
}
Когда для решения хватает параметров маршрута, #[Can('post.update')] на методе или классе
добавляет PolicyGuard: он аутентифицирует как AuthGuard (401 без principal) и спрашивает gate
с параметрами маршрута в роли объекта (['id' => '42']), отвечая 403 при отказе. keel:routes
показывает способность в колонке Auth; документ OpenAPI получает security, 401 и 403, как при
ролях.
Ограничение частоты¶
#[RateLimit(limit, window, by)] разрешает limit запросов за window секунд (по умолчанию 60)
на IP клиента (by: 'ip', по умолчанию) или на аутентифицированного principal
(by: 'principal', для анонимных запросов — на IP):
#[Post('/search')]
#[RateLimit(30, 60)]
public function search(#[FromBody] Query $query): array { /* ... */ }
Атрибут добавляет RateLimitGuard: каждый ответ несёт RateLimit-Limit, RateLimit-Remaining
и RateLimit-Reset (секунд до конца окна; имена заголовков — из раннего черновика IETF, а не
новое единое структурированное поле RateLimit); по исчерпании guard отвечает 429 с
Retry-After. Окна фиксированные и выровнены по часам (RateLimiter), поэтому на границе окна
может пройти до двух лимитов подряд; IP клиента — тот, что установил TrustedProxiesMiddleware,
так что заголовки forwarded учитываются только от доверенных прокси.
Ключ счётчика — HTTP-метод, шаблон маршрута и субъект (GET|/search|ip:203.0.113.5,
GET|/mine|principal:u1): два метода на одном пути и IP, похожий на id principal, никогда не
делят один счётчик. При by: 'principal' guard сам аутентифицирует запрос, если principal ещё не
прикреплён (нет AuthMiddleware или #[Auth] объявлен после #[RateLimit]): запрос с учётными
данными всегда считается по principal, неверные данные получают 401, и только по-настоящему
анонимный запрос считается по IP.
Счётчики живут в CounterStorage: APCu, если расширение загружено (общий для всех воркеров
сервера), иначе память текущего процесса. RedisCounters выносит их в Redis на весь парк
(RedisConfig(use: ['counters']), см. Кэш); свою реализацию для чего-то
другого регистрируйте под CounterStorage; InMemoryCounters подходит для тестов. ApcuCounters при сбое закрывается:
если APCu не может сохранить счётчик (общая память исчерпана), лимит считается превышенным, а не
тихо отключается — подбирайте apc.shm_size под количество ключей.
Тестирование¶
TestClient шлёт заголовки как любой клиент: $client->get('/me', ['Authorization' => 'Bearer t'])
или $client->withHeader('X-Api-Key', 'k'). Для HMAC используйте HmacAuthenticator::sign().
В тестах с лимитами регистрируйте InMemoryCounters как CounterStorage.