Pular para o conteúdo principal

victorstein.dev

97/100 Dias de Golang - Autenticação com JWT e Middlewares no Gin

Table of Contents

# Autenticação com JWT e Middlewares no Gin

Em vários posts criamos APIs com o Gin (dia 43, a previsão do tempo, o Mini CI/CD…), mas todas tinham o mesmo problema: qualquer pessoa podia chamar qualquer endpoint. Hoje vamos resolver isso com uma API que tem cadastro, login e rotas protegidas, usando JWT para autenticação e middlewares do Gin para controlar o acesso.

Antes, dois conceitos que costumam ser confundidos:

  • Autenticação: descobrir quem é o usuário. “Você é o Victor? Prove com a sua senha”;
  • Autorização: decidir o que esse usuário pode fazer. “O Victor pode acessar o relatório de administração?”

Na API isso aparece em dois status HTTP diferentes: 401 Unauthorized quando não sabemos quem é (sem token ou token inválido), e 403 Forbidden quando sabemos quem é, mas ele não tem permissão. O nome do 401 é confuso, mas ele significa “não autenticado”.

# O que é um JWT

Depois do login, o servidor precisa de um jeito de reconhecer o usuário nas próximas requisições sem pedir a senha de novo. A forma tradicional é a sessão: o servidor guarda “sessão abc123 = usuário 42” (em memória, no Redis, no banco) e envia o abc123 num cookie. A cada requisição, consulta essa informação.

O JWT (JSON Web Token) faz diferente: em vez de guardar a informação no servidor, ele coloca a informação dentro do próprio token, assinada para que ninguém consiga alterar. Um JWT é assim:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXBlbCI6InVzdWFyaW8iLCJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjIiLCJleHAiOjE3NjU3MTgxMDAsImlhdCI6MTc2NTcxNzIwMH0.7QzoMiwcm0pFyt-Win_jV5DYNd-sxKHxn4NK8BOz_WU

São três partes separadas por ponto, cada uma codificada em base64url:

  1. Header: o algoritmo da assinatura.

    {"alg":"HS256","typ":"JWT"}
    
  2. Payload: os dados, chamados de claims.

    {"papel":"usuario","iss":"auth-api","sub":"2","exp":1765718100,"iat":1765717200}
    

    Alguns claims têm nomes padronizados: sub (subject, o ID do usuário), iss (issuer, quem emitiu), iat (issued at, quando foi emitido) e exp (expiration, quando expira), os dois últimos em segundos desde 1970. O papel é um claim nosso.

  3. Assinatura: um HMAC-SHA256 do header + payload, calculado com uma chave secreta que só o servidor conhece.

Quando o token volta numa requisição, o servidor recalcula a assinatura com o segredo e compara. Se alguém mudar uma vírgula do payload, a assinatura não bate mais.

O JWT é assinado, não criptografado. Qualquer pessoa com o token consegue ler o payload, basta decodificar o base64 (cole o token acima no jwt.io e veja). Nunca coloque dados sensíveis no payload: nada de senha, CPF, dados de cartão.

A vantagem do JWT é que o servidor não precisa guardar nem consultar nada para validar um token, o que facilita ter várias instâncias da API ou vários serviços validando o mesmo token. A desvantagem aparece mais adiante.

# O projeto

Vamos usar três bibliotecas:

go get github.com/gin-gonic/gin@v1.11.0
go get github.com/golang-jwt/jwt/v5@v5.3.0
go get golang.org/x/crypto@v0.46.0

E quatro arquivos no mesmo pacote:

auth-api/
├── main.go        # rotas e handlers
├── middleware.go  # autenticação e autorização
├── token.go       # geração e validação do JWT
└── usuarios.go    # cadastro e verificação de senha

# Guardando senhas com bcrypt

Antes do JWT, o login. A regra número um: nunca guarde a senha do usuário, nem em texto puro, nem “criptografada”. Guardamos um hash, um resumo que não dá para reverter. No login, calculamos o hash da senha digitada e comparamos.

