Lección: Práctica: Añadir autenticación JWT a la API de usuarios
En esta lección práctica, integraremos un sistema de autenticación basado en JSON Web Tokens (JWT) en nuestra API de usuarios construida con Go y gorilla/mux. Pasaremos de una API abierta a una segura, donde las operaciones sensibles (como crear, actualizar o eliminar usuarios) requieran que el cliente demuestre su identidad presentando un token válido. Este es un paso fundamental para cualquier API de producción que maneje datos privados.
Implementaremos un flujo completo: un endpoint para el login que genere el token, la lógica para validar ese token en cada solicitud protegida usando middleware, y la estructura para incluir información del usuario (claims) dentro del propio token. Utilizaremos el paquete popular github.com/golang-jwt/jwt/v5 para la manipulación de JWT.
Concepto clave: JWT y Middleware de Autenticación
Un JSON Web Token (JWT) es un estándar abierto (RFC 7519) que define una forma compacta y autónoma de transmitir información de manera segura entre partes como un objeto JSON. Puede ser firmado digitalmente (usando un secreto HMAC o un par de claves RSA/ECDSA) para verificar su integridad. En el contexto de una API REST, el JWT actúa como una credencial de portador. Tras un login exitoso, el servidor emite un token que el cliente debe enviar en el encabezado Authorization de todas las solicitudes subsiguientes a endpoints protegidos.
Piensa en un JWT como el pase de acceso temporal que recibes en un evento después de mostrar tu identificación en la entrada. Una vez dentro, no necesitas volver a mostrar tu DNI en cada puesto; solo muestras el pase. El organizador del evento (nuestro servidor) confía en que el pase es válido y legítimo porque lo emitió él mismo con características de seguridad difíciles de falsificar. El middleware de autenticación es el guardia de seguridad en cada puerta interior que revisa tu pase antes de permitirte entrar a una zona VIP (un endpoint protegido).
Un JWT está compuesto por tres partes separadas por puntos: Header (algoritmo y tipo de token), Payload (los "claims" o datos, como el ID de usuario y fecha de expiración), y Signature (la firma que lo valida). El middleware en nuestro servidor se encargará de extraer el token del encabezado HTTP, verificar su firma y su fecha de expiración, y, si es válido, adjuntar la información del usuario (claims) al contexto de la solicitud para que los handlers finales puedan usarla.
Cómo funciona en la práctica: Flujo de autenticación paso a paso
El flujo comienza cuando un usuario (o cliente de la API) envía sus credenciales, típicamente un nombre de usuario y contraseña, a un endpoint dedicado como /api/login. Nuestro handler de login valida estas credenciales contra nuestra base de datos. Si son correctas, procede a generar el JWT. Para ello, definimos unos "claims", que son los datos que queremos almacenar dentro del token, como el ID del usuario y un tiempo de expiración (exp). Luego, firmamos estos claims usando una clave secreta que solo conoce el servidor, generando la cadena final del token que se envía como respuesta al cliente.
El cliente, al recibir el token, debe almacenarlo localmente (por ejemplo, en el almacenamiento local de un navegador o en la memoria de una aplicación móvil) e incluirlo en el encabezado Authorization de todas las solicitudes futuras a rutas protegidas. La convención es usar el esquema Bearer: Authorization: Bearer <tu_token_jwt>. En el servidor, definiremos una función de middleware que se ejecutará antes de llegar al handler de la ruta solicitada. Esta función hará lo siguiente: 1) Extraer el valor del encabezado Authorization. 2) Verificar el formato "Bearer". 3) Parsear y validar el token JWT usando nuestra clave secreta. 4) Si es válido, extraer los claims y almacenarlos en el contexto de la petición HTTP de Go.
Finalmente, los handlers de nuestros endpoints protegidos (como PUT /api/users/{id}) ya no necesitan preocuparse por la lógica de validación del token. Simplemente recuperan la información del usuario autenticado desde el contexto de la solicitud (por ejemplo, el userID) y proceden con la lógica de negocio, sabiendo que la identidad del cliente ya ha sido verificada. Este desacoplamiento es la principal ventaja del patrón middleware.
Código en acción: Implementación del Login y Middleware JWT
A continuación, veremos la implementación concreta. Primero, definiremos las estructuras y constantes necesarias, luego el handler de login, y finalmente la función middleware que protegerá nuestras rutas.
Estructuras y Configuración Inicial
Definimos las estructuras para las solicitudes de login y los claims personalizados de nuestro JWT. También configuramos una clave secreta (que en producción debe ser una variable de entorno larga y compleja).
package main
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
)
// Clave secreta para firmar los JWT. ¡DEBE SER MANTENIDA EN SECRETO!
// En producción, usa una variable de entorno.
var jwtKey = []byte("mi_super_secreto_seguro_y_largo_para_produccion_cambiar")
// Claims personalizados que incluyen el ID de usuario y los claims estándar
type CustomClaims struct {
UserID int `json:"user_id"`
jwt.RegisteredClaims
}
// Estructura para el body de la petición de login
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Estructura para la respuesta exitosa de login
type LoginResponse struct {
Token string `json:"token"`
}
Handler de Login
Este endpoint recibe credenciales, busca al usuario en la base de datos, compara la contraseña hasheada y, si es correcta, genera y devuelve un JWT.
func loginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
// Decodificar el JSON del cuerpo de la petición
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Formato de solicitud inválido", http.StatusBadRequest)
return
}
// 1. Buscar usuario en la "base de datos" (ejemplo simulado)
// En un caso real, esto sería una consulta a tu DB (ej: PostgreSQL, MySQL)
user, err := findUserByUsername(req.Username)
if err != nil {
// No revelar si el usuario no existe o la pass es incorrecta (seguridad)
http.Error(w, "Credenciales inválidas", http.StatusUnauthorized)
return
}
// 2. Comparar la contraseña proporcionada con el hash almacenado
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
http.Error(w, "Credenciales inválidas", http.StatusUnauthorized)
return
}
// 3. Definir la expiración del token (ej: 24 horas)
expirationTime := time.Now().Add(24 * time.Hour)
// 4. Crear los claims con el ID de usuario y la expiración
claims := &CustomClaims{
UserID: user.ID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: req.Username,
},
}
// 5. Generar el token firmado
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtKey)
if err != nil {
http.Error(w, "Error al generar el token", http.StatusInternalServerError)
return
}
// 6. Enviar el token en la respuesta JSON
resp := LoginResponse{Token: tokenString}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// Función de ejemplo para buscar usuario (simulada)
func findUserByUsername(username string) (*User, error) {
// Esto es un ejemplo. En la práctica, haz una consulta SQL o a tu ORM.
// Supongamos que encontramos un usuario con ID 42 y pass hash "hashed_password_abc123"
if username == "juanito" {
// El hash corresponde a la contraseña "miPass123"
hashedPass, _ := bcrypt.GenerateFromPassword([]byte("miPass123"), bcrypt.DefaultCost)
return &User{ID: 42, Username: "juanito", PasswordHash: string(hashedPass)}, nil
}
return nil, fmt.Errorf("usuario no encontrado")
}
Middleware de Autenticación JWT
Esta función se interpone antes de los handlers protegidos. Valida el token y, si es exitoso, inyecta los claims en el contexto de la petición.
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. Obtener el encabezado Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Se requiere token de autorización", http.StatusUnauthorized)
return
}
// 2. Verificar que tenga el formato "Bearer <token>"
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
http.Error(w, "Formato de autorización inválido. Usa: Bearer <token>", http.StatusUnauthorized)
return
}
tokenStr := bearerToken[1]
// 3. Parsear y validar el token
claims := &CustomClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
// Validar el algoritmo de firma
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("método de firma inesperado: %v", token.Header["alg"])
}
return jwtKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Token inválido o expirado", http.StatusUnauthorized)
return
}
// 4. Token válido. Añadir los claims al contexto de la petición.
// Creamos una nueva clave de contexto para evitar colisiones.
ctx := context.WithValue(r.Context(), "userClaims", claims)
// Llamamos al siguiente handler con el nuevo contexto.
next.ServeHTTP(w, r.WithContext(ctx))
}
}
// Handler protegido de ejemplo: Obtener perfil de usuario
func getProfileHandler(w http.ResponseWriter, r *http.Request) {
// Recuperar los claims del contexto
claims, ok := r.Context().Value("userClaims").(*CustomClaims)
if !ok {
http.Error(w, "No se pudieron obtener los datos de autenticación", http.StatusInternalServerError)
return
}
// Usar el UserID de los claims para buscar los datos del usuario
userID := claims.UserID
// ... lógica para obtener y devolver el perfil del usuario con ID `userID` ...
response := map[string]interface{}{
"message": "Perfil del usuario",
"user_id": userID,
"subject": claims.Subject,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
Integración con gorilla/mux
Finalmente, en nuestra función principal, registramos la ruta de login y protegemos las rutas sensibles envolviéndolas con nuestro middleware.
func main() {
r := mux.NewRouter()
// Ruta pública para login
r.HandleFunc("/api/login", loginHandler).Methods("POST")
// Subrouter para rutas protegidas
api := r.PathPrefix("/api").Subrouter()
// Aplicar el middleware a todas las rutas bajo /api (excepto /api/login que ya está fuera)
api.Use(func(next http.Handler) http.Handler {
// Convertimos nuestro middleware que espera un HandlerFunc a uno que use Handler.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Podríamos hacer una lógica más fina aquí para excluir algunas rutas.
// Por ahora, protegemos todo bajo /api.
authMiddleware(next.ServeHTTP)(w, r)
})
})
// Definir rutas protegidas
api.HandleFunc("/users/{id}", updateUserHandler).Methods("PUT")
api.HandleFunc("/users/{id}", deleteUserHandler).Methods("DELETE")
api.HandleFunc("/profile", getProfileHandler).Methods("GET")
// Ruta pública de ejemplo (no protegida)
r.HandleFunc("/api/users", getUsersHandler).Methods("GET")
http.ListenAndServe(":8080", r)
}
Tip de Seguridad: Nunca almacenes la clave secreta JWT (jwtKey) directamente en el código fuente. Utiliza siempre variables de entorno o un sistema de gestión de secretos (como Vault, AWS Secrets Manager) en entornos de producción. Una clave comprometida significa que cualquier persona puede generar tokens válidos.
Errores comunes y cómo evitarlos
Al implementar JWT, es fácil cometer ciertos errores que comprometen la seguridad o la funcionalidad de tu API. Aquí detallamos los más frecuentes:
- No validar el algoritmo de firma en el middleware: Al parsear el token, la función de callback debe verificar que el algoritmo usado (
token.Header["alg"]) sea el esperado (ej:HS256). Si no se hace, un atacante podría forjar un token firmado con un algoritmo diferente (comonone). Nuestro código incluye esta validación con la comprobacióntoken.Method.(*jwt.SigningMethodHMAC). - Almacenar información sensible en el payload del JWT: El payload de un JWT, aunque está codificado en Base64, no está encriptado por defecto. Cualquiera puede decodificarlo y leer su contenido. Nunca almacenes contraseñas, números de tarjetas de crédito u otros datos sensibles. Solo usa datos no sensibles como identificadores (userID) y metadatos de sesión (expiración).
- Usar claves secretas débiles o exponerlas: Una clave secreta corta o predecible es vulnerable a ataques de fuerza bruta. Genera una clave larga, aleatoria y compleja. Además, nunca la subas a repositorios de código público (GitHub). Usa variables de entorno desde el primer momento.
- Olvidar manejar la expiración del token (exp claim): La librería
jwt-govalida automáticamente la fecha de expiración (ExpiresAt) cuando se parsea el token conParseWithClaims. Asegúrate de establecer un tiempo de expiración razonable (horas o días, no meses) y de que tu sistema de reloj (y el del cliente) esté sincronizado (NTP). - No implementar un mecanismo de logout/revocación para JWTs: Por diseño, los JWT son válidos hasta que expiran. Si necesitas invalidar un token antes (por un logout seguro o una brecha de seguridad), debes implementar una lista negra de tokens (token blacklist) o usar un patrón de "token de refresco" de vida corta junto con un "token de acceso" de vida aún más corta, almacenando el estado de la sesión en el servidor para el refresh token.
Checklist de dominio
Antes de considerar esta lección completada, asegúrate de poder verificar los siguientes puntos:
- Puedo explicar las tres partes de un JWT (Header, Payload, Signature) y su propósito.
- He implementado un endpoint de login que valida credenciales y genera un JWT firmado con claims personalizados.
- He creado una función middleware que extrae, parsea y valida un token JWT del encabezado
Authorization: Bearer. - Sé cómo adjuntar información del usuario autenticado (desde los claims) al contexto de una petición HTTP en Go.
- Puedo recuperar la información del usuario desde el contexto dentro de un handler protegido.
- He integrado el middleware con gorilla/mux, aplicándolo a rutas específicas o a un subrouter.
- Comprendo los riesgos de seguridad de no validar el algoritmo de firma y de usar claves secretas débiles.
- Sé que los JWT por sí solos no son suficientes para un logout inmediato y conozco las estrategias para mitigarlo (listas negras, tokens de refresco).