Autenticação com JWT em PHP puro

1. Fundamentos do JWT e sua estrutura

JWT (JSON Web Token) é um padrão aberto (RFC 7519) que define uma forma compacta e autossuficiente de transmitir informações entre partes como um objeto JSON. Em APIs REST, ele é amplamente utilizado para autenticação stateless, onde o servidor não precisa armazenar sessões.

Um token JWT é composto por três partes separadas por pontos (.):

  • Header: contém o tipo do token (typ) e o algoritmo de assinatura (alg), geralmente HS256 para HMAC-SHA256.
  • Payload: contém as claims (declarações), como sub (subject), iat (issued at), exp (expiration) e dados personalizados.
  • Signature: assinatura gerada a partir do header e payload codificados, usando uma chave secreta.

É importante distinguir: JWT refere-se ao token em si, JWS (JSON Web Signature) é o mecanismo de assinatura (o mais comum), e JWE (JSON Web Encryption) é a versão criptografada. Neste artigo, trabalharemos com JWS.

2. Preparação do ambiente e funções auxiliares

Primeiro, configure os headers HTTP e funções auxiliares para manipulação de JSON e constantes:

<?php
// config.php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit();
}

define('JWT_SECRET', 'sua-chave-secreta-muito-forte-aqui');
define('JWT_EXPIRATION', 3600); // 1 hora em segundos

function jsonResposta($dados, $status = 200) {
    http_response_code($status);
    echo json_encode($dados, JSON_UNESCAPED_UNICODE);
    exit();
}

3. Codificação manual do JWT (Header e Payload)

Implementaremos o Base64url encode, que substitui + por -, / por _ e remove =:

<?php
function base64urlEncode($dados) {
    return rtrim(strtr(base64_encode($dados), '+/', '-_'), '=');
}

function base64urlDecode($dados) {
    return base64_decode(strtr($dados, '-_', '+/'));
}

Agora, crie o header e payload do JWT:

<?php
function criarHeaderJWT() {
    return json_encode([
        'typ' => 'JWT',
        'alg' => 'HS256'
    ]);
}

function criarPayloadJWT($dadosUsuario) {
    $agora = time();
    return json_encode([
        'iss' => 'meu-sistema',        // emissor
        'sub' => $dadosUsuario['id'],  // identificador do usuário
        'iat' => $agora,               // emitido em
        'exp' => $agora + JWT_EXPIRATION, // expiração
        'nome' => $dadosUsuario['nome'],
        'email' => $dadosUsuario['email']
    ]);
}

4. Assinatura HMAC-SHA256 e geração do token

A assinatura é gerada com hash_hmac usando SHA256. A função completa para criar o token:

<?php
function criarJWT($dadosUsuario, $secreto = JWT_SECRET) {
    $header = criarHeaderJWT();
    $payload = criarPayloadJWT($dadosUsuario);

    $headerEncoded = base64urlEncode($header);
    $payloadEncoded = base64urlEncode($payload);

    $assinatura = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secreto, true);
    $assinaturaEncoded = base64urlEncode($assinatura);

    return "$headerEncoded.$payloadEncoded.$assinaturaEncoded";
}

Exemplo de uso:

<?php
$usuario = ['id' => 1, 'nome' => 'João Silva', 'email' => 'joao@email.com'];
$token = criarJWT($usuario);
echo $token; // eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOi...

5. Validação e verificação do token recebido

Para validar, extraímos o token do header Authorization: Bearer, separamos as partes e verificamos assinatura e expiração:

<?php
function extrairToken() {
    $headers = getallheaders();
    $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';

    if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
        return $matches[1];
    }
    return null;
}

function validarJWT($token, $secreto = JWT_SECRET) {
    $partes = explode('.', $token);
    if (count($partes) !== 3) {
        return ['valido' => false, 'erro' => 'Token malformado'];
    }

    [$headerEncoded, $payloadEncoded, $assinaturaEncoded] = $partes;

    // Verificar assinatura
    $assinaturaCalculada = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secreto, true);
    $assinaturaRecebida = base64urlDecode($assinaturaEncoded);

    // Proteção contra timing attack
    if (!hash_equals($assinaturaCalculada, $assinaturaRecebida)) {
        return ['valido' => false, 'erro' => 'Assinatura inválida'];
    }

    // Decodificar payload
    $payload = json_decode(base64urlDecode($payloadEncoded), true);
    if (!$payload) {
        return ['valido' => false, 'erro' => 'Payload inválido'];
    }

    // Verificar expiração
    if (isset($payload['exp']) && $payload['exp'] < time()) {
        return ['valido' => false, 'erro' => 'Token expirado'];
    }

    return ['valido' => true, 'payload' => $payload];
}

