← Sesión, autenticación y Spring Security

Sesión 35 · Semana 18

Identidad, sesión y permisos del producto

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado integrar el cliente ya construido en servidor. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Hasta ahora las operaciones no distinguen quién las solicita. Autenticar es comprobar identidad; autorizar es decidir qué puede hacer esa identidad. Una sesión conserva información del usuario en el servidor y una cookie permite al navegador enviar su identificador. Hoy experimentarás con ese mecanismo y definirás permisos.

La amnesia congénita del protocolo HTTP

Cuando envías una petición GET /api/v1/proyectos, el servidor abre un socket TCP, lee la petición, consulta la base de datos, envía el JSON de respuesta y cierra o reutiliza la conexión.

Si un milisegundo después envías POST /api/v1/proyectos, para el servidor eres un perfecto desconocido. HTTP no tiene memoria: no sabe si eres el mismo usuario que acaba de consultar los proyectos, si estás en la misma oficina o si eres un bot al otro lado del planeta.

El principio sin estado de HTTP

Cada petición HTTP es tratada como si fuera la primera y la única de la historia del universo.

Para construir experiencias web donde un usuario «inicia sesión» y realiza múltiples acciones consecutivas, tuvimos que inventar un mecanismo artificial de continuidad sobre un protocolo amnésico: las sesiones y las cookies.

El truco del guardarropa: Session ID y Cookies

