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 verdadEl servidor guarda una tabla de sesiones activas; la cookie solo lleva el IDTodo va dentro del token, firmado; el servidor no guarda nada
Clientes idealesNavegadores (las cookies son automáticas)Apps móviles y de escritorio (manejan el header Authorization a mano)
Escalar a varios servidoresRequiere compartir el almacén de sesionesTrivial: cualquier servidor con la clave puede verificar
Revocar acceso al instanteFácil: borras la sesiónDifícil: el token vale hasta que expira (por eso se hacen de vida corta)
WebSocketIncómodoNatural: 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:

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

App (Flutter / React) Backend 1 POST /api/auth/login {usuario, contraseña} 2 200 {accessToken (30 min), refreshToken (14 días)} 3 GET /api/pedidos Authorization: Bearer <accessToken> 200 [pedidos…] (verificó la firma, leyó el rol) 4 …30 min después… GET /api/pedidos → 401 (token expirado) 5 POST /api/auth/refresh {refreshToken} → accessToken nuevo
El access token viaja en cada petición; el refresh token solo se usa para renovar.

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:

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

ClienteDóndeNotas
Flutterflutter_secure_storageUsa 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; SameSitelocalStorage 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ónADMINMESEROCOCINAREPARTIDOR
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:

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.

2.8 Checklist de seguridad del restaurante

CHECKLIST DE SEGURIDAD [✔] Contraseñas con BCrypt, nunca texto plano [✔] JWT_SECRET en variable de entorno, 32+ bytes [✔] Access token corto (30 min) + refresh revocable [✔] 401 → reintentar con refresh → login [✔] 403 → mensaje "sin permiso", sin reintento [✔] Roles verificados en el SERVIDOR, no en la app [✔] Mismo error para usuario inexistente y     contraseña incorrecta (no revelar usuarios) [✔] HTTPS/WSS si sales de la red local [✔] Validar token ANTES del upgrade de WebSocket *** CONSERVE ESTE TICKET ***