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.
Parece que el soporte para una cadena de consulta se agregó al cliente SockJS, consulte https://github.com/sockjs/sockjs-client/issues/72 .