Mas não qualquer hash: MD5 e SHA-256 são rápidos demais. Isso é ótimo para verificar arquivos, e péssimo para senhas, porque um atacante que vazar o banco consegue testar bilhões de senhas por segundo. O bcrypt foi feito para ser lento de propósito e já inclui um salt aleatório em cada hash, então duas pessoas com a mesma senha têm hashes diferentes.

usuarios.go:

package main

import (
	"errors"
	"strconv"
	"sync"

	"golang.org/x/crypto/bcrypt"
)

type Usuario struct {
	ID        string
	Email     string
	SenhaHash []byte
	Papel     string
}

var (
	ErrEmailEmUso           = errors.New("email já cadastrado")
	ErrCredenciaisInvalidas = errors.New("email ou senha inválidos")
)

// UsuarioRepo guarda os usuários em memória para manter o exemplo simples
type UsuarioRepo struct {
	mu       sync.Mutex
	porEmail map[string]Usuario
	proximo  int
}

func NovoUsuarioRepo() *UsuarioRepo {
	return &UsuarioRepo{porEmail: make(map[string]Usuario)}
}

func (r *UsuarioRepo) Registrar(email, senha, papel string) (Usuario, error) {
	hash, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost)
	if err != nil {
		return Usuario{}, err
	}

	r.mu.Lock()
	defer r.mu.Unlock()

	if _, existe := r.porEmail[email]; existe {
		return Usuario{}, ErrEmailEmUso
	}

	r.proximo++
	u := Usuario{ID: strconv.Itoa(r.proximo), Email: email, SenhaHash: hash, Papel: papel}
	r.porEmail[email] = u
	return u, nil
}

func (r *UsuarioRepo) Autenticar(email, senha string) (Usuario, error) {
	r.mu.Lock()
	u, existe := r.porEmail[email]
	r.mu.Unlock()

	if !existe {
		return Usuario{}, ErrCredenciaisInvalidas
	}
	if err := bcrypt.CompareHashAndPassword(u.SenhaHash, []byte(senha)); err != nil {
		return Usuario{}, ErrCredenciaisInvalidas
	}
	return u, nil
}

func (r *UsuarioRepo) Total() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return len(r.porEmail)
}

Para manter o foco, os usuários ficam num map protegido por mutex (dia 18). Numa aplicação real, seria uma tabela no banco, com as queries do post anterior.

Repare que o Autenticar retorna o mesmo erro tanto quando o email não existe quanto quando a senha está errada. Se as mensagens fossem diferentes, um atacante poderia descobrir quais emails estão cadastrados.

# Gerando e validando o token

token.go:

package main

import (
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	Papel string `json:"papel"`
	jwt.RegisteredClaims
}

type TokenService struct {
	segredo []byte
	duracao time.Duration
}

func (s *TokenService) Gerar(usuarioID, papel string) (string, error) {
	agora := time.Now()
	claims := Claims{
		Papel: papel,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   usuarioID,
			IssuedAt:  jwt.NewNumericDate(agora),
			ExpiresAt: jwt.NewNumericDate(agora.Add(s.duracao)),
			Issuer:    "auth-api",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(s.segredo)
}

func (s *TokenService) Validar(tokenString string) (*Claims, error) {
	claims := &Claims{}

	_, err := jwt.ParseWithClaims(tokenString, claims,
		func(t *jwt.Token) (any, error) {
			return s.segredo, nil
		},
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithIssuer("auth-api"),
		jwt.WithExpirationRequired(),
	)
	if err != nil {
		return nil, err
	}
	return claims, nil
}

func tokenExpirado(err error) bool {
	return errors.Is(err, jwt.ErrTokenExpired)
}

Os claims são uma struct que embute o jwt.RegisteredClaims, que já tem os campos padrão (sub, exp, iat, iss…), e adiciona o Papel. Lembra da composição de structs do dia 86?

Gerar monta os claims, cria o token com o algoritmo HS256 e assina com o segredo.

Validar faz o caminho inverso. O ParseWithClaims decodifica o token, verifica a assinatura usando a chave retornada pela função anônima e confere a expiração. As opções no final são importantes:

  • WithValidMethods: aceita só o algoritmo que nós usamos. Sem isso, a biblioteca usaria o algoritmo que vem no header do token, que é controlado por quem enviou. Existe um ataque clássico em que o atacante troca o header para "alg":"none" (sem assinatura) ou para outro algoritmo e consegue passar pela validação. Vamos testar isso daqui a pouco;
  • WithIssuer: rejeita tokens emitidos por outro sistema, mesmo que por azar usem o mesmo segredo;
  • WithExpirationRequired: rejeita tokens sem exp. Sem essa opção, um token sem data de expiração seria válido para sempre.

Quando um token está expirado, o erro retornado “embrulha” o jwt.ErrTokenExpired, e conseguimos identificar o caso com errors.Is para devolver uma mensagem mais clara.

# Os middlewares

Um middleware no Gin é um gin.HandlerFunc que roda antes do handler da rota. Ele pode deixar a requisição seguir com c.Next() ou interrompê-la com c.Abort(). É o lugar perfeito para a autenticação: escrevemos uma vez e aplicamos em todas as rotas que precisam.

middleware.go:

package main

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
)

