Seguridad y Autenticación en aplicaciones web
Programación con Herramientas Modernas
Antes de arrancar
Verificá que tengas activada la opción “Disable cache” en el navegador (F12 > solapa Network)
Spring Security
Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es
Spring Security
Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es
Spring Security
Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es
Spring Security
Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es
Spring Security: Autenticación y Roles
Vamos a definir qué endpoints deben estar autenticados, y qué roles deben tener acceso a cada caso de uso
Usuarios admin: pueden ver y actualizar información
Usuarios comunes: solo pueden ver información
Usuarios sin autenticar: solo pueden intentar loguearse o ver una página con errores
Spring Security: Autenticación
¿Dónde defino los usuarios y los roles? Si los gestiono yo...
1. InMemoryUserDetailsManager: Spring Security genera una base de usuarios en memoria cada vez que se levanta el servidor.
Spring Security: Autenticación
¿Dónde defino los usuarios y los roles? Si los gestiono yo...
2. Creamos nuestras tablas de Usuarios y Roles y los integramos con Spring Security.
Spring Security: Autenticación
¿Dónde defino los usuarios y los roles? Si delego en un framework...
Keycloak: gestiona identidades y accesos (IAM), actúa como un servidor de autenticación centralizado a través del mecanismo de Single Sign On (SSO, o login unificado).
Spring Security: Autenticación
¿Dónde defino los usuarios y los roles? Si delego en un framework...
LDAP / Active Directory: hay un directorio jerárquico donde se accede a la información de usuarios y sus roles. Útil en entornos corporativos (grandes empresas). No se crean tablas, se utiliza ese directorio.
Spring Security: Autenticación
¿Dónde defino los usuarios y los roles? Si delego en un framework...
Spring Security: Autenticación
Vamos a ir por la opción de crear la tabla de usuarios y roles según el contrato de Spring Security.
Spring Security: Autenticación
Un dato importante, la password se encripta en la base. No es 100% seguro pero es mejor que tenerlo libre...
class Usuario {
private var password: String = ""
private fun getDefaultEncoder(): PasswordEncoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()!!
fun crearPassword(rawPassword: String) { password = getDefaultEncoder().encode(rawPassword) }
Spring Security: Autenticación
En el Bootstrap generamos la lista de usuario con su rol correspondiente (la asociación es: un usuario has-many roles pero en nuestro método lo simplificamos a uno solo)
Spring Security: Autenticación
Luego en el filter chain decimos quiénes pueden acceder a cada caso de uso y si es necesario autenticarse
@Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
return httpSecurity...
.authorizeHttpRequests {
it.requestMatchers("/login").permitAll()
it.requestMatchers("/error").permitAll()
it.requestMatchers(HttpMethod.OPTIONS).permitAll()
// Permisos de admin para modificar
it.requestMatchers(HttpMethod.POST, "/heladerias").hasAuthority("ADMIN")
it.requestMatchers(HttpMethod.POST, "/duenios").hasAuthority("ADMIN")
it.requestMatchers(HttpMethod.PUT, "/heladerias/**").hasAuthority("ADMIN")
// Default: que se autentique para poder ver información
.anyRequest().authenticated()
}
Spring Security: Autenticación
Recordemos que el Filter es un componente que intercepta la petición HTTP antes de que llegue al Servlet de Spring (la clase que termina llamando a los endpoints).
Spring Security: Autenticación
Una vez que validamos usuario y password, queremos que la comunicación entre cliente y servidor fluya. Para eso vamos a usar una firma digital: un token, que no es otra cosa que una serie de bytes.
Springboot: Autenticación con JWT
Presentamos a JWT: JSON Web Token, donde el token representa
Podemos ver https://jwt.io/
Springboot: Autenticación con JWT
En la autenticación por JWT el usuario y la contraseña se validan y lo que vuelve es un token, que hay que utilizar en cada una de las llamadas siguientes (recordemos que http es stateless).
Springboot: Autenticación con JWT
Vemos la implementación en Springboot, en la clase HeladeriaSecurityConfig:
httpBasic permite capturar el header `Authorization` de cada request con el Bearer Token
este filtro de JWT permite capturar el usuario y la contraseña para el UsernamePasswordAuthorizationFilter de Sprint Security
Springboot: Autenticación con JWT
El endpoint de login no tiene autenticación, delega la validación al service y devuelve el token:
Pueden ver cómo la implementación del service delega la búsqueda al repositorio y cierta lógica al dominio (como verificar la contraseña en base al algoritmo de encriptación)
@PostMapping("/login")
fun login(@RequestBody credencialesDTO: CredencialesDTO): String {
return usuarioService.login(credencialesDTO)
}
@Transactional(Transactional.TxType.NEVER)
fun login(credencialesDTO: CredencialesDTO): String {
val usuario = validarUsuario(credencialesDTO.usuario)
usuario.validarCredenciales(credencialesDTO.password)
return tokenUtils.createToken(credencialesDTO.usuario,
usuario.roles.map { it.name })!!
}
Springboot: Autenticación con JWT
El algoritmo que genera el token en TokenUtils es
es importante especificar un vencimiento para el token (si te hackean es limitado el daño que puede causar
fun createToken(nombre: String, roles: List<String>): String? {
val longExpirationTime = accessTokenMinutes.minutes.inWholeMilliseconds
val now = Date()
return Jwts.builder()
.subject(nombre)
.issuedAt(now)
.expiration(Date(now.time + longExpirationTime))
.claim("roles", roles)
.signWith(Keys.hmacShaKeyFor(secretKey.toByteArray()))
.compact()
}
Springboot: Autenticación con JWT
Veamos un diagrama de secuencia:
title Diagrama de Secuencia - Login (hasta token)
actor Cliente
participant "UsuarioController" as UC
participant "UsuarioService" as US
participant "RepoUsuarios" as RU
participant "Usuario" as U
participant "TokenUtils" as TU
Cliente->>UC: POST /login {username, password}
UC->>US: login(credencialesDTO)
US->>RU: findByUsername(username)
RU-->>US: Optional<<Usuario>
US->>U: validarCredenciales(password)
U-->>US: OK (si coincide)
US->>TU: createToken(username, roles)
TU-->>US: accessToken
US-->>UC: TokenResponseDTO
UC-->>Cliente: {accessToken}
Springboot: Autenticación con JWT
Configuraciones como el vencimiento del token o el secret key se definen como autowired:
Eso se configura en el application.yml, o bien en un application-dev.yml que no subimos a git (al menos el secret):
@Component
class TokenUtils {
@Value("\${security.secret-key}")
lateinit var secretKey: String
@Value("\${security.access-token-minutes}")
var accessTokenMinutes: Int = 60
security:
secret-key: ...
access-token-minutes: 300
JWT: Login desde Bruno
El login nos devuelve un código http 401 si las credenciales no son válidas (“credenciales inválidas”, sin especificar demasiado) o el token correspondiente:
JWT: Login desde Bruno
Ese token se almacena como variable Post Response...
JWT: Login desde Bruno
...y se define como Header en otros endpoints:
Eso mismo podés hacerlo en POSTMAN o Insomnia.
Usando el JWT en nuestros endpoints
El frontend debe guardar el JWT para pasarlo por ejemplo en la búsqueda de heladerías:
title Diagrama de Secuencia - Request JWT GET /heladerias
actor Cliente
participant "JWTAuthorizationFilter" as JWT
participant "TokenUtils" as TU
participant "UsuarioService" as US
participant "RepoUsuarios" as RU
participant "HeladeriaController" as HC
Cliente->>JWT: GET /heladerias Authorization: Bearer token
JWT->>TU: getAuthentication(token)
TU-->>JWT: UsernamePasswordAuthenticationToken
JWT->>US: validarUsuario(username)
US->>RU: findByUsername(username)
RU-->>US: Optional<<Usuario>
US-->>JWT: Usuario
JWT->>JWT: SecurityContextHolder.setAuthentication()
JWT->>HC: getHeladerias()
Springboot: Autenticación con JWT
El JWTAuthorizationFilter decora todos los request que reciben el header Authorization para procesar el token y asignarle el usuario/rol correspondiente...
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
val bearerToken = request.getHeader("Authorization")
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
val token = bearerToken.substringAfter("Bearer ")
val usernamePAT = tokenUtils.getAuthentication(token)
usuarioService.validarUsuario(usernamePAT.name)
SecurityContextHolder.getContext().authentication = usernamePAT
Login: Qué pasa en el frontend
En el login vamos a recibir el token, lo almacenamos en el LocalStorage del navegador:
Login: Qué pasa en el frontend
Vemos el código que resuelve esta parte, en el service que maneja el usuario:
export async function loginUser(usuario: string, password: string) {
const token = await httpRequest<string>({
method: 'POST',
url: `${BACKEND_URL}/login`,
data: { usuario, password }
})
localStorage.setItem(TOKEN_KEY, token)
}
Login: Qué pasa en el frontend
Las alternativas para almacenar el token son: guardarlo en el Local Storage (eso persiste aun cuando cerramos el navegador), o bien en el Session Storage (está atado a la sesión, al cerrar el navegador se borra la información), o guardarlo en una cookie, técnica que usaremos más adelante para evitar CSRF. Podés profundizar en este link.
Login: salir de la aplicación
Tenemos un botón que borra el token manualmente:
Login: salir de la aplicación
El código es bastante sencillo, eliminamos el token y redirigimos al login:
const logout = () => {
localStorage.removeItem(TOKEN_KEY)
navigate({
to: '/login',
search: {
redirect: '/',
},
})
}
token
Login: sesión vencida (backend)
Cada vez que hacemos un pedido, el server valida que el token no haya expirado (JWTAuthorizationFilter). Si el token se vence atrapamos la excepción para que el mecanismo de Springboot no emita un error http 500: por más que asociemos el código 401 a TokenExpiradoException, necesitamos explícitamente setear el error de la respuesta.
} catch (e: TokenExpiradoException) {
logger.warn(e.message)
response.sendError(HttpStatus.UNAUTHORIZED.value(), e.message)
}
Login: sesión vencida (frontend)
...ese 401 lo recibimos como error en el service. Dentro del mecanismo de routing nosotros definimos una ruta específica cuando ocurre un error:
export const Route = createRootRoute({
component: RootComponent, // el componente principal
errorComponent: RouteErrorComponent,
})
Login: sesión vencida (frontend)
Ese componente llama a un modal (también llamada ventana de diálogo, un Toast un poco más elaborado), que se activa como un contenedor por encima de la página actual para mostrar un mensaje de error:
const RouteErrorComponent = ({ error, reset }: ErrorComponentProps) => {
const router = useRouter()
const onRetry = async () => {
await router.invalidate()
reset()
}
return <ErrorModal error={error as AxiosError}
title={'Ups! Hubo...'}
onRetry={onRetry} />
}
export default RouteErrorComponent
fuerza la recarga de los loaders de una ruta
Login: sesión vencida (frontend)
El modal muestra el error y redirige al login:
const errorMessage = sessionExpired
? 'La sesión ha expirado. Por favor, inicie sesión nuevamente.' : ...
return (
<Modal ... isOpened={!!error} close={onClose}>
<ModalResponseContent ... <p>{errorMessage}</p>
actions={[
sessionExpired && (
<Button label='Navegar a login'
key='goToLogin' type='button' onClick={logout} />
), ...
Login: sesión vencida (frontend)
Además, en cada ruta tenemos una función general para manejar errores. Allí preguntamos si venció la sesión y borramos el token para evitar mandarlo la próxima vez...
export const onErrorRoute = (error: AxiosError) => {
// Handle 401 errors that are not token expiration
if (error.response?.status === 401) {
const wwwAuthenticate = error.response.headers['www-authenticate']
const isTokenExpired = wwwAuthenticate?.includes('error="invalid_token"')
// Only redirect if it's not a token expiration (those are handled by interceptor)
if (!isTokenExpired) {
Login: roles (app)
Si intentamos utilizar el usuario phm para actualizar la heladería, vemos que no tenemos permisos suficientes:
Login: roles (backend)
Recordemos que al desencriptar el token podemos saber qué usuario somos, y por lo tanto qué rol:
// TokenUtils.kt
fun getAuthentication(token: String): UsernamePasswordAuthenticationToken {
try {
val secret = Keys.hmacShaKeyFor(secretKey.toByteArray())
val claims = Jwts.parser().verifyWith(secret).build()
.parseSignedClaims(token).payload
...
val roles = (claims["roles"] as List<*>).map { SimpleGrantedAuthority(it.toString()) }
return UsernamePasswordAuthenticationToken(claims.subject, null, roles)
Login: roles (backend)
Y también recordemos que en la configuración de Spring Security definimos qué roles pueden acceder a qué endpoints. El de la actualización de la heladería solo está permitido para ADMIN:
Lo bueno es que no necesitamos hacer nada en nuestro endpoint, la configuración intercepta la llamada y devuelve un código http 403 (forbidden)...
.authorizeHttpRequests {
...
it.requestMatchers(HttpMethod.PUT, "/heladerias/**").hasAuthority(ROLES.ADMIN.name)
Login: roles (frontend)
...ese 403 lo recibimos como error en el service y se activa nuevamente la ventana modal (es el mismo código que con el vencimiento del token pero con un mensaje diferente).
Resumen de una operación no permitida
Veamos un diagrama de secuencia de una actualización rechazada por falta de permisos:
title Diagrama de Secuencia - 403 Forbidden PUT /heladerias
actor Cliente
participant "JWTAuthorizationFilter" as JWT
participant "TokenUtils" as TU
participant "UsuarioService" as US
participant "RepoUsuarios" as RU
participant "Spring Security" as SS
Cliente->>JWT: PUT /heladerias/{id} Authorization: Bearer token
JWT->>TU: getAuthentication(token)
TU-->>JWT: UsernamePasswordAuthenticationToken
JWT->>US: validarUsuario(username)
US->>RU: findByUsername(username)
RU-->>US: Optional<<Usuario>
US-->>JWT: Usuario
JWT->>JWT: SecurityContextHolder.setAuthentication()
JWT->>SS: verificar autorización PUT /heladerias
SS->>SS: hasAuthority(ADMIN)
SS-->>Cliente: 403 Forbidden
Spring Security - CORS
Un cliente puede pedir al servidor imágenes, css, código javascript, videos, entre otras cosas
Cliente Web
Servidor Web
Spring Security - CORS
Pero para hacer pedidos mediante fetch o axios, utilizamos de fondo el objeto XMLHttpRequest, que requiere un mecanismo de negociación entre cliente y servidor, donde el servidor nos tiene que indicar qué orígenes están autorizados a hacer pedidos (requests). Ése es básicamente el mecanismo CORS
Cliente Web
Servidor Web
XMLHttpRequest
fetch/axios
Spring Security - CORS
La mayoría de los navegadores hacen una solicitud preflight a través de un método http OPTIONS para saber si es un origen válido para hacer el pedido correspondiente (que será GET, POST, etc.) ...
Cliente Web
Servidor Web
preflight / OPTIONS
Spring Security - CORS
el server le responde con headers sobre los orígenes válidos (p. ej: “https://elfront.com” o “*” que indica que acepta cualquier origen) y métodos http aceptados (p. ej: podría aceptar POST o GET pero no PUT).
Cliente Web
Servidor Web
headers
Spring Security - CORS
En resumen, el preflight devuelve 200 (todo ok) o un código de error (no tenés permisos para hacer este pedido 403 o 204, No Content).
Spring Security - CORS
Y todas las veces que hacemos un request, los navegadores hacen la negociación:
Spring Security - CORS
Vemos que a cada método le precede una llamada a OPTIONS (siempre tener en cuenta la opción “Disable cache”)
Spring Security - CORS
Desactivamos aquí CORS en la clase HeladeriaSecurityConfig...
@Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
return httpSecurity
.cors { it.disable() }
Spring Security - CORS
...porque luego le activamos esta configuración:
@Bean
fun corsConfigurer(): WebMvcConfigurer {
return object : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedHeaders("*")
.allowedMethods("POST", "GET", "PUT", "DELETE")
.allowCredentials(true)
}
}
}
queremos que el front sea reconocido (modo dev local)
y qué métodos http vamos a usar
CSRF: Backend
Una medida extra de seguridad que vamos a agregar es la protección contra CSRF, donde el server pasará como respuesta una cookie con un token: el token sirve únicamente para el usuario logueado (no sirve para hacer pedidos desde otro sitio).
@Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
return httpSecurity
...
.csrf {
it.ignoringRequestMatchers("/login")
it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
it.csrfTokenRequestHandler(SpaCsrfTokenRequestHandler())
el login no requiere token
el token lo pasamos a través de una cookie, httpOnly = false para que el frontend nos la pueda mandar
CSRF: Backend
Para manipular el token XSRF vamos a configurarle un handler específico. La implementación podés verla en el repositorio, lo importante es que podemos recibir el token
Solo se chequea el token en operaciones con efecto (PUT, POST, etc.)
CSRF: frontend
Axios ya trae una configuración para recibir la cookie con la clave XSRF-TOKEN y enviar el header con la clave X-XSRF-TOKEN:
export async function httpRequest<T>(request: AxiosRequestConfig): Promise<T> {
...
const okRequest = {
...request,
withXSRFToken: true,
withCredentials: true,
xsrfHeaderName: 'X-XSRF-TOKEN',
xsrfCookieName: 'XSRF-TOKEN',
}
const response = await axios(okRequest)
return response.data
}
CSRF: interacción
Vemos cómo funciona el mecanismo:
title XSRF Token Flow
actor User
participant Browser
participant Frontend as React
participant Server as Backend
User->>Frontend: Login con credenciales
Frontend->>Server: POST /login (sin XSRF)
Server-->>Browser: Set-Cookie: XSRF-TOKEN=abc123
User->>Frontend: Crea heladería (POST)
Frontend->>Browser: Leer cookie XSRF-TOKEN
Browser->>Frontend: Cookie: XSRF-TOKEN=abc123
Frontend->>Server: POST /heladerias\nX-XSRF-TOKEN: abc123\nContent-Type: application/json
Server->>Server: Validar XSRF: abc123 === abc123 ✅
Server-->>Frontend: 201 Created + datos heladería
note over Browser: El atacante no puede leer<br/>cookies de otros dominios (Same-Origin Policy)
CSRF: interacción
Y lo vemos en acción:
CSRF: para qué lo hacemos
Cuando un hacker nos quiere hacer crear una heladería (o actualizarla) necesitamos tener en la cookie el XSRF Token. Pero si visitamos otro sitio (el falso) el dominio es diferente, entonces la cookie no viaja (según el atributo SameSite que por defecto es Lax). Como no mandamos el XSRF-TOKEN el backend rebota el llamado.
BONUS: Refresh token
En la primera versión, cuando la sesión expira, el frontend recibe el 401 y nos redirige al login.
Esto es algo útil para aplicaciones sensibles, como el home banking.
BONUS: Refresh token
Pero una forma de mejorar la UX es tener dos tokens: uno que tiene la información del usuario (el JWT Token) y otro llamado Refresh Token que suele durar más y permite volver a obtener un nuevo token cuando la sesión expiró.
BONUS: Refresh token
En el login, recibimos en realidad ambos tokens.
BONUS: Refresh token
y los guardamos en diferentes variables
Tenemos un nuevo endpoint: refresh que recibe el refresh token y nos devuelve un nuevo par de tokens:
BONUS: Refresh token
El Refresh token tiene una duración mayor que el access token.
BONUS: Refresh token
Veamos el flujo del refresh token, primero desde el login:
title Refresh Token Flow
actor User
participant Frontend as React
participant Browser as localStorage
participant Server as Backend
== Login ==
User->>Frontend: Login(usuario, password)
Frontend->>Server: POST /login\n{ usuario, password }
Server-->>Frontend: 200 OK\n{ accessToken: "jwt123", refreshToken: "ref456" }
Frontend->>Browser: localStorage.setItem("jwt_token", "jwt123")
Frontend->>Browser: localStorage.setItem("refresh_token", "ref456")
== Buscar Heladerías ==
User->>Frontend: Navega a /home
Frontend->>Browser: localStorage.getItem("jwt_token") → "jwt123"
Frontend->>Server: GET /heladerias\nAuthorization: Bearer jwt123
Server-->>Frontend: 200 OK\n[ lista heladerías ]
== Sesión expira ==
note over Server: accessToken expira (JWT timeout)
User->>Frontend: Buscar heladerías (nuevamente)
Frontend->>Browser: localStorage.getItem("jwt_token") → "jwt123"
Frontend->>Server: GET /heladerias\nAuthorization: Bearer jwt123
Server-->>Frontend: 401 Unauthorized\nWWW-Authenticate: Bearer error="invalid_token"
== Refresh Token ==
Frontend->>Frontend: Interceptor detecta 401 + invalid_token
Frontend->>Browser: localStorage.getItem("refresh_token") → "ref456"
Frontend->>Server: POST /refresh\n{ refreshToken: "ref456" }
Server-->>Frontend: 200 OK\n{ accessToken: "jwt789", refreshToken: "ref012" }
Frontend->>Browser: localStorage.setItem("jwt_token", "jwt789")
Frontend->>Browser: localStorage.setItem("refresh_token", "ref012")
== Retry Request ==
Frontend->>Server: GET /heladerias\nAuthorization: Bearer jwt789
Server-->>Frontend: 200 OK\n[ lista heladerías ]
note over User: ✅ Usuario nunca se entera\ndel refresh automático
BONUS: Refresh token
Luego cuando se vence el access token, enviamos el refresh token al POST de refresh y obtenemos nuevas credenciales:
BONUS: Refresh token
Cuando se vence el access token, el server envía un 401 con un header especial para diferenciar credenciales inválidas vs. token vencido. Podés ver la implementación en el JWTAuthorizationFilter (backend) y routes.ts (frontend)
Cuando el refresh token se vence, hay que redirigir al login (no queda otra).
Analizadores de vulnerabilidades
Te dejamos algunas herramientas que analizan vulnerabilidades en tu proyecto. Y ahora mismo con IA podés pedirle que siga las reglas OWASP al generar código, o que busque XSS, CSRF en tu aplicación.
¡Gracias!