Imagina que vas al guardarropa de un teatro:

  1. Entregas tu abrigo (haces login con usuario y contraseña).
  2. El empleado no memoriza tu cara: te entrega una ficha de plástico numerada (un identificador de sesión o Session ID, ej: ficha #4815). Tu abrigo se queda colgado en el armario del guardarropa (memoria del servidor).
  3. Cada vez que pides algo (una copa, acceso a la sala), muestras la ficha. El personal comprueba el número y te atiende.
  4. Si pierdes la ficha, o si el guardarropa arde en llamas, se pierde el vínculo.
El ciclo de vida de una sesión basada en cookies
  1. 1. Petición inicial (sin cookie)
  2. 2. Tomcat crea HttpSession (ID: ABC)
  3. 3. Respuesta con Set-Cookie: JSESSIONID=ABC
  4. 4. Siguiente petición con Cookie: JSESSIONID=ABC

Una cookie no es un simple par clave-valor; es un mensaje con instrucciones de seguridad estrictas para el navegador:

Directiva Qué hace Por qué es obligatoria en producción
HttpOnly Impide que el código JavaScript de la página (mediante document.cookie) pueda leer o modificar la cookie. Si tu aplicación sufre una vulnerabilidad XSS (inyección de script malicioso), el atacante no puede robar la cookie de sesión.
Secure Obliga al navegador a transmitir la cookie únicamente a través de conexiones cifradas HTTPS. Evita que un atacante en una red Wi-Fi pública intercepte la cookie en texto claro.
SameSite=Lax / Strict Controla si la cookie se envía en peticiones originadas desde sitios web de terceros. Es la defensa nativa de los navegadores modernos contra ataques de falsificación de peticiones en sitios cruzados (CSRF).
Path=/ Delimita qué rutas del servidor tienen acceso a la cookie. Evita que aplicaciones aisladas bajo el mismo dominio compartan identificadores de sesión.

Los dos pilares de la seguridad: AuthN y AuthZ

En conversaciones informales es muy habitual escuchar a desarrolladores mezclar estos dos conceptos. En ingeniería de software profesional, confundirlos es la puerta de entrada a fallos críticos de seguridad:

Las dos etapas del control de acceso
  1. Petición entrante
  2. 1. Autenticación (AuthN): ¿Quién eres?
  3. 2. Autorización (AuthZ): ¿Tienes permiso?
  4. Ejecución del Controlador
  • Autenticación (AuthN - Authentication): Es el proceso de verificar la identidad de un actor.
    • Responde a la pregunta: ¿Quién eres tú y cómo demuestras que eres quien dices ser?
    • Mecanismos: Usuario y contraseña, biometría, tarjeta inteligente, token criptográfico firmado.
  • Autorización (AuthZ - Authorization): Es el proceso de determinar si una identidad confirmada tiene permiso para ejecutar una acción sobre un recurso.
    • Responde a la pregunta: ¿Este usuario autenticado tiene derecho a ejecutar DELETE /api/v1/proyectos/1?
    • Mecanismos: Roles (ROLE_ADMINISTRADOR), permisos puntuales (TAREA_EDITAR), listas de control de acceso (ACL).

La regla de oro de la precedencia

La autorización no tiene sentido sin una autenticación previa.

No puedes decidir qué permisos tiene alguien si no sabes quién es. Primero se identifica al actor (AuthN); si la identidad es válida, se evalúan sus permisos (AuthZ).

La distinción semántica entre 401 y 403

Uno de los errores más frecuentes en APIs REST es devolver el código HTTP equivocado ante accesos denegados:

Código HTTP Nombre formal Significado real Cuándo se devuelve
401 Unauthorized En realidad significa Unauthenticated (No identificado o credenciales no válidas). El cliente no ha enviado ninguna credencial, o el token/contraseña que envió está caducado o es incorrecto. El cliente puede reintentar la petición aportando credenciales válidas.
403 Forbidden Identificado pero sin privilegios suficientes (Prohibido). El servidor sabe perfectamente quién es el usuario (está autenticado), pero sus roles o permisos no le permiten realizar esa operación concreta. Reintentar con las mismas credenciales no servirá de nada.

Diseño de la Matriz de Control de Acceso (RBAC)

Para que el desarrollo de la seguridad no sea caótico, el equipo de ingeniería define una Matriz RBAC (Role-Based Access Control) antes de escribir código de seguridad:

Definimos los 4 roles del sistema. Son los nombres que usará el resto del curso, del enum Rol que escribes dentro de un momento hasta el proyecto final, así que conviene fijarlos aquí y no volver a tocarlos:

  1. ANON: visitante sin autenticar. No constituye un rol de la aplicación, sino la ausencia de credenciales.
  2. DESARROLLADOR: miembro técnico del equipo.
  3. JEFE_PROYECTO: responsable de planificación y asignación.
  4. ADMINISTRADOR: administrador global de la plataforma.
Endpoint Método HTTP ANON DESARROLLADOR JEFE_PROYECTO ADMINISTRADOR
/api/v1/proyectos GET (Listar) 401 200 OK 200 OK 200 OK
/api/v1/proyectos/{id} GET (Detalle) 401 200 OK 200 OK 200 OK
/api/v1/proyectos POST (Crear) 401 403 201 Created 201 Created
/api/v1/proyectos/{id} PUT (Editar) 401 403 200 OK 200 OK
/api/v1/proyectos/{id} DELETE (Borrar) 401 403 403 204 No Content
/api/v1/proyectos/{id}/tareas GET (Tareas) 401 200 OK 200 OK 200 OK
/api/v1/proyectos/{id}/tareas POST (Crear tarea) 401 201 Created 201 Created 201 Created
/api/v1/tareas/{id} DELETE (Borrar tarea) 401 403 204 No Content 204 No Content
/api/v1/usuarios GET (Listar usuarios) 401 403 403 200 OK

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre el cliente integrado y el backend. Reproduce una operación que actualmente puede ejecutar cualquiera.
  2. Enumera perfiles de tu producto y acciones sobre cada recurso. Añade si importa quién es su propietario; esa será la matriz de permisos.
  3. Localiza el controlador de diagnóstico y la pestaña de cookies del navegador. Los ejemplos con HttpSession sirven para entender el mecanismo, no para sustituir después Spring Security por un login casero.

Paso 2 · Experimentar con HttpSession en Spring Boot

Crea controller/SesionDemoController.java como experimento separado de tus endpoints de negocio. Abre la URL directamente en el navegador y registra el contador antes de llamar desde otro origen. Después aplica las propiedades de cookie y repite. El POST de cierre se envía con el cliente HTTP o mediante fetch; escribir su URL en la barra envía GET. Este controlador explica HttpSession, no autentica a nadie: se retira del producto cuando incorpores el acceso real.

package com.ejemplo.gestor.controller;

import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/v1/sesion-demo")
public class SesionDemoController {

    @GetMapping("/visita")
    public ResponseEntity<Map<String, Object>> registrarVisita(HttpSession session) {
        // Obtenemos el contador de la memoria de sesión del servidor
        Integer visitas = (Integer) session.getAttribute("contadorVisitas");

        if (visitas == null) {
            visitas = 1;
        } else {
            visitas++;
        }

        // Guardamos el nuevo valor en la sesión
        session.setAttribute("contadorVisitas", visitas);

        return ResponseEntity.ok(Map.of(
            "sessionId", session.getId(),
            "numeroVisita", visitas,
            "esNuevaSesion", session.isNew(),
            "creadaEn", session.getCreationTime()
        ));
    }

    @PostMapping("/logout")
    public ResponseEntity<Map<String, String>> cerrarSesion(HttpSession session) {
        // Destruimos la sesión en la memoria del servidor
        session.invalidate();
        return ResponseEntity.ok(Map.of("mensaje", "Sesión invalidada con éxito"));
    }
}

En Spring Boot podemos forzar que las cookies de sesión cumplan las máximas directivas de seguridad:

# Seguridad de cookies de sesión
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=false
server.servlet.session.cookie.same-site=lax
server.servlet.session.timeout=30m

En local sin HTTPS, en producción con él

En desarrollo local mantenemos server.servlet.session.cookie.secure=false porque trabajamos sobre http://localhost. En producción con certificados TLS/HTTPS, debe ser siempre true.

Paso 3 · Inspeccionar peticiones, respuestas y cookies en DevTools

Arranca tu aplicación y realiza este experimento en tu navegador abriendo http://localhost:8080/api/v1/sesion-demo/visita:

  1. Primera petición:
    • Abre DevTools (F12) y ve a la pestaña Network.
    • Revisa en Response Headers la cabecera: Set-Cookie: JSESSIONID=XXXXX; Path=/; HttpOnly; SameSite=Lax.
    • En el JSON verás "numeroVisita": 1 y "esNuevaSesion": true.
  2. Segunda petición (recarga con F5):
    • Mira en Request Headers: el navegador ha enviado automáticamente Cookie: JSESSIONID=XXXXX.
    • En el JSON verás "numeroVisita": 2 y "esNuevaSesion": false. ¡El servidor te ha reconocido!
  3. Inspección del almacén de cookies:
    • Ve a la pestaña Application (o Almacenamiento en Firefox) → Cookieshttp://localhost:8080.
    • Comprueba que la cookie JSESSIONID tiene el checkbox de HttpOnly marcado.
  4. La prueba de la amnesia provocada:
    • Haz clic derecho sobre la cookie JSESSIONID en DevTools y selecciona Delete (Borrar).
    • Recarga la página: el servidor te asigna un nuevo JSESSIONID y el contador vuelve a empezar en 1.

Paso 4 · Si algo no sale como dice el guion

Síntoma Causa casi segura Qué mirar
El contador vuelve a 1 en cada petición La cookie no viaja de vuelta En DevTools → Application → Cookies, comprueba que existe JSESSIONID para localhost:8080
Funciona en el cliente HTTP y no en el navegador Falta credentials: 'include' En una petición a otro origen, el navegador no manda cookies salvo que se lo pidas
Con credentials: 'include' salta un error de CORS El servidor no admite credenciales allowCredentials(true) y orígenes enumerados, nunca comodín: es la sesión 33
El contador se comparte entre dos pestañas Es el comportamiento correcto La sesión es del navegador, no de la pestaña. Para verlo con otra identidad, abre una ventana de incógnito
El contador se reinicia al recompilar La sesión vive en la memoria del proceso Es exactamente el problema del reto de esta sesión

Vuelve a tu página cliente/index.html de la UD8:

  1. Haz un fetch('http://localhost:8080/api/v1/sesion-demo/visita') desde el cliente en http://localhost:5500.
  2. ¡Atención! En peticiones Cross-Origin (distinto puerto), el navegador NO envía cookies por defecto a menos que configures:
    fetch('http://localhost:8080/api/v1/sesion-demo/visita', {
      credentials: 'include' // Obliga a enviar y recibir cookies en peticiones cross-origin
    });
  3. Comprueba que el contador de visitas se incrementa en la interfaz web sin recargar la página.
  4. Localiza la cookie con tus ojos. DevTools → pestaña Application (o Almacenamiento) → Cookieshttp://localhost:8080. Ahí está JSESSIONID con su valor. Anótalo.
  5. Comprueba que la cookie es la sesión, y no otra cosa. Bórrala desde ese mismo panel y vuelve a lanzar la petición: el contador empieza de cero y aparece un JSESSIONID nuevo. El servidor no te ha reconocido, aunque eres la misma persona en el mismo ordenador. Eso es lo que significa que HTTP no tiene memoria.
  6. Comprueba que la sesión vive en el servidor. Con el contador en 5, reinicia Spring Boot sin tocar el navegador y vuelve a pedir. Vuelve a 1: la cookie sigue en tu navegador, pero los datos que apuntaba estaban en la memoria del proceso, y el proceso ha muerto. Es el mismo hecho que descubriste en la sesión 1 con las tareas en memoria, aplicado ahora a la identidad.
  7. Escribe en tres líneas la diferencia entre lo que guarda el navegador (un identificador opaco) y lo que guarda el servidor (los datos asociados). Esa distinción es la que permitirá comprender en la sesión 39 qué cambia al introducir un JWT.
Cómo saber que lo has terminado
Has visto la cookie JSESSIONID en DevTools, la has borrado y has comprobado el efecto; sabes que reiniciar el servidor pierde la sesión aunque la cookie siga; y puedes explicar por qué la misma petición desde tu cliente HTTP no arrastra estado.

Autenticación frente a autorización

Paso 6 · De la matriz al modelo conceptual en Java

Reutiliza la entidad Usuario que ya represente a los responsables; conserva id, nombre, relaciones y accesos usados por el proyecto. Añade username, hash de contraseña, rol y activo como evolución del modelo, no reemplazando la clase por un esquema incompleto. El enum Rol va en model/Rol.java. Los registros anteriores necesitan una migración antes de imponer nuevas columnas obligatorias: realiza el experimento en la base de desarrollo y prepara sus credenciales con el procedimiento de la sesión 36.

package com.ejemplo.gestor.model;

public enum Rol {
    ROLE_DESARROLLADOR,
    ROLE_JEFE_PROYECTO,
    ROLE_ADMINISTRADOR;

    // Prefijo ROLE_ requerido por conveniencia en Spring Security
}

Un usuario tiene credenciales de autenticación (username, password) y una colección de roles para autorización:

@Entity
@Table(name = "usuarios")
public class Usuario {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password; // ¡Nunca en texto plano! (lo veremos en la sesión 36)

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 30)
    private Rol rol;

    @Column(nullable = false)
    private boolean activo = true;

    // Constructores, getters y setters
}