func AuthMiddleware(tokens *TokenService) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		tokenString, ok := strings.CutPrefix(header, "Bearer ")
		if !ok || tokenString == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"erro": "token não informado"})
			return
		}

		claims, err := tokens.Validar(tokenString)
		if err != nil {
			msg := "token inválido"
			if tokenExpirado(err) {
				msg = "token expirado"
			}
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"erro": msg})
			return
		}

		c.Set("usuario_id", claims.Subject)
		c.Set("papel", claims.Papel)
		c.Next()
	}
}

func ExigirPapel(papel string) gin.HandlerFunc {
	return func(c *gin.Context) {
		if c.GetString("papel") != papel {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"erro": "acesso negado"})
			return
		}
		c.Next()
	}
}

AuthMiddleware (autenticação):

  1. Lê o header Authorization, que por convenção tem o formato Bearer <token>. O strings.CutPrefix remove o "Bearer " e diz se o prefixo existia;
  2. Valida o token;
  3. Guarda o ID e o papel do usuário no contexto do Gin com c.Set, para os handlers usarem depois;
  4. Chama c.Next() para seguir para o próximo middleware ou para o handler.

Se algo der errado, o AbortWithStatusJSON responde com 401 e interrompe a cadeia: nenhum handler depois dele é executado. Um erro comum é esquecer o return depois do Abort: o Abort impede os próximos handlers, mas o resto da função atual continua rodando.

ExigirPapel (autorização) é uma função que retorna um middleware, o que permite configurá-lo: ExigirPapel("admin"), ExigirPapel("financeiro")… Ele confia que o AuthMiddleware já rodou antes e colocou o papel no contexto. Se o papel não for o exigido, responde 403.

O gin.Context tem o seu próprio Set/Get, que é o mais prático dentro do Gin. Se a informação precisar chegar em camadas que recebem um context.Context padrão (o repositório, o slog do dia 94…), o caminho é o context.WithValue no c.Request, como fizemos com o request ID.

# As rotas

main.go:

package main

import (
	"errors"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/gin-gonic/gin"
)

type credenciais struct {
	Email string `json:"email" binding:"required,email"`
	Senha string `json:"senha" binding:"required,min=8"`
}

