Autenticação é o processo de verificar quem é o cliente. Em APIs REST stateless, não existe sessão no servidor — cada requisição deve provar sua identidade. JWT (JSON Web Token) é o padrão dominante para isso: um token autocontido que carrega claims (afirmações sobre o usuário), assinado criptograficamente para que o servidor possa verificar sua autenticidade sem consultar o banco de dados.
Um JWT tem três partes separadas por pontos: o header (algoritmo), o payload (dados) e a assinatura. O servidor assina o token com uma chave secreta na emissão; na verificação, recalcula a assinatura e compara — se bater, o token é válido. Se qualquer bit do payload for alterado, a assinatura não bate e o token é rejeitado.
Neste artigo implementamos JWT do zero para entender o mecanismo, depois usamos a biblioteca firebase/php-jwt para produção, construímos refresh tokens, e criamos um middleware de autenticação reutilizável.
Anatomia de um JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (base64url)
.
eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEiLCJpYXQiOjE3MDAwMDAwMDB9 ← Payload (base64url)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Assinatura (HMAC-SHA256)
<?php
declare(strict_types=1);
// ── Decodificando manualmente para entender a estrutura ───────────────
$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
. '.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEiLCJpYXQiOjE3MDAwMDAwMDB9'
. '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
[$headerB64, $payloadB64, $assinaturaB64] = explode('.', $token);
// base64url → base64 padrão → decodificar
function base64urlDecode(string $input): string
{
$b64 = strtr($input, '-_', '+/');
$b64 = str_pad($b64, strlen($b64) + (4 - strlen($b64) % 4) % 4, '=');
return base64_decode($b64);
}
$header = json_decode(base64urlDecode($headerB64), associative: true);
$payload = json_decode(base64urlDecode($payloadB64), associative: true);
// $header = ['alg' => 'HS256', 'typ' => 'JWT']
// $payload = ['sub' => '42', 'name' => 'Ana', 'iat' => 1700000000]
// ── Claims padrão do JWT ──────────────────────────────────────────────
// iss (issuer) — quem emitiu o token
// sub (subject) — a quem o token se refere (geralmente o ID do usuário)
// aud (audience) — para quem o token é destinado
// exp (expiration)— timestamp Unix de expiração — SEMPRE definir
// iat (issued at) — timestamp Unix de emissão
// jti (JWT ID) — ID único do token — útil para revogação
Implementação JWT do zero (para entender)
<?php
declare(strict_types=1);
class JwtManual
{
public static function assinar(array $payload, string $chave): string
{
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$headerB64 = self::base64urlEncode(json_encode($header));
$payloadB64 = self::base64urlEncode(json_encode($payload));
$mensagem = "{$headerB64}.{$payloadB64}";
$assinatura = hash_hmac('sha256', $mensagem, $chave, binary: true);
return "{$mensagem}." . self::base64urlEncode($assinatura);
}
public static function verificar(string $token, string $chave): array
{
$partes = explode('.', $token);
if (count($partes) !== 3) {
throw new \InvalidArgumentException('Token JWT malformado.');
}
[$headerB64, $payloadB64, $assinaturaB64] = $partes;
// Recalcula a assinatura esperada
$mensagem = "{$headerB64}.{$payloadB64}";
$assinaturaEsperada = hash_hmac('sha256', $mensagem, $chave, binary: true);
$assinaturaRecebida = self::base64urlDecode($assinaturaB64);
// Comparação segura — evita timing attacks
if (!hash_equals($assinaturaEsperada, $assinaturaRecebida)) {
throw new \RuntimeException('Assinatura JWT inválida.');
}
$payload = json_decode(self::base64urlDecode($payloadB64), associative: true);
// Verifica expiração
if (isset($payload['exp']) && $payload['exp'] < time()) {
throw new \RuntimeException('Token JWT expirado.');
}
return $payload;
}
private static function base64urlEncode(string $input): string
{
return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
}
private static function base64urlDecode(string $input): string
{
$b64 = strtr($input, '-_', '+/');
$b64 = str_pad($b64, strlen($b64) + (4 - strlen($b64) % 4) % 4, '=');
return base64_decode($b64);
}
}
// Teste
$payload = ['sub' => '42', 'nome' => 'Ana', 'exp' => time() + 3600, 'iat' => time()];
$token = JwtManual::assinar($payload, 'minha-chave-secreta');
$dados = JwtManual::verificar($token, 'minha-chave-secreta'); // retorna $payload
JWT com firebase/php-jwt (produção)
Para produção, usamos a biblioteca firebase/php-jwt que implementa todos os algoritmos e edge cases corretamente.
<?php
declare(strict_types=1);
// composer require firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
class ServicoJWT
{
// HS256 e RS256 têm a mesma força de assinatura; o que muda é quem consegue
// verificar. Com HS256 a mesma chave assina e confere, então todo serviço que
// precisa validar o token também consegue emitir um. Com RS256, a chave pública
// só verifica — e por isso ele é a escolha quando quem emite e quem consome
// são serviços diferentes. Numa aplicação única, como esta, HS256 basta.
private const ALGORITMO = 'HS256';
// Públicas porque o controller responde o TTL ao cliente: um número solto na
// resposta vira mentira no dia em que a constante mudar.
public const TTL_ACCESS = 900; // 15 minutos — token de acesso curto
public const TTL_REFRESH = 604800; // 7 dias — refresh token longo
public function __construct(
private readonly string $chaveSecreta, // mínimo 32 caracteres, variável de ambiente
private readonly string $issuer = 'meuapp.com.br',
) {}
public function emitirTokenDeAcesso(int $usuarioId, string $perfil, string $email): string
{
$agora = time();
$payload = [
'iss' => $this->issuer,
'sub' => (string) $usuarioId,
'iat' => $agora,
'exp' => $agora + self::TTL_ACCESS,
'perfil' => $perfil,
'email' => $email,
'tipo' => 'access',
];
return JWT::encode($payload, $this->chaveSecreta, self::ALGORITMO);
}
public function emitirRefreshToken(int $usuarioId): string
{
$agora = time();
$payload = [
'iss' => $this->issuer,
'sub' => (string) $usuarioId,
'iat' => $agora,
'exp' => $agora + self::TTL_REFRESH,
'jti' => bin2hex(random_bytes(16)), // ID único para revogação
'tipo' => 'refresh',
];
return JWT::encode($payload, $this->chaveSecreta, self::ALGORITMO);
}
public function verificar(string $token): object
{
return JWT::decode($token, new Key($this->chaveSecreta, self::ALGORITMO));
}
public function verificarComTratamento(string $token): array
{
try {
$payload = $this->verificar($token);
return ['valido' => true, 'payload' => $payload, 'erro' => null];
} catch (ExpiredException $e) {
return ['valido' => false, 'payload' => null, 'erro' => 'token_expirado'];
} catch (SignatureInvalidException $e) {
return ['valido' => false, 'payload' => null, 'erro' => 'assinatura_invalida'];
} catch (BeforeValidException $e) {
return ['valido' => false, 'payload' => null, 'erro' => 'token_ainda_nao_valido'];
} catch (\Exception $e) {
return ['valido' => false, 'payload' => null, 'erro' => 'token_invalido'];
}
}
}
Controller de autenticação — login, refresh e logout
<?php
declare(strict_types=1);
class AuthController
{
// Hash de uma senha aleatória que ninguém conhece. Serve só para gastar o
// mesmo tempo de CPU quando o email não existe, deixando as duas respostas
// indistinguíveis no relógio. Publicar este valor não abre nada.
private const HASH_DESCARTAVEL =
'$2y$12$5dkUo9h7.5aYO0aerz8nY.j0VBh2iEByG7lttB.l1CmNyjO7khqRu';
public function __construct(
private readonly ServicoJWT $jwt,
private readonly UsuarioRepositorioInterface $usuarios,
private readonly RefreshTokenRepositorio $refreshTokens,
) {}
// POST /api/auth/login
public function login(array $params): void
{
$corpo = $this->lerCorpo();
if (empty($corpo['email']) || empty($corpo['senha'])) {
Resposta::badRequest('Email e senha são obrigatórios.');
}
$usuario = $this->usuarios->buscarPorEmail($corpo['email']);
// Atenção ao curto-circuito do &&. Escrito como
// "$usuario !== null && password_verify(...)", o hash nunca chega a ser
// calculado quando o email não existe: a resposta sai em microssegundos,
// contra os cerca de 180 ms de um email cadastrado. A diferença é grande
// o bastante para varrer a base inteira no cronômetro, que é justamente
// o que se queria evitar.
// Por isso o password_verify roda SEMPRE — contra um hash descartável
// quando não há usuário — e só depois os dois testes se combinam.
$hashAlvo = $usuario?->senhaHash ?? self::HASH_DESCARTAVEL;
$conferiu = password_verify($corpo['senha'], $hashAlvo);
$senhaCorreta = $usuario !== null && $conferiu;
if (!$senhaCorreta) {
Resposta::erro(401, 'Credenciais inválidas.');
}
if (!$usuario->ativo) {
Resposta::erro(403, 'Conta desativada. Entre em contato com o suporte.');
}
// Emite os dois tokens
$accessToken = $this->jwt->emitirTokenDeAcesso($usuario->id, $usuario->perfil, $usuario->email);
$refreshToken = $this->jwt->emitirRefreshToken($usuario->id);
// Persiste o refresh token (necessário para revogação) — mas o que
// vai para a tabela é o HASH, nunca o token em claro. Refresh token vale
// sete dias: uma cópia do banco em claro seria um molho de chaves prontas
// para uso. Como sha256 é determinístico, a busca por ele continua direta.
$this->refreshTokens->salvar(
usuarioId: $usuario->id,
token: hash('sha256', $refreshToken),
expiresAt: new \DateTimeImmutable('+' . ServicoJWT::TTL_REFRESH . ' seconds'),
);
Resposta::ok([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'tipo' => 'Bearer',
'expira_em' => ServicoJWT::TTL_ACCESS, // segundos
'usuario' => [
'id' => $usuario->id,
'nome' => $usuario->nome,
'email' => $usuario->email,
'perfil' => $usuario->perfil,
],
]);
}
// POST /api/auth/refresh
public function refresh(array $params): void
{
$corpo = $this->lerCorpo();
if (empty($corpo['refresh_token'])) {
Resposta::badRequest('refresh_token é obrigatório.');
}
$resultado = $this->jwt->verificarComTratamento($corpo['refresh_token']);
if (!$resultado['valido']) {
Resposta::erro(401, 'Refresh token inválido ou expirado.');
}
$payload = $resultado['payload'];
// Verifica se é um refresh token (não um access token)
if (($payload->tipo ?? '') !== 'refresh') {
Resposta::erro(401, 'Token inválido para esta operação.');
}
// Verifica se o token não foi revogado no banco. A busca é pelo hash,
// porque foi o hash que se guardou na emissão.
if (!$this->refreshTokens->existe(hash('sha256', $corpo['refresh_token']))) {
Resposta::erro(401, 'Refresh token revogado.');
}
$usuarioId = (int) $payload->sub;
$usuario = $this->usuarios->buscarPorId($usuarioId);
if ($usuario === null || !$usuario->ativo) {
Resposta::erro(401, 'Usuário não encontrado ou desativado.');
}
// Rotação de refresh token — invalida o antigo, emite novo
$this->refreshTokens->revogar(hash('sha256', $corpo['refresh_token']));
$novoRefreshToken = $this->jwt->emitirRefreshToken($usuario->id);
$this->refreshTokens->salvar(
usuarioId: $usuario->id,
token: hash('sha256', $novoRefreshToken),
expiresAt: new \DateTimeImmutable('+' . ServicoJWT::TTL_REFRESH . ' seconds'),
);
Resposta::ok([
'access_token' => $this->jwt->emitirTokenDeAcesso($usuario->id, $usuario->perfil, $usuario->email),
'refresh_token' => $novoRefreshToken,
'tipo' => 'Bearer',
'expira_em' => ServicoJWT::TTL_ACCESS,
]);
}
// POST /api/auth/logout
public function logout(array $params): void
{
$corpo = $this->lerCorpo();
if (!empty($corpo['refresh_token'])) {
$this->refreshTokens->revogar(hash('sha256', $corpo['refresh_token']));
}
Resposta::semConteudo();
}
private function lerCorpo(): array
{
$raw = file_get_contents('php://input');
$dados = json_decode($raw ?: '', associative: true);
if (json_last_error() !== JSON_ERROR_NONE) Resposta::badRequest('JSON inválido.');
return $dados ?? [];
}
}
Middleware de autenticação
<?php
declare(strict_types=1);
class MiddlewareAutenticacao
{
public function __construct(
private readonly ServicoJWT $jwt,
) {}
// Middleware que extrai e valida o Bearer token
public function autenticar(array $params, callable $proximo): void
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!str_starts_with($authHeader, 'Bearer ')) {
Resposta::naoAutorizado('Token de autenticação não fornecido.');
}
$token = substr($authHeader, 7); // remove "Bearer "
$resultado = $this->jwt->verificarComTratamento($token);
if (!$resultado['valido']) {
$mensagens = [
'token_expirado' => 'Token expirado. Faça refresh ou login novamente.',
'assinatura_invalida' => 'Token com assinatura inválida.',
'token_ainda_nao_valido' => 'Token ainda não é válido.',
'token_invalido' => 'Token inválido.',
];
Resposta::naoAutorizado($mensagens[$resultado['erro']] ?? 'Token inválido.');
}
$payload = $resultado['payload'];
// Verifica se é um access token (não refresh token)
if (($payload->tipo ?? '') !== 'access') {
Resposta::naoAutorizado('Use o access token para autenticar requisições.');
}
// Injeta o usuário autenticado na superglobal de requisição
// (em frameworks reais, isto vai para um objeto Request)
$_REQUEST['usuario_autenticado'] = [
'id' => (int) $payload->sub,
'perfil' => $payload->perfil,
'email' => $payload->email,
];
$proximo($params);
}
// Middleware de autorização por perfil
public function exigirPerfil(string ...$perfisPermitidos): callable
{
return function (array $params, callable $proximo) use ($perfisPermitidos): void {
$usuario = $_REQUEST['usuario_autenticado'] ?? null;
if ($usuario === null) {
Resposta::naoAutorizado();
}
if (!in_array($usuario['perfil'], $perfisPermitidos, true)) {
Resposta::proibido(
"Perfil '{$usuario['perfil']}' não tem permissão para esta operação."
);
}
$proximo($params);
};
}
}
// ── Usando os middlewares no router ───────────────────────────────────
$auth = new MiddlewareAutenticacao($jwt);
$router = new Router();
// Rotas públicas — sem autenticação
$router->post('/api/auth/login', [$authController, 'login']);
$router->post('/api/auth/refresh', [$authController, 'refresh']);
$router->post('/api/auth/logout', [$authController, 'logout']);
$router->get ('/api/produtos', [$produtoController, 'listar']);
// Rotas autenticadas
$router->post('/api/produtos', function(array $params) use ($auth, $produtoController): void {
$auth->autenticar($params, fn($p) => $produtoController->criar($p));
});
// Rota apenas para admins
$router->delete('/api/usuarios/{id}', function(array $params) use ($auth, $usuarioController): void {
$auth->autenticar($params, function($p) use ($auth, $usuarioController): void {
$auth->exigirPerfil('admin')($p, fn($pp) => $usuarioController->deletar($pp));
});
});
Implementar o JWT à mão antes de instalar a biblioteca vale pelo que revela: o token não é criptografado, só assinado. Qualquer pessoa com o token lê o payload — base64url não esconde nada — e o que a assinatura garante é apenas que ninguém o alterou. Daí a regra que decide o desenho inteiro: no payload entra o que pode ser público, e o access token vive pouco justamente porque não há como cancelá-lo antes da hora.
A parte mais escorregadia não é a criptografia, é o cotidiano do login. Um && na ordem errada e o servidor responde em microssegundos para e-mail inexistente e em 180 ms para e-mail cadastrado — a base toda enumerável no cronômetro, mesmo com a mensagem de erro idêntica nos dois casos. Refresh token guardado em claro tem o mesmo caráter: não quebra nada hoje, e transforma uma cópia do banco em sete dias de acesso.
Fontes e leituras recomendadas
- JWT.io — Debugger e especificação: https://jwt.io
- RFC 7519 — JSON Web Token: https://datatracker.ietf.org/doc/html/rfc7519
- firebase/php-jwt: https://github.com/firebase/php-jwt
- OWASP Cheat Sheet — Autenticação: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- PHP Manual — password_hash / password_verify: https://www.php.net/manual/pt_BR/function.password-hash.php
Exercícios
Exercício 1
Implemente o RefreshTokenRepositorio com PDO: tabela refresh_tokens com colunas token (hash SHA-256 — nunca armazene o token em texto puro), usuario_id, expires_at, revogado_em. Métodos: salvar(), existe(), revogar(), limparExpirados().
Ver resposta
✓ Resposta: Por que SHA-256 aqui e password_hash na senha: senha tem entropia baixa e sofre ataque de dicionário, então precisa de KDF lento. Token de 256 bits aleatórios não tem dicionário — o hash rápido só serve para que um vazamento do banco não entregue tokens utilizáveis.
<?php
declare(strict_types=1);
// CREATE TABLE refresh_tokens (
// id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
// token_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 em hex
// usuario_id INT UNSIGNED NOT NULL,
// expires_at DATETIME NOT NULL,
// revogado_em DATETIME NULL,
// criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
// INDEX idx_rt_usuario (usuario_id),
// INDEX idx_rt_expira (expires_at),
// CONSTRAINT fk_rt_usuario FOREIGN KEY (usuario_id)
// REFERENCES usuarios(id) ON DELETE CASCADE
// ) ENGINE=InnoDB;
declare(strict_types=1);
final class RefreshTokenRepositorio
{
public function __construct(private readonly PDO $pdo) {}
/**
* Guarda o HASH, nunca o token. Se o banco vazar, os tokens continuam
* inúteis — mesma lógica de senha. Aqui SHA-256 basta (diferente de
* senha) porque o token já é aleatório de alta entropia: não há
* dicionário para atacar, então não é preciso KDF lento.
*/
public function salvar(string $token, int $usuarioId, DateTimeImmutable $expiraEm): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO refresh_tokens (token_hash, usuario_id, expires_at)
VALUES (:hash, :usuario, :expira)'
);
$stmt->execute([
':hash' => $this->hash($token),
':usuario' => $usuarioId,
':expira' => $expiraEm->format('Y-m-d H:i:s'),
]);
}
/** @return array{usuario_id: int}|null null = inválido, expirado ou revogado */
public function existe(string $token): ?array
{
$stmt = $this->pdo->prepare(
'SELECT usuario_id FROM refresh_tokens
WHERE token_hash = :hash
AND revogado_em IS NULL
AND expires_at > NOW()
LIMIT 1'
);
$stmt->execute([':hash' => $this->hash($token)]);
$linha = $stmt->fetch(PDO::FETCH_ASSOC);
return $linha ? ['usuario_id' => (int) $linha['usuario_id']] : null;
}
public function revogar(string $token): bool
{
$stmt = $this->pdo->prepare(
'UPDATE refresh_tokens SET revogado_em = NOW()
WHERE token_hash = :hash AND revogado_em IS NULL'
);
$stmt->execute([':hash' => $this->hash($token)]);
return $stmt->rowCount() > 0;
}
/** Logout de todos os dispositivos. */
public function revogarTodosDoUsuario(int $usuarioId): int
{
$stmt = $this->pdo->prepare(
'UPDATE refresh_tokens SET revogado_em = NOW()
WHERE usuario_id = :usuario AND revogado_em IS NULL'
);
$stmt->execute([':usuario' => $usuarioId]);
return $stmt->rowCount();
}
/** Rotação: o refresh usado morre e nasce outro. */
public function rotacionar(string $tokenAntigo, string $tokenNovo,
int $usuarioId, DateTimeImmutable $expiraEm): void
{
$this->pdo->beginTransaction();
try {
$this->revogar($tokenAntigo);
$this->salvar($tokenNovo, $usuarioId, $expiraEm);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function limparExpirados(int $diasDeGraca = 7): int
{
// Não apaga na hora: manter um tempo permite detectar reuso de token
// revogado, que é sinal de credencial roubada.
$stmt = $this->pdo->prepare(
'DELETE FROM refresh_tokens
WHERE expires_at < DATE_SUB(NOW(), INTERVAL :dias DAY)'
);
$stmt->bindValue(':dias', $diasDeGraca, PDO::PARAM_INT);
$stmt->execute();
return $stmt->rowCount();
}
public static function gerar(): string
{
return bin2hex(random_bytes(32)); // 256 bits de entropia
}
private function hash(string $token): string
{
return hash('sha256', $token);
}
}
// ---------- Uso -----------------------------------------------------------
$token = RefreshTokenRepositorio::gerar();
$repo->salvar($token, usuarioId: 7, expiraEm: new DateTimeImmutable('+30 days'));
if (($dados = $repo->existe($token)) !== null) {
$novo = RefreshTokenRepositorio::gerar();
$repo->rotacionar($token, $novo, $dados['usuario_id'], new DateTimeImmutable('+30 days'));
}
Exercício 2
Adicione ao middleware um mecanismo de token blacklist em memória (usando APCu): quando o logout é chamado, armazene o jti do access token no APCu pelo tempo restante até expirar. O middleware verifica se o jti está na blacklist antes de autorizar.
Ver resposta
✓ Resposta: O ponto que o exercício ensina: guardar o jti só até o exp mantém a blacklist pequena para sempre. E lembre que APCu é por processo/servidor — com mais de uma máquina atrás de um balanceador, a blacklist precisa ser Redis, senão o logout vale só no nó que atendeu.
<?php
declare(strict_types=1);
final class BlacklistToken
{
private const PREFIXO = 'jwt:bl:';
/**
* Access token não dá para "cancelar": ele é auto-contido e válido até
* expirar. A blacklist é a forma de invalidar antes da hora — e ela só
* precisa guardar o jti pelo tempo que FALTA para o token morrer.
*/
public function revogar(string $jti, int $expiraEm): bool
{
$ttl = $expiraEm - time();
if ($ttl <= 0) {
return true; // já expirou sozinho, nada a guardar
}
// O TTL do APCu faz a limpeza acontecer sozinha.
return apcu_store(self::PREFIXO . $jti, true, $ttl);
}
public function estaRevogado(string $jti): bool
{
return apcu_exists(self::PREFIXO . $jti);
}
public function limpar(): void
{
foreach (new APCUIterator('/^' . preg_quote(self::PREFIXO, '/') . '/') as $item) {
apcu_delete($item['key']);
}
}
}
final class ServicoJWT
{
public function __construct(
private readonly string $segredo,
private readonly int $ttlAcesso = 900, // 15 min
) {}
public function emitir(int $usuarioId, array $extra = []): array
{
$agora = time();
// jti: identificador único do token. É ele que a blacklist guarda.
$jti = bin2hex(random_bytes(16));
$payload = [
'sub' => $usuarioId,
'jti' => $jti,
'iat' => $agora,
'exp' => $agora + $this->ttlAcesso,
] + $extra;
return ['token' => $this->codificar($payload), 'jti' => $jti,
'exp' => $payload['exp']];
}
public function validar(string $token): array
{
$partes = explode('.', $token);
if (count($partes) !== 3) {
throw new RuntimeException('Token malformado.');
}
[$cabecalho64, $payload64, $assinatura64] = $partes;
$esperada = $this->assinar("{$cabecalho64}.{$payload64}");
// hash_equals: comparar assinatura com === vaza informação por tempo.
if (!hash_equals($esperada, $assinatura64)) {
throw new RuntimeException('Assinatura inválida.');
}
$payload = json_decode($this->base64UrlDecode($payload64), true);
if (($payload['exp'] ?? 0) < time()) {
throw new RuntimeException('Token expirado.');
}
return $payload;
}
private function codificar(array $payload): string
{
$cabecalho = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$corpo = $this->base64UrlEncode(json_encode($payload));
return "{$cabecalho}.{$corpo}." . $this->assinar("{$cabecalho}.{$corpo}");
}
private function assinar(string $dados): string
{
return $this->base64UrlEncode(hash_hmac('sha256', $dados, $this->segredo, true));
}
private function base64UrlEncode(string $dados): string
{
return rtrim(strtr(base64_encode($dados), '+/', '-_'), '=');
}
private function base64UrlDecode(string $dados): string
{
return base64_decode(strtr($dados, '-_', '+/'));
}
}
// ---------- Middleware ----------------------------------------------------
function autenticarJwt(ServicoJWT $jwt, BlacklistToken $blacklist): callable
{
return static function (array $params, callable $proximo) use ($jwt, $blacklist): mixed {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(\S+)$/i', $header, $m)) {
http_response_code(401);
return ['erro' => 'token_ausente'];
}
try {
$payload = $jwt->validar($m[1]);
} catch (RuntimeException $e) {
http_response_code(401);
return ['erro' => 'token_invalido', 'mensagem' => $e->getMessage()];
}
// A checagem da blacklist vem DEPOIS de validar a assinatura: não faz
// sentido consultar o jti de um token que nem é nosso.
if ($blacklist->estaRevogado($payload['jti'] ?? '')) {
http_response_code(401);
return ['erro' => 'token_revogado'];
}
$params['usuario_id'] = (int) $payload['sub'];
$params['jti'] = $payload['jti'];
$params['exp'] = $payload['exp'];
return $proximo($params);
};
}
// ---------- Logout --------------------------------------------------------
$router->post('/api/auth/logout', function (array $p) use ($blacklist, $refreshRepo): array {
$blacklist->revogar($p['jti'], $p['exp']);
$refreshRepo->revogarTodosDoUsuario($p['usuario_id']);
http_response_code(204);
return [];
})->middleware(autenticarJwt($jwt, $blacklist));
Exercício 3
Implemente o fluxo de troca de senha: POST /api/auth/trocar-senha recebe o token de acesso atual + senha atual + nova senha. Após trocar, revogue todos os refresh tokens do usuário (logout de todos os dispositivos).
Ver resposta
✓ Resposta: Trocar a senha e não revogar as sessões é uma falha comum e séria: se alguém entrou com a senha antiga, o refresh token dele continua funcionando por 30 dias — trocar a senha não expulsa o invasor.
<?php
declare(strict_types=1);
final class AuthController
{
public function __construct(
private readonly PDO $pdo,
private readonly ServicoJWT $jwt,
private readonly RefreshTokenRepositorio $refreshRepo,
private readonly BlacklistToken $blacklist,
) {}
/** POST /api/auth/trocar-senha (exige access token válido) */
public function trocarSenha(array $params): array
{
$dados = json_decode(file_get_contents('php://input') ?: '{}', true) ?? [];
$atual = (string) ($dados['senha_atual'] ?? '');
$nova = (string) ($dados['nova_senha'] ?? '');
if ($erros = $this->validarNovaSenha($nova, $atual)) {
return Resposta::erro('validacao', 'Senha inválida.', 422, ['campos' => $erros]);
}
$usuarioId = (int) $params['usuario_id'];
$stmt = $this->pdo->prepare('SELECT senha_hash FROM usuarios WHERE id = :id');
$stmt->execute([':id' => $usuarioId]);
$hashAtual = $stmt->fetchColumn();
if ($hashAtual === false || !password_verify($atual, (string) $hashAtual)) {
// 403, não 422: o token é válido, a credencial é que não confere.
return Resposta::erro('senha_incorreta', 'A senha atual não confere.', 403);
}
$this->pdo->beginTransaction();
try {
$update = $this->pdo->prepare(
'UPDATE usuarios SET senha_hash = :hash, senha_alterada_em = NOW()
WHERE id = :id'
);
$update->execute([
':hash' => password_hash($nova, PASSWORD_DEFAULT),
':id' => $usuarioId,
]);
// Trocar senha derruba todas as sessões: é o que corta o acesso
// de quem tinha roubado a credencial.
$revogados = $this->refreshRepo->revogarTodosDoUsuario($usuarioId);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
// O access token atual também morre — inclusive o desta requisição.
$this->blacklist->revogar($params['jti'], $params['exp']);
// Emite um par novo para o dispositivo que trocou a senha: sem isso,
// o usuário troca a senha e é deslogado na hora, o que irrita.
$acesso = $this->jwt->emitir($usuarioId);
$refresh = RefreshTokenRepositorio::gerar();
$this->refreshRepo->salvar($refresh, $usuarioId, new DateTimeImmutable('+30 days'));
return Resposta::json([
'mensagem' => 'Senha alterada. Os outros dispositivos foram desconectados.',
'sessoes_encerradas' => $revogados,
'access_token' => $acesso['token'],
'refresh_token' => $refresh,
'expira_em' => $acesso['exp'],
]);
}
private function validarNovaSenha(string $nova, string $atual): array
{
$erros = [];
if (mb_strlen($nova) < 10) {
$erros['nova_senha'] = 'Mínimo de 10 caracteres.';
}
if ($nova === $atual) {
$erros['nova_senha'] = 'A nova senha precisa ser diferente da atual.';
}
if (preg_match('/^\d+$/', $nova)) {
$erros['nova_senha'] = 'Não use apenas números.';
}
return $erros;
}
}
$router->post('/api/auth/trocar-senha', fn ($p) => $auth->trocarSenha($p))
->middleware(autenticarJwt($jwt, $blacklist));
Exercício 4
Adicione rate limiting por IP no endpoint de login: máximo de 5 tentativas falhas em 15 minutos, usando APCu para controle. Após exceder, retornar 429 Too Many Requests com header Retry-After.
Ver resposta
✓ Resposta: Dois vazamentos sutis fechados aqui: rodar password_verify mesmo sem usuário (senão o tempo de resposta denuncia quem está cadastrado) e a mensagem genérica de erro. Contar por IP+e-mail, e não só por IP, evita que um NAT corporativo bloqueie o prédio inteiro.
<?php
declare(strict_types=1);
final class LimitadorLogin
{
private const PREFIXO = 'login:falhas:';
public function __construct(
private readonly int $maxTentativas = 5,
private readonly int $janelaSegundos = 900, // 15 min
) {}
/** @return array{bloqueado: bool, restantes: int, retry_after: int} */
public function situacao(string $ip, string $email = ''): array
{
$chave = $this->chave($ip, $email);
$registros = $this->registrosValidos($chave);
if (count($registros) < $this->maxTentativas) {
return [
'bloqueado' => false,
'restantes' => $this->maxTentativas - count($registros),
'retry_after' => 0,
];
}
return [
'bloqueado' => true,
'restantes' => 0,
'retry_after' => max(1, (min($registros) + $this->janelaSegundos) - time()),
];
}
public function registrarFalha(string $ip, string $email = ''): void
{
$chave = $this->chave($ip, $email);
$registros = $this->registrosValidos($chave);
$registros[] = time();
apcu_store($chave, $registros, $this->janelaSegundos);
}
/** Login bem-sucedido zera o contador. */
public function limpar(string $ip, string $email = ''): void
{
apcu_delete($this->chave($ip, $email));
}
private function registrosValidos(string $chave): array
{
$limite = time() - $this->janelaSegundos;
return array_values(array_filter(
apcu_fetch($chave) ?: [],
static fn(int $t): bool => $t > $limite,
));
}
private function chave(string $ip, string $email): string
{
// Contar por IP + e-mail evita que um atacante num IP compartilhado
// (NAT corporativo) bloqueie a conta de todo mundo do prédio.
return self::PREFIXO . sha1($ip . '|' . mb_strtolower($email));
}
}
// ---------- No endpoint de login -----------------------------------------
$router->post('/api/auth/login', function (array $params) use ($pdo, $jwt, $limitador, $refreshRepo): array {
$ip = trim(explode(',', $_SERVER['REMOTE_ADDR'] ?? 'desconhecido')[0]);
$dados = json_decode(file_get_contents('php://input') ?: '{}', true) ?? [];
$email = mb_strtolower(trim((string) ($dados['email'] ?? '')));
$senha = (string) ($dados['senha'] ?? '');
$situacao = $limitador->situacao($ip, $email);
if ($situacao['bloqueado']) {
http_response_code(429);
header('Retry-After: ' . $situacao['retry_after']);
return [
'erro' => 'muitas_tentativas',
'mensagem' => 'Muitas tentativas de login. Tente novamente mais tarde.',
'tente_em' => $situacao['retry_after'] . 's',
];
}
$stmt = $pdo->prepare('SELECT id, senha_hash FROM usuarios WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
// password_verify roda mesmo com usuário inexistente, contra um hash
// descartável: sem isso, a resposta volta mais rápido para e-mail que não
// existe, e dá para enumerar quem é cliente pelo tempo de resposta.
$hash = $usuario['senha_hash'] ?? '$2y$12$umHashQualquerParaGastarOMesmoTempo......';
if (!password_verify($senha, $hash) || $usuario === false) {
$limitador->registrarFalha($ip, $email);
$restantes = $limitador->situacao($ip, $email)['restantes'];
http_response_code(401);
// Mensagem genérica: nunca diga "e-mail não cadastrado".
return [
'erro' => 'credenciais_invalidas',
'mensagem' => 'E-mail ou senha incorretos.',
'tentativas_restantes' => $restantes,
];
}
$limitador->limpar($ip, $email);
$acesso = $jwt->emitir((int) $usuario['id']);
$refresh = RefreshTokenRepositorio::gerar();
$refreshRepo->salvar($refresh, (int) $usuario['id'], new DateTimeImmutable('+30 days'));
return ['access_token' => $acesso['token'], 'refresh_token' => $refresh,
'expira_em' => $acesso['exp']];
});
Exercício 5
Desafio: implemente autenticação com RS256 (RSA): gere um par de chaves com openssl_pkey_new(), assine com a chave privada e verifique com a pública. Adapte o ServicoJWT para suportar ambos os algoritmos, configurável via construtor.
Ver resposta
✓ Resposta: A checagem do alg contra o algoritmo configurado não é preciosismo — é a correção de uma das vulnerabilidades mais exploradas em JWT. Aceitar o alg que vem no token permite ao atacante trocar RS256 por HS256 e assinar usando a chave pública, que ele já tem.
<?php
declare(strict_types=1);
enum AlgoritmoJwt: string
{
case HS256 = 'HS256'; // simétrico: mesma chave assina e verifica
case RS256 = 'RS256'; // assimétrico: privada assina, pública verifica
public function ehAssimetrico(): bool
{
return $this === self::RS256;
}
}
final class ServicoJWT
{
/**
* @param string $chaveAssinatura HS256: o segredo. RS256: a chave privada (PEM).
* @param string|null $chaveVerificacao RS256: a chave pública (PEM).
*/
public function __construct(
private readonly AlgoritmoJwt $algoritmo,
private readonly string $chaveAssinatura,
private readonly ?string $chaveVerificacao = null,
private readonly int $ttl = 900,
) {
if ($algoritmo->ehAssimetrico() && $chaveVerificacao === null) {
throw new InvalidArgumentException('RS256 exige a chave pública.');
}
if (!$algoritmo->ehAssimetrico() && strlen($chaveAssinatura) < 32) {
throw new InvalidArgumentException('Segredo HS256 curto demais (mín. 32 bytes).');
}
}
public static function gerarParDeChaves(int $bits = 2048): array
{
$recurso = openssl_pkey_new([
'private_key_bits' => $bits,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($recurso, $privada);
$publica = openssl_pkey_get_details($recurso)['key'];
return ['privada' => $privada, 'publica' => $publica];
}
public function emitir(int $usuarioId, array $extra = []): string
{
$agora = time();
$payload = [
'sub' => $usuarioId,
'jti' => bin2hex(random_bytes(16)),
'iat' => $agora,
'exp' => $agora + $this->ttl,
] + $extra;
// O alg vai no cabeçalho — mas NUNCA será lido para escolher o
// algoritmo na validação. Veja o porquê na validação abaixo.
$cabecalho = $this->b64(json_encode([
'alg' => $this->algoritmo->value,
'typ' => 'JWT',
]));
$corpo = $this->b64(json_encode($payload));
return "{$cabecalho}.{$corpo}." . $this->assinar("{$cabecalho}.{$corpo}");
}
public function validar(string $token): array
{
$partes = explode('.', $token);
if (count($partes) !== 3) {
throw new RuntimeException('Token malformado.');
}
[$cabecalho64, $payload64, $assinatura] = $partes;
$cabecalho = json_decode($this->b64d($cabecalho64), true);
// ⚠️ A vulnerabilidade clássica de JWT: confiar no `alg` do token.
// Um atacante troca para "none" ou para HS256 e assina com a chave
// PÚBLICA (que é pública!). A defesa é comparar com o algoritmo
// configurado e recusar qualquer divergência.
if (($cabecalho['alg'] ?? '') !== $this->algoritmo->value) {
throw new RuntimeException('Algoritmo não permitido.');
}
if (!$this->verificar("{$cabecalho64}.{$payload64}", $assinatura)) {
throw new RuntimeException('Assinatura inválida.');
}
$payload = json_decode($this->b64d($payload64), true);
if (($payload['exp'] ?? 0) < time()) {
throw new RuntimeException('Token expirado.');
}
return $payload;
}
private function assinar(string $dados): string
{
if (!$this->algoritmo->ehAssimetrico()) {
return $this->b64(hash_hmac('sha256', $dados, $this->chaveAssinatura, true));
}
$chave = openssl_pkey_get_private($this->chaveAssinatura);
if ($chave === false) {
throw new RuntimeException('Chave privada inválida.');
}
openssl_sign($dados, $assinatura, $chave, OPENSSL_ALGO_SHA256);
return $this->b64($assinatura);
}
private function verificar(string $dados, string $assinatura64): bool
{
if (!$this->algoritmo->ehAssimetrico()) {
return hash_equals(
$this->b64(hash_hmac('sha256', $dados, $this->chaveAssinatura, true)),
$assinatura64,
);
}
$chave = openssl_pkey_get_public((string) $this->chaveVerificacao);
if ($chave === false) {
throw new RuntimeException('Chave pública inválida.');
}
// openssl_verify: 1 = válida, 0 = inválida, -1 = erro.
return openssl_verify($dados, $this->b64d($assinatura64), $chave,
OPENSSL_ALGO_SHA256) === 1;
}
private function b64(string $d): string
{
return rtrim(strtr(base64_encode($d), '+/', '-_'), '=');
}
private function b64d(string $d): string
{
return base64_decode(strtr($d, '-_', '+/'));
}
}
// ---------- Uso -----------------------------------------------------------
$hs = new ServicoJWT(AlgoritmoJwt::HS256, $_ENV['JWT_SECRET']);
$token = $hs->emitir(7);
print_r($hs->validar($token));
$par = ServicoJWT::gerarParDeChaves();
$rs = new ServicoJWT(AlgoritmoJwt::RS256, $par['privada'], $par['publica']);
$tokenRs = $rs->emitir(7);
print_r($rs->validar($tokenRs));
// A vantagem do RS256: o serviço que só VERIFICA recebe apenas a pública.
// Se ele for comprometido, ninguém consegue emitir token em seu nome.
$verificador = new ServicoJWT(AlgoritmoJwt::RS256, $par['publica'], $par['publica']);