Autenticação JWT

Autenticação JWT

Assinar e conferir o token à mão para enxergar o mecanismo, depois passar para a firebase/php-jwt, com refresh token rotacionado e middleware que separa autenticação de autorização. Inclui o curto-circuito no login que entrega quais e-mails existem na base.
PHP

• • 27 min de leitura

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

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']);
Comentários

Mais em PHP

Design Patterns: Singleton, Factory e Builder
Design Patterns: Singleton, Factory e Builder

Três padrões de criação e o que cada um custa: o Singleton, que garante…

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…

APIs com Laravel
APIs com Laravel

API Resources, Sanctum, tratamento de erro em JSON e rate limiting, montados…