6. Integração com autenticação de usuário

Vamos simular um banco de dados com array estático e criar rotas de login e middleware de proteção:

<?php
// Simulação de banco de dados
$usuarios = [
    1 => ['id' => 1, 'nome' => 'João Silva', 'email' => 'joao@email.com', 'senha' => password_hash('123456', PASSWORD_DEFAULT)],
    2 => ['id' => 2, 'nome' => 'Maria Souza', 'email' => 'maria@email.com', 'senha' => password_hash('654321', PASSWORD_DEFAULT)]
];

// Rota de login
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_SERVER['REQUEST_URI'] === '/login') {
    $dados = json_decode(file_get_contents('php://input'), true);
    $email = $dados['email'] ?? '';
    $senha = $dados['senha'] ?? '';

    $usuarioEncontrado = null;
    foreach ($usuarios as $u) {
        if ($u['email'] === $email && password_verify($senha, $u['senha'])) {
            $usuarioEncontrado = $u;
            break;
        }
    }

    if (!$usuarioEncontrado) {
        jsonResposta(['erro' => 'Credenciais inválidas'], 401);
    }

    $token = criarJWT($usuarioEncontrado);
    jsonResposta(['token' => $token, 'usuario' => ['id' => $usuarioEncontrado['id'], 'nome' => $usuarioEncontrado['nome']]]);
}

// Middleware de proteção
function middlewareAutenticacao() {
    $token = extrairToken();
    if (!$token) {
        jsonResposta(['erro' => 'Token não fornecido'], 401);
    }

    $resultado = validarJWT($token);
    if (!$resultado['valido']) {
        jsonResposta(['erro' => $resultado['erro']], 401);
    }

    return $resultado['payload'];
}

// Rota protegida (exemplo)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $_SERVER['REQUEST_URI'] === '/perfil') {
    $usuario = middlewareAutenticacao();
    jsonResposta(['usuario' => $usuario]);
}

7. Tratamento de erros e boas práticas de segurança

Implemente mensagens de erro padronizadas e proteções adicionais:

<?php
function jsonErro($mensagem, $codigo = 400) {
    $erros = [
        'token_invalido' => 'Token inválido ou malformado',
        'token_expirado' => 'Token expirado, faça login novamente',
        'token_ausente' => 'Token de autenticação não fornecido',
        'credenciais_invalidas' => 'Email ou senha incorretos'
    ];

    $mensagemFormatada = $erros[$mensagem] ?? $mensagem;
    jsonResposta(['erro' => $mensagemFormatada, 'codigo' => $mensagem], $codigo);
}

Boas práticas adicionais:

  1. Timing attack: Use hash_equals() para comparação de assinaturas (já implementado acima).
  2. Renovação de token: Implemente um token de refresh com expiração maior (ex: 7 dias) e um endpoint /refresh que valida o refresh token e emite um novo access token.
  3. Blacklist de tokens: Para logout ou revogação, mantenha uma lista (Redis ou banco) de tokens invalidados até sua expiração natural.
  4. HTTPS obrigatório: Nunca transmita tokens por HTTP simples.
  5. Payload mínimo: Inclua apenas dados essenciais no payload; informações sensíveis devem ser buscadas no servidor.

Exemplo de endpoint de refresh:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_SERVER['REQUEST_URI'] === '/refresh') {
    $dados = json_decode(file_get_contents('php://input'), true);
    $refreshToken = $dados['refresh_token'] ?? '';

    // Validar refresh token (com expiração maior)
    $resultado = validarJWT($refreshToken, JWT_SECRET_REFRESH);
    if (!$resultado['valido']) {
        jsonResposta(['erro' => 'Refresh token inválido'], 401);
    }

    // Emitir novo access token
    $novoToken = criarJWT($resultado['payload']);
    jsonResposta(['token' => $novoToken]);
}

Referências