Cuando empece a trabajar en WriteInOne, un backend para crear blogs personales hecho en Kotlin con Spring Boot WebFlux, obviamente pense en usar Spring Security para manejar los JWT y tokens de refresco. De hecho tenia un poco de experiencia con ello de otros projectos previos que no usaban WebFlux, asi que me pareció la mejor opción.
Pero cuando empecé a enganchar todo, algo me pareció un poco raro.
Spring Security no encaja con enrutamiento funcional.
Spring Security esta diseñada para el modelo clásico MVC, con @RestController, @RequestMapping y demas anotaciones en todos lados
En este modelo, las rutas se reparten en decenas de clases de controladores, así que centralizar las reglas de seguridad tiene sentido. Simplemente declaras las reglas en un bean SecurityWebFilterChain:
.authorizeExchange { auth ->
auth.pathMatchers("/auth/**").permitAll()
auth.pathMatchers("/sites/**").authenticated()
auth.anyExchange().authenticated()
}
Y, ¿cuál es el problema? Pues que estoy usando el enrutamiento funcional de Webflux. Todas las rutas ya estan en un solo lugar: el router. Así que ahora tengo dos sitios declarando la misma información: en el router puedes ver POST /sites, y en la configuracion de seguridad tienes que volver a decir que /sites/** existe y que requiere auth. Si cambias uno de ellos te toca cambiar el otro, y si se te olvida, puedes tener problemas.
Y no es que Spring Security este mal, es solo que su objetivo era agrupar todas esas clases de @Controller bajo unas reglas genericas en un solo lugar.
Que hice en su lugar
Ya que en el pasado he usado otros frameworks que manejan la autorización de otra manera, basicamente el concepto de Pipelines. Por ejemplo como se hace en Elysia.js y en Phoenix (Elixir). Ambos te dejan agrupar rutas y aplicar funciones o middleware a esos grupos, manteniendo las reglas de accesso en el mismo sitio que la declaracion de las rutas a proteger. Así que intenté hacer lo mismo en el backend de Kotlin.
Basicamente, las rutas publicas o los endpoints de login se mantienen en un grupo sin proteger, pero las que necesitan un JWT simplemente tienen que pasar primero por un filtro que valida la autorización, en caso de que sea válida la añade al contexto y si no lo fuera retorna un 401 sin llegar si quiera al handler.
Se vería así:
fun routes() = publicRoutes().and(protectedRoutes())
fun publicRoutes() = route()
.POST("/auth/register", authHandler::register)
.POST("/auth/login", authHandler::login)
.build()
fun protectedRoutes() = route()
.POST("/sites", siteHandler::create)
.GET("/sites", siteHandler::list)
.build()
.filter(jwtAuthFilter)
Y el filtro solo tiene que implementar HandlerFilterFunction:
@Component
class JwtAuthFilter(private val tokenService: TokenService) : HandlerFilterFunction<ServerResponse, ServerResponse> {
override fun filter(request: ServerRequest, next: HandlerFunction<ServerResponse>): Mono<ServerResponse> {
val token = request.cookies()[ACCESS_TOKEN_COOKIE]?.firstOrNull()?.value
?: throw UnauthorizedException()
return Mono.fromCallable { tokenService.getUserIdFromToken(token) }
.map { userId -> RequestContext(userId, extractRequestId(request)) }
.onErrorMap { throw UnauthorizedException() }
.flatMap { reqContext ->
next.handle(request).contextWrite { it.withRequestContext(reqContext) }
}
}
}
Aqui podemos ver lo que comenté antes: el userId extraído del token se incluye en el contexto de Reactor ContextView vía una función extension de Kotlin (En java podriamos simplemente crear una función que coja el context y lo modifique). Luego cualquier función de más adelante en el flujo puede recuperarlo con Mono.deferContextual { ctx -> ctx.getRequestContext() }. Sin necesidad de variables locales del hilo, o constantemente pasar el argumento de función en función.
El mismo RequestContext tambien se incluye en el MDC. Ya que un ThreadLocalAccessor registrado via ContextRegistry de Micrometer lo pilla y llama a MDC.put("userId", ...) automaticamente cuando el contexto se propaga a cada hilo. El resultado es que todos los logs pueden incluir automaticamente datos de la request como el userId o el requestId.
Por ciertoHandlerFilterFunction es una interfaz de WebFlux que no necesita en absoluto a Spring Security ni ningun otro framework. Lo cual hace que se pueda testear aislada, injectar en cualquier grupo de rutas que lo necesite, e incluso combinarse con otros filtros.
En resumen, rutas protegidas a protectedRoutes(). Rutas públicas a publicRoutes(). La regla se define en el mismo sitio que las rutas. Fácil de entender, dificil de romper.
¿Y si quiero acceso por roles? Pues añado un filtro nuevo que valide el rol y creo adminRoutes(). Este patrón es así de flexible.
¿Y las desventajas?
Si, salirse de las convenciones de Spring Security tiene un coste:
- No
@PreAuthorizeu otras anotaciones de seguridad a nivel de método. - No tendras integración automatica con
ReactiveSecurityContextHolder, por eso hacemos el nuestro propio. - Será menos familiar para desarrolladores que se esperen el setup estándar.
Si tu equipo y tu ya estais trabajando con proyectos que usen @PreAuthorize, la migración tendría un coste mas elevado. Pero si estás empezando algo de cero, no hay a penas dificultades.
Conclusión
Spring Secutiry es un framework podente que ayuda a resolver problemas reales, pero los mayores problemas que resuelve son los aplicados al sistema de anotaciones del modelo MVC. Si camias a usar enrutamiento funcional, el router debería ser tu única fuente de verdad.
Mantén tu seguridad donde vivan tus rutas. Es mas sencillo, explicito y cuando algo se rompa a las 2 de la mañana, sabrás exactamente donde mirar.