Paso 7 · Auditoría cruzada de la matriz

Revisa la matriz de permisos y responde a estas comprobaciones de diseño:

  1. El caso del observador anónimo:
    • ¿Tiene sentido que la documentación Swagger (/swagger-ui.html) devuelva 401 o debería ser pública (200 OK)?
    • Respuesta: En entornos corporativos las especificaciones internas suelen protegerse; en APIs públicas se dejan abiertas.
  2. La coherencia del código de error:
    • Si un DESARROLLADOR autenticado intenta borrar un proyecto (DELETE /api/v1/proyectos/5), ¿por qué devolver 401 sería un grave error técnico?
    • Respuesta: Porque el usuario ya está autenticado; devolver 401 le induciría a pensar que su contraseña falló, cuando en realidad no tiene el privilegio necesario (403 Forbidden).

Paso 8 · Control de acceso basado en atributos (ABAC)

La matriz por roles (RBAC) es muy potente, pero tiene un límite:

  • Un DESARROLLADOR tiene permiso para editar tareas (PUT /api/v1/tareas/{id}).
  • Pero, ¿debe poder editar una tarea asignada a otro compañero? ¿O solo las tareas donde él es el responsable?

Diseña la regla de negocio para el control de acceso a nivel de fila (Row-Level Security):

  1. Añade a la entidad Tarea el campo Usuario asignadoA.
  2. Define la condición lógica: «Un usuario con rol DESARROLLADOR solo puede modificar el estado de una tarea si tarea.asignadoA.id == usuarioAutenticado.id».
  3. Si intenta editar la tarea de otro desarrollador, ¿qué código HTTP debe responder la API?
  4. Construye la matriz completa de la que va a depender el resto de la unidad. Una fila por cada combinación de endpoint y método, una columna por rol, y en cada casilla el código de estado exacto. Son las 9 filas de la tabla de esta sesión, y ese documento se convierte en:
    • la configuración que escribes en la sesión 36,
    • las anotaciones @PreAuthorize de la sesión 38,
    • y los tests automáticos de la sesión 38. Si la matriz está mal, la implementación de seguridad construye sobre un error, así que merece la pena discutirla ahora.
  5. Resuelve las cuatro casillas que siempre generan debate, y anota la razón de cada decisión:
    • ¿Un DESARROLLADOR puede ver los proyectos que no son suyos, aunque no pueda editarlos?
    • ¿Un JEFE_PROYECTO puede borrar un proyecto, o eso solo el ADMINISTRADOR?
    • ¿Alguien que no sea ADMINISTRADOR puede listar los usuarios del sistema?
    • ¿La documentación Swagger es pública o exige credenciales?
  6. El dilema del 403 frente al 404. Si un usuario pide un recurso que existe pero no le pertenece, un 403 le confirma que ese recurso existe. Con identificadores numéricos secuenciales, alguien puede recorrer 1, 2, 3… y averiguar cuántos proyectos tiene la empresa sin ver ninguno. Devolver 404 lo oculta, a costa de un mensaje de error más confuso para el usuario legítimo. Elige una de las dos, aplícala de forma coherente en toda la matriz y déjalo escrito: es una de las preguntas clásicas de la defensa de la UD12.
