1 of 70

Seguridad y Autenticación en aplicaciones web

Programación con Herramientas Modernas

2 of 70

Antes de arrancar

Verificá que tengas activada la opción “Disable cache” en el navegador (F12 > solapa Network)

3 of 70

Spring Security

Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es

4 of 70

Spring Security

Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es

  • autenticación: validar credenciales de usuario

5 of 70

Spring Security

Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es

  • autorización: entender a qué recursos tiene acceso un usuario.

6 of 70

Spring Security

Spring Security es la propuesta de Springboot para manejar la seguridad en lo que es

  • protección contra ataques: como CSRF o secuestro de sesión

7 of 70

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

8 of 70

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.

9 of 70

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.

10 of 70

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).

11 of 70

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.

12 of 70

Spring Security: Autenticación

¿Dónde defino los usuarios y los roles? Si delego en un framework...

OAuth2 + OIDC: te podés integrar con servicios de autenticación externos, como tu correo de Google, Facebook, Github, etc.

13 of 70

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.

14 of 70

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) }

15 of 70

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)

16 of 70

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()

}

17 of 70

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).

18 of 70

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.

19 of 70

Springboot: Autenticación con JWT

Presentamos a JWT: JSON Web Token, donde el token representa

  • información sobre la sesión asociada a un usuario identificado
  • con formato JSON
  • y firmado digitalmente, donde un algoritmo utiliza una clave como forma de encriptar los datos.

Podemos ver https://jwt.io/

20 of 70

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).

21 of 70

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

22 of 70

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 })!!

}

23 of 70

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()

}

24 of 70

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}

25 of 70

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

26 of 70

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:

27 of 70

JWT: Login desde Bruno

Ese token se almacena como variable Post Response...

28 of 70

JWT: Login desde Bruno

...y se define como Header en otros endpoints:

Eso mismo podés hacerlo en POSTMAN o Insomnia.

29 of 70

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()

30 of 70

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

31 of 70

Login: Qué pasa en el frontend

En el login vamos a recibir el token, lo almacenamos en el LocalStorage del navegador:

32 of 70

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)

}

33 of 70

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.

34 of 70

Login: salir de la aplicación

Tenemos un botón que borra el token manualmente:

35 of 70

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

36 of 70

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)

}

37 of 70

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,

})

38 of 70

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

39 of 70

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} />

), ...

40 of 70

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) {

41 of 70

Login: roles (app)

Si intentamos utilizar el usuario phm para actualizar la heladería, vemos que no tenemos permisos suficientes:

42 of 70

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)

43 of 70

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)

44 of 70

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).

45 of 70

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

46 of 70

Spring Security - CORS

Un cliente puede pedir al servidor imágenes, css, código javascript, videos, entre otras cosas

Cliente Web

Servidor Web

47 of 70

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

48 of 70

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

49 of 70

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

50 of 70

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).

51 of 70

Spring Security - CORS

Y todas las veces que hacemos un request, los navegadores hacen la negociación:

52 of 70

Spring Security - CORS

Vemos que a cada método le precede una llamada a OPTIONS (siempre tener en cuenta la opción “Disable cache”)

53 of 70

Spring Security - CORS

Desactivamos aquí CORS en la clase HeladeriaSecurityConfig...

@Bean

fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {

return httpSecurity

.cors { it.disable() }

54 of 70

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

55 of 70

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

56 of 70

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

  • a través del header
  • o bien por un input type hidden

Solo se chequea el token en operaciones con efecto (PUT, POST, etc.)

57 of 70

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

}

58 of 70

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)

59 of 70

CSRF: interacción

Y lo vemos en acción:

60 of 70

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.

61 of 70

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.

62 of 70

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ó.

63 of 70

BONUS: Refresh token

En el login, recibimos en realidad ambos tokens.

64 of 70

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:

65 of 70

BONUS: Refresh token

El Refresh token tiene una duración mayor que el access token.

66 of 70

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

67 of 70

BONUS: Refresh token

Luego cuando se vence el access token, enviamos el refresh token al POST de refresh y obtenemos nuevas credenciales:

68 of 70

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).

69 of 70

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.

70 of 70

¡Gracias!