Capítulo 02 · Base
JWT, autenticación y autorización
Antes de servir un solo plato hay que saber quién está en la cocina. Este capítulo explica cómo el sistema sabe quién eres (autenticación), qué puedes hacer (autorización) y cómo un token JWT transporta esa identidad en cada petición.
2.1 Autenticación vs autorización
Son dos preguntas distintas que el backend responde en dos momentos distintos:
Autenticación · ¿quién eres?
- Ocurre una vez, en el login
- El usuario demuestra su identidad (usuario + contraseña, PIN de empleado, etc.)
- Si falla →
401 Unauthorized - Resultado: el servidor emite un token que representa "esta persona ya se identificó"
Autorización · ¿puedes hacer esto?
- Ocurre en cada petición
- El servidor lee el rol/permisos del token y decide si esa operación está permitida
- Si falla →
403 Forbidden - Ejemplo: un MESERO puede crear pedidos pero no borrar platillos del menú
Memoriza el par de códigos, porque tus apps deben reaccionar distinto a cada uno: 401 = "no sé quién eres" (el token falta o expiró → manda al usuario a la pantalla de login), 403 = "sé quién eres y no puedes" (muestra "no tienes permiso" y no insistas).
2.2 ¿Por qué tokens y no sesiones?
Hay dos grandes estrategias para "recordar" que un usuario ya se autenticó:
| Sesiones (cookie + estado en servidor) | Tokens (JWT, sin estado) | |
|---|---|---|
| Dónde vive la verdad | El servidor guarda una tabla de sesiones activas; la cookie solo lleva el ID | Todo va dentro del token, firmado; el servidor no guarda nada |
| Clientes ideales | Navegadores (las cookies son automáticas) | Apps móviles y de escritorio (manejan el header Authorization a mano) |
| Escalar a varios servidores | Requiere compartir el almacén de sesiones | Trivial: cualquier servidor con la clave puede verificar |
| Revocar acceso al instante | Fácil: borras la sesión | Difícil: el token vale hasta que expira (por eso se hacen de vida corta) |
| WebSocket | Incómodo | Natural: mandas el token al conectar |
Para nuestro sistema — clientes Flutter y Tauri, WebSockets, posiblemente varios dispositivos — la elección natural es JWT: los clientes no-navegador no tienen manejo automático de cookies, y el header Authorization funciona igual en fetch, dio y el handshake del WebSocket.
2.3 Anatomía de un JWT
Un JWT (JSON Web Token) es un texto de tres partes separadas por puntos: header.payload.firma. Las dos primeras son JSON codificado en Base64Url (¡no cifrado! cualquiera puede leerlas); la tercera es una firma criptográfica que garantiza que nadie las alteró.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxNCIsIm5vbWJyZSI6… . SflKxwRJSMeKKF2QT4…
└──────────── header ───────────────┘ └──────────── payload ───────┘ └────── firma ──────┘
header (decodificado): payload (decodificado):
{ {
"alg": "HS256", "sub": "14", ← ID del usuario (subject)
"typ": "JWT" "nombre": "Ana Reyes",
} "rol": "MESERO", ← claim propio: autorización
"iat": 1785424800, ← emitido en (issued at)
"exp": 1785428400 ← expira en (unix timestamp)
}
firma = HMACSHA256( base64url(header) + "." + base64url(payload),
CLAVE_SECRETA_DEL_SERVIDOR )
Los campos del payload se llaman claims. Algunos son estándar (sub, iat, exp, iss) y puedes agregar los tuyos (rol, nombre). La magia está en la firma:
- Solo el servidor conoce la
CLAVE_SECRETA. Si alguien modifica el payload (por ejemplo, cambia"rol": "MESERO"por"ADMIN"), la firma ya no coincide y el servidor rechaza el token. - Por eso el servidor no necesita base de datos para verificar: recalcula la firma y compara. Es una operación de microsegundos.
- Y por eso nunca pongas secretos en el payload (contraseñas, datos sensibles): es legible por cualquiera que tenga el token. Pégalo en jwt.io y lo verás decodificado.
La clave secreta es lo más valioso de tu backend. Quien la tenga puede fabricar tokens con cualquier rol. Guárdala en una variable de entorno (JWT_SECRET), nunca en el código ni en el repositorio, y hazla larga y aleatoria (mínimo 32 bytes para HS256; genérala con openssl rand -base64 48).
2.4 El flujo completo de login
El sistema usa dos tokens porque hay una tensión: un token de vida larga es cómodo (no pides login cada rato) pero peligroso (si lo roban, vale por semanas). La solución estándar:
- Access token — vida corta (15–60 min). Viaja en cada petición. Si lo roban, expira pronto.
- Refresh token — vida larga (días/semanas). Solo se usa contra
/api/auth/refreshpara conseguir un access token nuevo. El servidor sí lo guarda en base de datos, lo que permite revocarlo (cerrar sesión remota, despedir a un empleado).
Implementa la renovación como un interceptor: en axios/dio, un interceptor de respuestas detecta el 401, llama a /refresh, reintenta la petición original con el token nuevo y solo si el refresh también falla manda al usuario al login. Así ninguna pantalla de tu app tiene que preocuparse por la expiración. Verás el interceptor de dio en el capítulo 5.
Dónde guardar los tokens en cada cliente
| Cliente | Dónde | Notas |
|---|---|---|
| Flutter | flutter_secure_storage | Usa Keystore (Android) / Keychain (iOS): cifrado por el sistema operativo. |
| Tauri (escritorio) | plugin tauri-plugin-store o el keyring del SO (crate keyring) | Mejor que localStorage: sobrevive a limpiar el webview y puede cifrarse. |
| Web pura (si algún día) | cookie HttpOnly; Secure; SameSite | localStorage es legible por cualquier script → vulnerable a XSS. En una app interna es un riesgo aceptado por muchos equipos, pero conócelo. |
2.5 Los roles del restaurante
La autorización de este sistema se basa en roles (RBAC: role-based access control). Cada usuario tiene exactamente un rol, y cada endpoint declara qué roles admite:
| Operación | ADMIN | MESERO | COCINA | REPARTIDOR |
|---|---|---|---|---|
Gestionar menú y precios (/api/platillos) | ✔ | — | — | — |
Crear pedidos (POST /api/pedidos) | ✔ | ✔ | — | — |
| Cambiar estado a EN_PREPARACION / LISTO | ✔ | — | ✔ | — |
| Marcar ENTREGADO | ✔ | ✔ | — | ✔ |
| Pedir impresión de ticket | ✔ | ✔ | ✔ | — |
Enviar ubicación (/ws/ubicaciones) | — | — | — | ✔ |
| Ver mapa de repartidores y reportes | ✔ | — | — | — |
Esta tabla es tu especificación de autorización. Escríbela antes de programar: cada fila se convertirá en una regla en el código del backend, y las apps la usan para ocultar botones que el rol no puede usar (recordando que ocultar el botón es cortesía; la regla real vive en el servidor).
2.6 Implementación en el backend
La estructura es la misma en cualquier tecnología: (1) un endpoint de login que verifica credenciales y firma tokens, (2) un middleware/filtro que intercepta cada petición, valida el token y anota quién es el usuario, y (3) reglas por ruta que exigen roles.
// dependencia: io.jsonwebtoken:jjwt (api, impl, jackson) 0.12.x
@Service
public class JwtService {
// inyectada desde variable de entorno JWT_SECRET (¡nunca en el código!)
@Value("${jwt.secret}")
private String secreto;
private SecretKey clave() {
return Keys.hmacShaKeyFor(secreto.getBytes(StandardCharsets.UTF_8));
}
public String generarAccessToken(Usuario usuario) {
return Jwts.builder()
.subject(String.valueOf(usuario.getId()))
.claim("rol", usuario.getRol().name()) // ADMIN, MESERO, …
.claim("nombre", usuario.getNombre())
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plus(30, ChronoUnit.MINUTES)))
.signWith(clave())
.compact();
}
/** Devuelve los claims si el token es válido; lanza JwtException si no. */
public Claims validar(String token) {
return Jwts.parser()
.verifyWith(clave())
.build()
.parseSignedClaims(token) // verifica firma Y expiración
.getPayload();
}
}
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtFilter(JwtService jwtService) { this.jwtService = jwtService; }
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
Claims claims = jwtService.validar(header.substring(7));
// "presentamos" al usuario ante Spring Security:
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), // principal = id del usuario
null,
List.of(new SimpleGrantedAuthority(
"ROLE_" + claims.get("rol", String.class))));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
// token inválido o expirado: no autenticamos y la
// configuración de abajo responderá 401 en rutas protegidas
}
}
chain.doFilter(req, res);
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filtros(HttpSecurity http, JwtFilter jwtFilter)
throws Exception {
return http
.csrf(csrf -> csrf.disable()) // API sin cookies: CSRF no aplica
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/pedidos")
.hasAnyRole("MESERO", "ADMIN")
.requestMatchers("/api/platillos/**").hasRole("ADMIN")
.requestMatchers("/api/pedidos/*/estado")
.hasAnyRole("COCINA", "MESERO", "REPARTIDOR", "ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UsuarioRepository usuarios;
private final PasswordEncoder encoder;
private final JwtService jwt;
// constructor con los tres…
public record LoginDTO(@NotBlank String usuario, @NotBlank String password) {}
public record TokensDTO(String accessToken, String refreshToken) {}
@PostMapping("/login")
public TokensDTO login(@Valid @RequestBody LoginDTO dto) {
Usuario u = usuarios.findByUsuario(dto.usuario())
.orElseThrow(() -> new CredencialesInvalidasException());
// comparamos contra el hash BCrypt guardado, jamás texto plano
if (!encoder.matches(dto.password(), u.getPasswordHash())) {
throw new CredencialesInvalidasException(); // → 401
}
return new TokensDTO(
jwt.generarAccessToken(u),
refreshTokenService.emitirYGuardar(u)); // este sí va a la BD
}
}
// go get github.com/golang-jwt/jwt/v5 github.com/gin-gonic/gin
package auth
import (
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secreto = []byte(os.Getenv("JWT_SECRET")) // ¡nunca en el código!
type Claims struct {
Rol string `json:"rol"`
Nombre string `json:"nombre"`
jwt.RegisteredClaims
}
func GenerarAccessToken(usuarioID int64, rol, nombre string) (string, error) {
claims := Claims{
Rol: rol,
Nombre: nombre,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", usuarioID),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secreto)
}
// Validar verifica firma y expiración; devuelve los claims si todo está bien.
func Validar(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
func(t *jwt.Token) (any, error) { return secreto, nil })
if err != nil || !token.Valid {
return nil, fmt.Errorf("token inválido: %w", err)
}
return token.Claims.(*Claims), nil
}
// AuthRequired valida el token y guarda los claims en el contexto.
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(401, gin.H{"error": "falta el token"})
return
}
claims, err := Validar(strings.TrimPrefix(header, "Bearer "))
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "token inválido o expirado"})
return
}
c.Set("usuarioID", claims.Subject) // disponible para los handlers
c.Set("rol", claims.Rol)
c.Next()
}
}
// RequiereRol se apila después de AuthRequired en rutas restringidas.
func RequiereRol(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
rol := c.GetString("rol")
if !slices.Contains(roles, rol) {
c.AbortWithStatusJSON(403, gin.H{"error": "rol sin permiso"})
return
}
c.Next()
}
}
func main() {
r := gin.Default()
// rutas públicas
r.POST("/api/auth/login", auth.Login)
r.POST("/api/auth/refresh", auth.Refresh)
// rutas protegidas: el middleware corre antes que cada handler
api := r.Group("/api", auth.AuthRequired())
{
api.POST("/pedidos",
auth.RequiereRol("MESERO", "ADMIN"), pedidos.Crear)
api.PATCH("/pedidos/:id/estado",
auth.RequiereRol("COCINA", "MESERO", "REPARTIDOR", "ADMIN"),
pedidos.CambiarEstado)
api.Any("/platillos", auth.RequiereRol("ADMIN"), platillos.Handler)
}
r.Run(":8080")
}
// go get golang.org/x/crypto/bcrypt
type LoginDTO struct {
Usuario string `json:"usuario" binding:"required"`
Password string `json:"password" binding:"required"`
}
func Login(c *gin.Context) {
var dto LoginDTO
if err := c.ShouldBindJSON(&dto); err != nil {
c.JSON(400, gin.H{"error": "usuario y password son requeridos"})
return
}
u, err := repositorio.BuscarPorUsuario(dto.Usuario)
// comparamos contra el hash BCrypt guardado, jamás texto plano.
// Nota: mismo mensaje si el usuario no existe o la contraseña falla,
// para no revelar cuáles usuarios existen.
if err != nil || bcrypt.CompareHashAndPassword(
[]byte(u.PasswordHash), []byte(dto.Password)) != nil {
c.JSON(401, gin.H{"error": "credenciales inválidas"})
return
}
access, _ := GenerarAccessToken(u.ID, u.Rol, u.Nombre)
refresh, _ := EmitirYGuardarRefresh(u.ID) // este sí va a la BD
c.JSON(200, gin.H{"accessToken": access, "refreshToken": refresh})
}
2.7 Autenticar el WebSocket
El constructor WebSocket del navegador y web_socket_channel de Flutter no permiten mandar headers personalizados en el handshake, así que el patrón Authorization: Bearer no siempre está disponible. Las dos soluciones habituales:
- Token en la query string (la más simple y la que usa esta guía):
ws://servidor:8080/ws/cocina?token=eyJ…. El servidor valida el token antes de aceptar el upgrade y rechaza la conexión con 401 si es inválido. Precaución: las URLs pueden quedar en logs; en una red local interna es un riesgo bajo y aceptado. - Primer mensaje de autenticación: el servidor acepta la conexión pero no envía nada hasta recibir
{"type":"AUTH","token":"…"}en N segundos, o corta.
func WsCocina(c *gin.Context) {
claims, err := auth.Validar(c.Query("token"))
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "token inválido"})
return // nunca llegamos a hacer el upgrade
}
if claims.Rol != "COCINA" && claims.Rol != "ADMIN" {
c.AbortWithStatusJSON(403, gin.H{"error": "canal solo para cocina"})
return
}
conn, _ := upgrader.Upgrade(c.Writer, c.Request, nil)
hub.Registrar("cocina", conn) // el hub del capítulo 3
}
En Spring con STOMP existe una tercera vía elegante: el cliente manda el token como header del frame CONNECT de STOMP (que sí admite headers, porque es un protocolo sobre el WebSocket) y un ChannelInterceptor lo valida. Verás la configuración STOMP en el capítulo 3.3.