Cómo saber que lo has terminado
Tienes una tabla con todas las casillas rellenas, sin ninguna «depende»; las cuatro decisiones polémicas están tomadas y justificadas por escrito; y has elegido entre 403 y 404 con un criterio que puedes defender.

Paso 9 · Comprobar y registrar el resultado del proyecto

  1. Observa cuándo se crea y envía la cookie de sesión y qué ocurre al iniciar otro contexto de navegador sin esa cookie.
  2. Revisa la matriz con otra persona: debe decidir para cada acción si requiere identidad, rol y propiedad, sin dejar casos ambiguos.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · El coste de la sesión: ¿Qué pasa al escalar a 5 servidores?

El almacenamiento de sesiones en memoria tiene un enemigo mortal: el escalado horizontal.

Imagina que tu empresa tiene tanto éxito que un solo servidor Spring Boot no da abasto y pones un balanceador de carga (Nginx o AWS ALB) delante de dos instancias del backend (Servidor 1 y Servidor 2):

  1. El usuario hace login en el Servidor 1. El Servidor 1 guarda su sesión en su memoria RAM local y emite JSESSIONID=ABC.
  2. En la siguiente petición, el balanceador desvía el tráfico al Servidor 2.
  3. ¿Qué ocurre en el Servidor 2 cuando recibe JSESSIONID=ABC? ¿Tiene esa sesión en su memoria local?
  4. Investiga las dos soluciones industriales a este problema: Sesiones pegajosas (Sticky Sessions) frente a un Almacén centralizado en memoria (Spring Session con Redis).