func main() {
	segredo := os.Getenv("JWT_SEGREDO")
	if len(segredo) < 32 {
		log.Fatal("JWT_SEGREDO precisa ter pelo menos 32 caracteres")
	}

	tokens := &TokenService{segredo: []byte(segredo), duracao: 15 * time.Minute}
	if d, err := time.ParseDuration(os.Getenv("JWT_DURACAO")); err == nil {
		tokens.duracao = d
	}

	usuarios := NovoUsuarioRepo()
	if _, err := usuarios.Registrar("admin@exemplo.com", "senha-do-admin", "admin"); err != nil {
		log.Fatal(err)
	}

	r := gin.Default()

	r.POST("/registrar", func(c *gin.Context) {
		var req credenciais
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"erro": err.Error()})
			return
		}

		u, err := usuarios.Registrar(req.Email, req.Senha, "usuario")
		if errors.Is(err, ErrEmailEmUso) {
			c.JSON(http.StatusConflict, gin.H{"erro": err.Error()})
			return
		}
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"erro": "erro interno"})
			return
		}
		c.JSON(http.StatusCreated, gin.H{"id": u.ID, "email": u.Email})
	})

	r.POST("/login", func(c *gin.Context) {
		var req credenciais
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"erro": err.Error()})
			return
		}

		u, err := usuarios.Autenticar(req.Email, req.Senha)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"erro": err.Error()})
			return
		}

		token, err := tokens.Gerar(u.ID, u.Papel)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"erro": "erro interno"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"token": token})
	})

	autenticado := r.Group("/", AuthMiddleware(tokens))
	{
		autenticado.GET("/perfil", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{
				"usuario_id": c.GetString("usuario_id"),
				"papel":      c.GetString("papel"),
			})
		})

		admin := autenticado.Group("/admin", ExigirPapel("admin"))
		admin.GET("/relatorio", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"usuarios_cadastrados": usuarios.Total()})
		})
	}

	log.Fatal(r.Run(":8097"))
}

Alguns pontos:

  • O segredo vem de variável de ambiente (dia 41) e nunca deve ser commitado. Para o HS256, ele deve ter pelo menos 32 bytes aleatórios. Um bom jeito de gerar é openssl rand -base64 32;
  • O token dura 15 minutos. O JWT_DURACAO existe só para conseguirmos testar a expiração;
  • binding:"required,email" e min=8: o Gin valida o JSON recebido com essas tags antes de chegar na nossa lógica;
  • Grupos de rotas: /registrar e /login ficam fora de qualquer grupo, são públicas. O grupo autenticado aplica o AuthMiddleware em tudo que está dentro dele. E o grupo admin, dentro do autenticado, adiciona o ExigirPapel("admin"). Uma requisição para /admin/relatorio passa pelos dois middlewares, nessa ordem, antes de chegar no handler.

# Testando

Subindo a API:

export JWT_SEGREDO="um-segredo-bem-grande-com-mais-de-32-caracteres"
go run .

Cadastro, incluindo um email repetido e dados inválidos:

$ curl -s -X POST localhost:8097/registrar -d '{"email":"victor@exemplo.com","senha":"minha-senha-123"}'
{"email":"victor@exemplo.com","id":"2"}

$ curl -s -X POST localhost:8097/registrar -d '{"email":"victor@exemplo.com","senha":"outra-senha-123"}'
{"erro":"email já cadastrado"}

$ curl -s -X POST localhost:8097/registrar -d '{"email":"nao-e-email","senha":"123"}'
{"erro":"Key: 'credenciais.Email' Error:Field validation for 'Email' failed on the 'email' tag\nKey: 'credenciais.Senha' Error:Field validation for 'Senha' failed on the 'min' tag"}

O ID é 2 porque o admin, cadastrado na inicialização, ficou com o 1. A mensagem de validação do Gin não é das mais amigáveis; numa API real vale traduzir esses erros para algo mais legível.

Login, com senha errada e com a senha certa:

$ curl -s -X POST localhost:8097/login -d '{"email":"victor@exemplo.com","senha":"senha-errada"}'
{"erro":"email ou senha inválidos"}

$ curl -s -X POST localhost:8097/login -d '{"email":"victor@exemplo.com","senha":"minha-senha-123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXBlbCI6InVzdWFyaW8iLCJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjIiLCJleHAiOjE3NjU3MTgxMDAsImlhdCI6MTc2NTcxNzIwMH0.7QzoMiwcm0pFyt-Win_jV5DYNd-sxKHxn4NK8BOz_WU"}

Guardando o token numa variável para facilitar:

TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXBlbCI6InVzdWFyaW8iLCJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjIiLCJleHAiOjE3NjU3MTgxMDAsImlhdCI6MTc2NTcxNzIwMH0.7QzoMiwcm0pFyt-Win_jV5DYNd-sxKHxn4NK8BOz_WU"

Rotas protegidas (o -w ' %{http_code}' faz o curl mostrar o status no final):

$ curl -s -w ' %{http_code}' localhost:8097/perfil
{"erro":"token não informado"} 401

