spring spring-security websocket jwt sockjs

Token web JSON(JWT) con SockJS/STOMP Web Socket basado en Spring



spring-security websocket (3)

Situación actual

ACTUALIZACIÓN 2016-12-13 : el problema al que se hace referencia a continuación ahora está marcado como fijo, por lo que el truco a continuación ya no es necesario, que la primavera 4.3.5 o superior. Consulte https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication .

Situación anterior

Actualmente (septiembre de 2016), Spring no admite esto, excepto a través de un parámetro de consulta respondido por @ rossen-stoyanchev, quien escribió mucho (¿todo?) Del soporte Spring WebSocket. No me gusta el enfoque de parámetro de consulta debido a la posible fuga de referencia HTTP y el almacenamiento del token en los registros del servidor. Además, si las ramificaciones de seguridad no le molestan, tenga en cuenta que he encontrado que este enfoque funciona para conexiones WebSocket verdaderas, pero si está utilizando SockJS con mecanismos alternativos a otros mecanismos, nunca se recurre al método de determineUser el usuario para el repliegue. Consulte la autenticación de respaldo WebSocket SockJS basada en token de Spring 4.x.

Creé un número de Spring para mejorar la compatibilidad con la autenticación WebSocket basada en tokens: jira.spring.io/browse/SPR-14690

Haciéndolo

Mientras tanto, he encontrado un truco que funciona bien en las pruebas. Omita la maquinaria de autenticación de primavera Spring-level incorporada. En su lugar, configure el token de autenticación en el nivel de mensaje enviándolo en los encabezados de Stomp en el lado del cliente (esto refleja bien lo que ya está haciendo con las llamadas HTTP XHR normales), por ejemplo:

stompClient.connect({''X-Authorization'': ''token''}, ...); stompClient.subscribe(..., {''X-Authorization'': ''token''}); stompClient.send("/wherever", {''X-Authorization'': ''token''}, ...);

En el lado del servidor, obtén el token del mensaje de Stomp usando un ChannelInterceptor

@Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.setInterceptors(new ChannelInterceptorAdapter() { Message<*> preSend(Message<*> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); List tokenList = accessor.getNativeHeader("X-Authorization"); String token = null; if(tokenList == null || tokenList.size < 1) { return message; } else { token = tokenList.get(0); if(token == null) { return message; } } // validate and convert to a Principal based on your own requirements e.g. // authenticationManager.authenticate(JwtAuthentication(token)) Principal yourAuth = [...]; accessor.setUser(yourAuth); // not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler! accessor.setLeaveMutable(true); return MessageBuilder.createMessage(message.payload, accessor.messageHeaders) } })

Esto es simple y nos lleva el 85% del camino, sin embargo, este enfoque no es compatible con el envío de mensajes a usuarios específicos. Esto se debe a que el mecanismo de Spring para asociar usuarios a sesiones no se ve afectado por el resultado del ChannelInterceptor . Spring WebSocket supone que la autenticación se realiza en la capa de transporte, no en la capa de mensaje, y por lo tanto ignora la autenticación a nivel de mensaje.

El truco para hacer que esto funcione de todos modos, es crear nuestras instancias de DefaultUserDestinationResolver y DefaultUserDestinationResolver , exponerlas al entorno y luego usar el interceptor para actualizarlas como si lo hiciera Spring. En otras palabras, algo así como:

@Configuration @EnableWebSocketMessageBroker @Order(HIGHEST_PRECEDENCE + 50) class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer() { private DefaultSimpUserRegistry userRegistry = new DefaultSimpUserRegistry(); private DefaultUserDestinationResolver resolver = new DefaultUserDestinationResolver(userRegistry); @Bean @Primary public SimpUserRegistry userRegistry() { return userRegistry; } @Bean @Primary public UserDestinationResolver userDestinationResolver() { return resolver; } @Override public configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/queue", "/topic"); } @Override public registerStompEndpoints(StompEndpointRegistry registry) { registry .addEndpoint("/stomp") .withSockJS() .setWebSocketEnabled(false) .setSessionCookieNeeded(false); } @Override public configureClientInboundChannel(ChannelRegistration registration) { registration.setInterceptors(new ChannelInterceptorAdapter() { Message<*> preSend(Message<*> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); List tokenList = accessor.getNativeHeader("X-Authorization"); accessor.removeNativeHeader("X-Authorization"); String token = null; if(tokenList != null && tokenList.size > 0) { token = tokenList.get(0); } // validate and convert to a Principal based on your own requirements e.g. // authenticationManager.authenticate(JwtAuthentication(token)) Principal yourAuth = token == null ? null : [...]; if (accessor.messageType == SimpMessageType.CONNECT) { userRegistry.onApplicationEvent(SessionConnectedEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.SUBSCRIBE) { userRegistry.onApplicationEvent(SessionSubscribeEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.UNSUBSCRIBE) { userRegistry.onApplicationEvent(SessionUnsubscribeEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.DISCONNECT) { userRegistry.onApplicationEvent(SessionDisconnectEvent(this, message, accessor.sessionId, CloseStatus.NORMAL)); } accessor.setUser(yourAuth); // not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler! accessor.setLeaveMutable(true); return MessageBuilder.createMessage(message.payload, accessor.messageHeaders); } }) } }

Ahora Spring es plenamente consciente de la autenticación, es decir, inyecta el Principal en cualquier método de controlador que lo requiera, lo expone al contexto de Spring Security 4.x y asocia al usuario a la sesión de WebSocket para enviar mensajes a usuarios / sesiones específicos .

Mensajes de seguridad de primavera

Por último, si usa la compatibilidad de Spring Security 4.x Messaging, asegúrese de configurar el @Order de su AbstractWebSocketMessageBrokerConfigurer en un valor más alto que AbstractSecurityWebSocketMessageBrokerConfigurer de Spring Security ( Ordered.HIGHEST_PRECEDENCE + 50 funcionaría, como se muestra arriba). De esta forma, su interceptor establece el Principal antes de que Spring Security ejecute su verificación y establezca el contexto de seguridad.

Creando un Principal (Actualización de junio de 2018)

Mucha gente parece confundirse con esta línea en el código anterior:

// validate and convert to a Principal based on your own requirements e.g. // authenticationManager.authenticate(JwtAuthentication(token)) Principal yourAuth = [...];

Esto está bastante fuera del alcance de la pregunta, ya que no es específico de Stomp, pero lo ampliaré un poco de todos modos, porque está relacionado con el uso de tokens de autenticación con Spring. Al usar la autenticación basada en token, el Principal que necesita generalmente será una clase JwtAuthentication personalizada que JwtAuthentication clase AbstractAuthenticationToken Spring Security. AbstractAuthenticationToken implementa la interfaz Authentication que extiende la interfaz Principal y contiene la mayoría de la maquinaria para integrar su token con Spring Security.

Por lo tanto, en el código de Kotlin (lo siento, no tengo el tiempo o la inclinación para traducir esto de vuelta a Java), su JwtAuthentication podría ser algo como esto, que es un simple contenedor de AbstractAuthenticationToken :

import my.model.UserEntity import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.core.GrantedAuthority class JwtAuthentication( val token: String, // UserEntity is your application''s model for your user val user: UserEntity? = null, authorities: Collection<GrantedAuthority>? = null) : AbstractAuthenticationToken(authorities) { override fun getCredentials(): Any? = token override fun getName(): String? = user?.id override fun getPrincipal(): Any? = user }

Ahora necesita un AuthenticationManager que sepa cómo manejarlo. Esto podría parecerse a lo siguiente, nuevamente en Kotlin:

@Component class CustomTokenAuthenticationManager @Inject constructor( val tokenHandler: TokenHandler, val authService: AuthService) : AuthenticationManager { val log = logger() override fun authenticate(authentication: Authentication?): Authentication? { return when(authentication) { // for login via username/password e.g. crash shell is UsernamePasswordAuthenticationToken -> { findUser(authentication).let { //checkUser(it) authentication.withGrantedAuthorities(it).also { setAuthenticated(true) } } } // for token-based auth is JwtAuthentication -> { findUser(authentication).let { val tokenTypeClaim = tokenHandler.parseToken(authentication.token)[CLAIM_TOKEN_TYPE] when(tokenTypeClaim) { TOKEN_TYPE_ACCESS -> { //checkUser(it) authentication.withGrantedAuthorities(it).also { setAuthenticated(true) } } TOKEN_TYPE_REFRESH -> { //checkUser(it) JwtAuthentication(authentication.token, it, listOf(SimpleGrantedAuthority(Authorities.REFRESH_TOKEN))) } else -> throw IllegalArgumentException("Unexpected token type claim $tokenTypeClaim.") } } } else -> null } } private fun findUser(authentication: JwtAuthentication): UserEntity = authService.login(authentication.token) ?: throw BadCredentialsException("No user associated with token or token revoked.") private fun findUser(authentication: UsernamePasswordAuthenticationToken): UserEntity = authService.login(authentication.principal.toString(), authentication.credentials.toString()) ?: throw BadCredentialsException("Invalid login.") @Suppress("unused", "UNUSED_PARAMETER") private fun checkUser(user: UserEntity) { // TODO add these and lock account on x attempts //if(!user.enabled) throw DisabledException("User is disabled.") //if(user.accountLocked) throw LockedException("User account is locked.") } fun JwtAuthentication.withGrantedAuthorities(user: UserEntity): JwtAuthentication { return JwtAuthentication(token, user, authoritiesOf(user)) } fun UsernamePasswordAuthenticationToken.withGrantedAuthorities(user: UserEntity): UsernamePasswordAuthenticationToken { return UsernamePasswordAuthenticationToken(principal, credentials, authoritiesOf(user)) } private fun authoritiesOf(user: UserEntity) = user.authorities.map(::SimpleGrantedAuthority) }

TokenHandler inyectado abstrae el análisis de tokens JWT, pero debe usar una biblioteca de tokens JWT común como jjwt . El AuthService inyectado es su abstracción que realmente crea su UserEntity función de los reclamos en el token, y puede comunicarse con su base de datos de usuario u otros sistemas de fondo.

Ahora, volviendo a la línea con la que comenzamos, podría parecerse a esto, donde authenticationManager es un AuthenticationManager inyectado en nuestro adaptador por Spring, y es una instancia de CustomTokenAuthenticationManager que definimos anteriormente:

Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));

Este principio se adjunta al mensaje como se describe arriba. HTH!

Fondo

Estoy en el proceso de configurar una aplicación web RESTful utilizando Spring Boot (1.3.0.BUILD-SNAPSHOT) que incluye un STOMP / SockJS WebSocket, que pretendo consumir desde una aplicación de iOS así como navegadores web. Quiero usar JSON Web Tokens (JWT) para asegurar las solicitudes REST y la interfaz WebSocket, pero tengo dificultades con esta última.

La aplicación está asegurada con Spring Security: -

@Configuration @EnableWebSecurity public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public WebSecurityConfiguration() { super(true); } @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("steve").password("steve").roles("USER"); } @Override protected void configure(HttpSecurity http) throws Exception { http .exceptionHandling().and() .anonymous().and() .servletApi().and() .headers().cacheControl().and().and() // Relax CSRF on the WebSocket due to needing direct access from apps .csrf().ignoringAntMatchers("/ws/**").and() .authorizeRequests() //allow anonymous resource requests .antMatchers("/", "/index.html").permitAll() .antMatchers("/resources/**").permitAll() //allow anonymous POSTs to JWT .antMatchers(HttpMethod.POST, "/rest/jwt/token").permitAll() // Allow anonymous access to websocket .antMatchers("/ws/**").permitAll() //all other request need to be authenticated .anyRequest().hasRole("USER").and() // Custom authentication on requests to /rest/jwt/token .addFilterBefore(new JWTLoginFilter("/rest/jwt/token", authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class) // Custom JWT based authentication .addFilterBefore(new JWTTokenFilter(), UsernamePasswordAuthenticationFilter.class); } }

La configuración de WebSocket es estándar:

@Configuration @EnableScheduling @EnableWebSocketMessageBroker public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); config.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); } }

También tengo una subclase de AbstractSecurityWebSocketMessageBrokerConfigurer para asegurar el WebSocket: -

@Configuration public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer { @Override protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { messages.anyMessage().hasRole("USER"); } @Override protected boolean sameOriginDisabled() { // We need to access this directly from apps, so can''t do cross-site checks return true; } }

También hay un par de clases @RestController anotadas para manejar varios bits de funcionalidad y estas están aseguradas con éxito a través del JWTTokenFilter registrado en mi clase WebSecurityConfiguration .

Problema

Sin embargo, parece que no puedo asegurar que WebSocket esté protegido con JWT. Estoy usando SockJS 1.1.0 y STOMP 1.7.1 en el navegador y no puedo entender cómo pasar el token. Parece que SockJS no permite que se envíen parámetros con las solicitudes iniciales /info y / o solicitud de saludo.

La documentación de Spring Security for WebSockets indica que AbstractSecurityWebSocketMessageBrokerConfigurer garantiza que:

Cualquier mensaje CONNECT entrante requiere un token CSRF válido para hacer cumplir la política Same Origin

Lo que parece implicar que el protocolo de enlace inicial no debe ser seguro y debe invocarse la autenticación en el momento de recibir un mensaje STOMP CONNECT. Lamentablemente, parece que no puedo encontrar ninguna información con respecto a la implementación de esto. Además, este enfoque requeriría una lógica adicional para desconectar un cliente deshonesto que abre una conexión WebSocket y nunca envía un STOMP CONNECT.

Al ser (muy) nuevo en Spring, tampoco estoy seguro de si las Sesiones de Primavera se ajustan a esto o cómo. Si bien la documentación es muy detallada, no parece haber una guía agradable y simple (también conocida como idiotas) sobre cómo los diversos componentes se unen / interactúan entre sí.

Pregunta

¿Cómo hago para proteger SockJS WebSocket al proporcionar un token web JSON, preferiblemente en el punto de apretón de manos (¿es posible?).


Con el último SockJS 1.0.3 puede pasar parámetros de consulta como parte de la URL de conexión. Por lo tanto, puedes enviar un token JWT para autorizar una sesión.

var socket = new SockJS(''http://localhost/ws?token=AAA''); var stompClient = Stomp.over(socket); stompClient.connect({}, function(frame) { stompClient.subscribe(''/topic/echo'', function(data) { // topic handler }); } }, function(err) { // connection error });

Ahora todas las solicitudes relacionadas con websocket tendrán el parámetro "? Token = AAA"

http://localhost/ws/info?token=AAA&t=1446482506843

http://localhost/ws/515/z45wjz24/websocket?token=AAA

Luego, con Spring, puede configurar algunos filtros que identificarán una sesión utilizando el token proporcionado.