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:
Header: o algoritmo da assinatura.
{"alg":"HS256","typ":"JWT"}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) eexp(expiration, quando expira), os dois últimos em segundos desde 1970. Opapelé um claim nosso.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 semexp. 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):
- Lê o header
Authorization, que por convenção tem o formatoBearer <token>. Ostrings.CutPrefixremove o"Bearer "e diz se o prefixo existia; - Valida o token;
- Guarda o ID e o papel do usuário no contexto do Gin com
c.Set, para os handlers usarem depois; - 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.Contexttem o seu próprioSet/Get, que é o mais prático dentro do Gin. Se a informação precisar chegar em camadas que recebem umcontext.Contextpadrão (o repositório, o slog do dia 94…), o caminho é ocontext.WithValuenoc.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_DURACAOexiste só para conseguirmos testar a expiração; binding:"required,email"emin=8: o Gin valida o JSON recebido com essas tags antes de chegar na nossa lógica;- Grupos de rotas:
/registrare/loginficam fora de qualquer grupo, são públicas. O grupoautenticadoaplica oAuthMiddlewareem tudo que está dentro dele. E o grupoadmin, dentro doautenticado, adiciona oExigirPapel("admin"). Uma requisição para/admin/relatoriopassa 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.