Objetivo mínimoControlador de sesión implementado y cabeceras Set-Cookie y Cookie inspeccionadas en DevTools.
Si lo tienesConsumo desde el cliente web con credentials: 'include' y directivas HttpOnly verificadas.
RetoProblema de escalabilidad de sesiones en memoria y solución con Spring Session / Redis comprendido y argumentado.
Ver respuestas

1 · Porque el protocolo no conserva ninguna información ni contexto entre peticiones sucesivas; cada mensaje se procesa de forma totalmente independiente.

2 · Solo una clave opaca aleatoria (un puntero); los datos reales del usuario (atributos de sesión) permanecen almacenados en la memoria del servidor.

3 · El navegador devuelve una cadena vacía u oculta la cookie, impidiendo que el código JavaScript del cliente acceda a ella y neutralizando el robo de sesión por XSS.

4 · Porque por la Política del Mismo Origen el navegador restringe el envío automático de credenciales a orígenes externos por defecto para evitar fugas involuntarias de sesión.

Reto · Matriz formal de seguridad de la aplicación

En entornos profesionales de desarrollo seguro (como los marcos ISO 27001 o ENS - Esquema Nacional de Seguridad), la seguridad no puede dejarse a la improvisación.

Elabora una matriz formal de control de accesos completa para el sistema:

  1. Incluye las operaciones sobre las entidades de Proyecto, Tarea, Comentario y Usuario.
  2. Define con precisión el código de respuesta HTTP esperado ante cada escenario (éxito, anónimo, rol insuficiente y recurso inexistente).
  3. Evalúa el compromiso entre seguridad y divulgación de información: ¿debe responderse 403 Forbidden o 404 Not Found cuando un usuario no tiene permiso para saber siquiera si un recurso existe?

Formato de entrega

Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.

Objetivo mínimoDiferenciación conceptual y semántica entre AuthN y AuthZ comprendida (401 vs 403).
Si lo tienesMatriz de roles y permisos (RBAC) diseñada y enum de roles definido en Java.
RetoExtensión de la matriz con reglas de seguridad a nivel de fila (ABAC) y justificación de respuestas 403 vs 404.
Ver respuestas

1 · Autenticación es verificar la identidad del actor (quién es); autorización es comprobar si esa identidad verificada tiene derecho a ejecutar una acción concreta.

2 · Cuando el usuario ya está autenticado con éxito, pero sus roles o permisos actuales no le otorgan privilegios suficientes para la acción solicitada.

3 · Por una imprecisión histórica en los primeros borradores de la especificación RFC de HTTP en los años 90; conceptualmente el 401 indica falta de credenciales válidas.

4 · Role-Based Access Control: Control de Acceso Basado en Roles.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

La matriz incluye usuario anónimo, usuario autenticado, recurso propio y recurso ajeno.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.