Autenticação
Uma API pública de leitura pode atender qualquer um, mas criar, alterar e apagar dados exige saber quem está pedindo. Numa API, a forma mais comum de fazer isso é um token enviado em cada requisição, no cabeçalho Authorization: Bearer <token>. Este capítulo guarda tokens no banco, usa o middleware de autenticação do yiisoft/auth para conferi-los e protege as rotas de escrita das tarefas.
Para login com sessão e senha, em páginas do site, veja o capítulo Autenticação da seção de segurança.
O pacote yiisoft/auth
composer require yiisoft/authO pacote separa o problema em três partes:
- O middleware
Yiisoft\Auth\Middleware\Authentication, que roda antes da action, tenta identificar o cliente e responde401se não conseguir. - O método de autenticação, que diz onde procurar a credencial. O
HttpBearerlê o cabeçalhoAuthorization: Bearer. Há tambémHttpHeader(um cabeçalho próprio, por padrãoX-Api-Key),QueryParameter(na query string, por padrãoaccess-token),HttpBasic,HttpCookieeComposite, que tenta vários em sequência. - Um repositório de identidades, que você escreve. Para tokens, ele implementa
IdentityWithTokenRepositoryInterface, com um único método:findIdentityByToken(string $token, ?string $type = null). Ele devolve um objeto que implementaIdentityInterface, ounullse o token não vale.
A tabela de tokens
Crie a migration src/Migration/M261008051100CreateApiTokenTable.php:
<?php
declare(strict_types=1);
namespace App\Migration;
use Yiisoft\Db\Migration\MigrationBuilder;
use Yiisoft\Db\Migration\RevertibleMigrationInterface;
use Yiisoft\Db\Migration\TransactionalMigrationInterface;
/**
* Cria a tabela `api_token`, com dois tokens de exemplo.
*
* O banco guarda só o hash SHA-256 de cada token.
*/
final class M261008051100CreateApiTokenTable implements RevertibleMigrationInterface, TransactionalMigrationInterface
{
public function up(MigrationBuilder $b): void
{
$columnBuilder = $b->columnBuilder();
$b->createTable('api_token', [
'id' => $columnBuilder::primaryKey(),
'cliente' => $columnBuilder::string(100)->notNull(),
'token_hash' => $columnBuilder::char(64)->notNull()->unique(),
'expira_em' => $columnBuilder::datetime()->null(),
]);
$b->insertBatch('api_token', [
['Cliente de exemplo', hash('sha256', 'token-de-exemplo-nao-use-em-producao'), null],
['Cliente antigo', hash('sha256', 'token-de-exemplo-expirado'), '2026-01-01 00:00:00'],
], ['cliente', 'token_hash', 'expira_em']);
}
public function down(MigrationBuilder $b): void
{
$b->dropTable('api_token');
}
}O banco não guarda o token, só o hash SHA-256 dele. Se alguém ler a tabela (um backup vazado, uma consulta indevida), não consegue usar os tokens. A ideia é a mesma de guardar o hash de uma senha, mas aqui um hash rápido como o SHA-256 basta: o token é longo e aleatório, então não dá para descobri-lo por tentativa. Isso também permite buscar pelo hash com um WHERE simples, o que não seria possível com o password_hash().
Os dois tokens de exemplo servem para os testes: um vale para sempre, o outro já expirou. Os valores estão no código de propósito; num sistema real, nenhum token fica escrito no repositório.
A identidade e o repositório
A identidade é o cliente dono do token. Crie src/Api/Token/ClienteDaApi.php:
<?php
declare(strict_types=1);
namespace App\Api\Token;
use Yiisoft\Auth\IdentityInterface;
/**
* O cliente identificado pelo token enviado na requisição.
*/
final readonly class ClienteDaApi implements IdentityInterface
{
public function __construct(
public int $id,
public string $nome,
) {}
public function getId(): string
{
return (string) $this->id;
}
}E o repositório, em src/Api/Token/TokenRepository.php:
<?php
declare(strict_types=1);
namespace App\Api\Token;
use DateTimeImmutable;
use Yiisoft\Auth\IdentityWithTokenRepositoryInterface;
use Yiisoft\Db\Connection\ConnectionInterface;
use Yiisoft\Security\Random;
final readonly class TokenRepository implements IdentityWithTokenRepositoryInterface
{
private const TABELA = 'api_token';
public function __construct(
private ConnectionInterface $db,
) {}
public function findIdentityByToken(string $token, ?string $type = null): ?ClienteDaApi
{
$linha = $this->db->select(['id', 'cliente'])
->from(self::TABELA)
->where(['token_hash' => hash('sha256', $token)])
->andWhere([
'or',
['expira_em' => null],
['>', 'expira_em', (new DateTimeImmutable())->format('Y-m-d H:i:s')],
])
->one();
return $linha === null ? null : new ClienteDaApi((int) $linha['id'], $linha['cliente']);
}
/**
* Cria um token para o cliente e devolve o valor em texto puro.
*
* O valor não fica salvo em lugar nenhum: mostre-o uma única vez a quem pediu.
*/
public function emitir(string $cliente, ?DateTimeImmutable $expiraEm = null): string
{
$token = Random::string(40);
$this->db->createCommand()->insert(self::TABELA, [
'cliente' => $cliente,
'token_hash' => hash('sha256', $token),
'expira_em' => $expiraEm?->format('Y-m-d H:i:s'),
])->execute();
return $token;
}
}O findIdentityByToken() calcula o hash do token recebido e procura uma linha com esse hash que não tenha expirado. O parâmetro $type serve para quem tem mais de um tipo de token (de acesso, de atualização) na mesma tabela; aqui não é usado.
O emitir() gera 40 caracteres aleatórios com o Random do yiisoft/security, grava o hash e devolve o token. É assim que um token novo seria criado, numa tela do painel ou num comando de console:
$token = $tokenRepository->emitir('App do celular', new DateTimeImmutable('+30 days'));O valor devolvido aparece uma vez para quem pediu e não pode ser recuperado depois. Para revogar um token, basta apagar a linha.
A resposta de falha
Sem configuração, o Authentication responde 401 com o texto "Your request was made with invalid credentials.". Para manter o padrão de erros da API, passamos um handler próprio, em src/Web/Api/Shared/FalhaDeAutenticacao.php:
<?php
declare(strict_types=1);
namespace App\Web\Api\Shared;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\Http\Status;
/**
* Resposta do middleware de autenticação quando não há token válido.
*/
final readonly class FalhaDeAutenticacao implements RequestHandlerInterface
{
public function __construct(
private Problemas $problemas,
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->problemas->criar(
Status::UNAUTHORIZED,
'Envie um token válido no cabeçalho Authorization: Bearer <token>.',
);
}
}O Problemas é a classe de respostas de erro do capítulo Erros.
Protegendo as rotas
No arquivo de rotas, as rotas de escrita das tarefas vão para um grupo sem prefixo dentro de /v1, com o middleware de autenticação:
use App\Api\Token\TokenRepository;
use Psr\Http\Message\ResponseFactoryInterface;
use Yiisoft\Auth\Method\HttpBearer;
use Yiisoft\Auth\Middleware\Authentication;
Group::create('/v1')
->namePrefix('v1/')
->routes(
// ... openapi.json e as duas rotas de leitura
Group::create()
->middleware(
static fn (
TokenRepository $tokens,
ResponseFactoryInterface $responseFactory,
Api\Shared\FalhaDeAutenticacao $falha,
) => new Authentication(new HttpBearer($tokens), $responseFactory, $falha),
)
->routes(
Route::get('/eu')
->action(Api\V1\Eu\Action::class)
->name('eu'),
Route::post('/tarefas')
->action([Api\V1\Tarefa\TarefaController::class, 'create'])
->name('tarefas/create'),
Route::put('/tarefas/{id:\d+}')
->action([Api\V1\Tarefa\TarefaController::class, 'update'])
->name('tarefas/update'),
Route::delete('/tarefas/{id:\d+}')
->action([Api\V1\Tarefa\TarefaController::class, 'delete'])
->name('tarefas/delete'),
),
),Por que uma função, e não só Authentication::class? O construtor do Authentication recebe o método de autenticação tipado como uma união de interfaces, e o container não tem como adivinhar qual implementação usar. O roteador aceita como middleware uma função que devolve um middleware: os parâmetros da função são resolvidos pelo container, e o middleware devolvido é executado em seguida. Outra saída seria registrar o Authentication no container, com uma definição em config/web/di/; a função na rota deixa a configuração perto das rotas que ela protege.
As rotas de leitura continuam públicas. Se a API inteira exigir token, o middleware vai direto no grupo /v1.
Quem está chamando
Quando o token vale, o Authentication guarda a identidade num atributo da requisição, com o nome da própria classe. A rota /eu mostra isso. Crie src/Web/Api/V1/Eu/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Api\V1\Eu;
use App\Api\Token\ClienteDaApi;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Auth\Middleware\Authentication;
use Yiisoft\DataResponse\ResponseFactory\JsonResponseFactory;
final readonly class Action
{
public function __construct(
private JsonResponseFactory $responseFactory,
) {}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
/** @var ClienteDaApi $cliente */
$cliente = $request->getAttribute(Authentication::class);
return $this->responseFactory->createResponse([
'id' => $cliente->id,
'cliente' => $cliente->nome,
]);
}
}Qualquer action protegida pode ler o cliente do mesmo jeito, por exemplo para registrar quem criou cada tarefa.
Testando
Sem token:
curl -i http://127.0.0.1:8080/api/v1/euHTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=UTF-8
WWW-Authenticate: Bearer realm="api"
Content-Length: 137
{"type":"about:blank","title":"Unauthorized","status":401,"detail":"Envie um token válido no cabeçalho Authorization: Bearer <token>."}O cabeçalho WWW-Authenticate foi acrescentado pelo HttpBearer: é o jeito do HTTP dizer ao cliente qual esquema de autenticação usar. O nome do realm muda com withRealm().
Com o token de exemplo:
curl -H "Authorization: Bearer token-de-exemplo-nao-use-em-producao" http://127.0.0.1:8080/api/v1/eu{"id":1,"cliente":"Cliente de exemplo"}Com o token expirado (token-de-exemplo-expirado), a resposta é o mesmo 401 da primeira requisição. O cliente não fica sabendo se o token não existe ou se expirou, o que não ajuda quem está tentando adivinhar tokens.
As rotas de escrita agora pedem token:
curl -i -X POST -H "Content-Type: application/json" -d '{"titulo":"Sem token"}' http://127.0.0.1:8080/api/v1/tarefasHTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=UTF-8
WWW-Authenticate: Bearer realm="api"curl -i -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer token-de-exemplo-nao-use-em-producao" \
-d '{"titulo":"Ler a RFC 9457"}' http://127.0.0.1:8080/api/v1/tarefasHTTP/1.1 201 Created
Content-Type: application/json; charset=UTF-8
Location: /api/v1/tarefas/10
Content-Length: 92
{"id":10,"titulo":"Ler a RFC 9457","concluida":false,"criadaEm":"2026-10-08T02:11:32-03:00"}As leituras continuam abertas: curl http://127.0.0.1:8080/api/v1/tarefas funciona sem token.
Cuidados
- Use HTTPS. O token vai em texto puro no cabeçalho; sem criptografia no caminho, qualquer um entre o cliente e o servidor pode copiá-lo.
- Dê prazo de validade aos tokens e permita revogá-los. Um token sem prazo que vaza vale para sempre.
- Não grave o cabeçalho
Authorizationem logs. - Evite o
QueryParameterquando puder: a URL, com o token dentro, acaba em logs de servidor, histórico do navegador e cabeçalhosReferer. - Para limitar quantas requisições cada cliente faz, o pacote
yiisoft/rate-limitertem o middlewareLimitRequestsMiddleware.
Comparando com o Yii2
No Yii2, a autenticação de API era um filtro, como yii\filters\auth\HttpBearerAuth, configurado em behaviors() do controller. A identidade vinha do método estático findIdentityByAccessToken() da classe de usuário, e o resultado ficava em Yii::$app->user. No Yii3:
- A autenticação é o middleware
Authentication, aplicado a rotas ou grupos. - A busca pelo token fica num repositório que implementa
IdentityWithTokenRepositoryInterface, separado da classe de identidade. - A identidade chega à action como atributo da requisição.