$ curl -s -w ' %{http_code}' -H "Authorization: Bearer $TOKEN" localhost:8097/perfil
{"papel":"usuario","usuario_id":"2"} 200

$ curl -s -w ' %{http_code}' -H "Authorization: Bearer $TOKEN" localhost:8097/admin/relatorio
{"erro":"acesso negado"} 403

Sem token, 401. Com token, o /perfil responde com os dados que o middleware tirou do token. E no /admin/relatorio o usuário é autenticado, mas não tem o papel admin: 403.

Fazendo login com o admin, o relatório é liberado:

$ curl -s -w ' %{http_code}' -H "Authorization: Bearer $TOKEN_ADMIN" localhost:8097/admin/relatorio
{"usuarios_cadastrados":2} 200

## Tentando burlar

Agora a parte divertida. Como o payload é só base64, um usuário mal-intencionado pode decodificar o próprio token, trocar "papel":"usuario" por "papel":"admin" e montar um token novo mantendo a assinatura original:

# payload alterado: {"exp":1765718100,"iat":1765717200,"iss":"auth-api","papel":"admin","sub":"2"}
$ curl -s -w ' %{http_code}' -H "Authorization: Bearer $TOKEN_ADULTERADO" localhost:8097/admin/relatorio
{"erro":"token inválido"} 401

Rejeitado: a assinatura foi calculada sobre o payload original e não bate com o novo. Para gerar uma assinatura válida seria preciso conhecer o segredo.

E o ataque do alg: none, em que o header diz que o token não tem assinatura e a terceira parte vai vazia:

# header: {"alg":"none","typ":"JWT"}
$ curl -s -w ' %{http_code}' -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.<payload com papel admin>." localhost:8097/admin/relatorio
{"erro":"token inválido"} 401

Também rejeitado, graças ao WithValidMethods.

Expiração: subindo a API com JWT_DURACAO=2s, fazendo login e esperando 3 segundos antes de usar o token:

$ curl -s -w ' %{http_code}' -H "Authorization: Bearer $TOKEN" localhost:8097/perfil
{"erro":"token expirado"} 401

# Cuidados com JWT

Não dá para “deslogar” um JWT. Lembra que a vantagem do JWT é o servidor não guardar nada? A desvantagem é a mesma: se um token vazar, ou se o usuário for bloqueado, o token continua válido até expirar, porque o servidor não tem onde marcar que ele foi cancelado. Por isso tokens de acesso devem ter vida curta (5 a 15 minutos).

Refresh tokens. Para o usuário não precisar fazer login a cada 15 minutos, o padrão é emitir dois tokens no login: o access token, curto, usado em todas as requisições, e um refresh token, longo (dias), usado só para pedir um novo access token. O refresh token fica guardado no banco, então esse pode ser revogado. É um bom exercício para estender o exemplo.

Onde o front-end guarda o token. No localStorage ele fica acessível a qualquer JavaScript da página, então uma falha de XSS permite roubá-lo. A alternativa mais segura é um cookie HttpOnly + Secure + SameSite, que o JavaScript não consegue ler.

Sempre HTTPS. O token é uma credencial: quem o tiver, é o usuário até ele expirar. Sem HTTPS, ele trafega em texto puro.

HS256 ou RS256? Usamos o HS256, em que o mesmo segredo assina e valida. Se vários serviços precisarem validar os tokens, todos teriam que conhecer o segredo, e qualquer um deles poderia emitir tokens. Nesse cenário se usa o RS256 ou o ES256: uma chave privada, só no serviço de autenticação, assina; e uma chave pública, que pode ser distribuída, valida. Com a golang-jwt a mudança é pequena: trocar o SigningMethod e as chaves.

E, por fim, autenticação é uma área em que erros custam caro. Para produção, vale considerar um provedor pronto (Keycloak, Auth0, Cognito, Firebase Auth…) e só validar os tokens emitidos por ele.

# Referências

  1. golang-jwt/jwt
  2. RFC 7519 - JSON Web Token
  3. Documentação bcrypt
  4. Gin - Using middleware
  5. OWASP - Password Storage Cheat Sheet
  6. OWASP - JSON Web Token Cheat Sheet