JSON Web Token (JWT) avec Sockjs / STOMP web Socket à base de ressort
arrière-plan
je suis en train de configurer une application Web RESTful en utilisant Spring Boot (1.3.0.BUILD-SNAPSHOT) qui inclut un Stomp/SockJS WebSocket, que j'ai l'intention de consommer à partir d'une application iOS ainsi que les navigateurs web. Je veux utiliser JSON Web Tokens (JWT) pour sécuriser les requêtes REST et L'interface WebSocket mais j'ai des difficultés avec cette dernière.
l'application est sécurisée par une sécurité à ressort:-
@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 configuration WebSocket est standard: -
@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();
}
}
j'ai aussi une sous-classe de AbstractSecurityWebSocketMessageBrokerConfigurer
pour sécuriser le 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;
}
}
il y a aussi quelques classes annotées @RestController
pour gérer divers bits de fonctionnalité et celles-ci sont sécurisées avec succès via le JWTTokenFilter
enregistré dans ma classe WebSecurityConfiguration
.
problème
Cependant, je n'arrive pas à sécuriser le WebSocket avec JWT. J'utilise SockJS 1.1.0 et STOMP 1.7.1 dans le navigateur et ne peut pas comprendre comment passer le token. Il apparaîtrait que les SockJS ne permettent pas d'envoyer les paramètres avec les requêtes initiales /info
et/ou poignée de main.
le Spring Security for WebSockets documentation stipule que le AbstractSecurityWebSocketMessageBrokerConfigurer
assure que:
tout message de connexion entrant nécessite un jeton CSRF valide pour appliquer la même Politique d'origine
, ce qui semble impliquer que la poignée de main initiale devrait être non sécurisée et l'authentification invoquée au point de réception d'un message STOMP CONNECT. Malheureusement, il semble que je ne trouve aucune information concernant la mise en œuvre de cette mesure. En outre, cette approche nécessiterait une logique supplémentaire pour: déconnecter un client voyou qui ouvre une connexion WebSocket et n'envoie jamais de connexion STOMP.
étant (très) nouveau au printemps, Je ne suis pas sûr non plus si ou comment les sessions de printemps s'inscrivent là-dedans. Bien que la documentation soit très détaillée, il ne semble pas à un guide agréable et simple (aka idiots) de la façon dont les différents composants s'emboîtent / interagissent les uns avec les autres.
Question
Comment sécuriser le Sockjs WebSocket par fournir un jeton Web JSON, de préférence au point de poignée de main (est-ce même possible)?
3 réponses
semble comme support pour une chaîne de requête a été ajoutée au client SockJS, voir https://github.com/sockjs/sockjs-client/issues/72 .
Situation Actuelle
mise à jour 2016-12-13 : la question référencée ci-dessous est maintenant marquée fixe, de sorte que le piratage ci-dessous n'est plus nécessaire quel ressort 4.3.5 ou supérieur. Voir https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication .
Situation Antérieure
actuellement (Sep 2016), ce N'est pas supporté par le printemps sauf via le paramètre de requête comme répondu par @rossen-stoyanchev, qui a beaucoup écrit (tous?) du Ressort WebSocket de soutien. Je n'aime pas l'approche par paramètre de requête à cause d'une fuite potentielle de la référence HTTP et du stockage du token dans les journaux du serveur. De plus, si les ramifications de sécurité Ne vous dérangent pas, notez que j'ai trouvé que cette approche fonctionne pour de véritables connexions WebSocket, mais si vous utilisez des Sockj avec des retombées sur d'autres mécanismes, la méthode determineUser
n'est jamais appelée pour le repli. Voir Printemps 4.x basée sur les jetons WebSocket SockJS de secours d'authentification .
j'ai créé un numéro de printemps pour améliorer la prise en charge de L'authentification basée sur un token WebSocket: https://jira.spring.io/browse/SPR-14690
Piratage
en attendant, j'ai trouvé un hack qui fonctionne bien dans testing. Contourner le système intégré Printemps au niveau de la connexion Printemps auth machines. Au lieu de cela, définissez le token d'authentification au niveau du message en l'envoyant dans les en-têtes Stomp du côté client (ce qui reflète bien ce que vous faites déjà avec les appels HTTP XHR réguliers) par exemple:
stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);
du côté du serveur, obtenir le jeton à partir du message Stomp en utilisant 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)
}
})
C'est simple et nous obtient 85% de la façon là-bas, cependant, cette approche ne en charge l'envoi de messages à des utilisateurs spécifiques. Cela est dû au fait que la machinerie de Spring pour associer les utilisateurs aux sessions n'est pas affectée par le résultat du ChannelInterceptor
. Spring WebSocket suppose que l'authentification se fait à la couche transport et non à la couche message, ignorant ainsi l'authentification au niveau du message.
le hack pour faire ce travail de toute façon, est de créer nos instances de DefaultSimpUserRegistry
et DefaultUserDestinationResolver
, les exposer à l'environnement, et puis utiliser l'intercepteur pour mettre à jour ces comme si le Printemps lui-même le faisait. En d'autres termes, quelque chose comme:
@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);
}
})
}
}
maintenant Spring est pleinement conscient de l'authentification, c'est-à-dire qu'il injecte le Principal
dans toutes les méthodes de controller qui l'exigent, l'exposent au contexte de la sécurité Spring 4.x, et associe l'utilisateur à la session WebSocket pour envoyer des messages à des utilisateurs/sessions spécifiques.
Printemps De Sécurité De La Messagerie
Enfin, si vous utilisez la sécurité printemps 4.soutien de messagerie x, assurez-vous de mettre le @Order
de votre AbstractWebSocketMessageBrokerConfigurer
à une valeur plus élevée que le AbstractSecurityWebSocketMessageBrokerConfigurer
de sécurité de ressort ( Ordered.HIGHEST_PRECEDENCE + 50
fonctionnerait, comme montré ci-dessus). De cette façon, votre intercepteur définit le Principal
avant que Spring Security n'exécute sa vérification et ne définisse le contexte de sécurité.
la Création d'une entité (mise à Jour juin 2018)
Beaucoup de gens semblent confus par cette ligne dans le code ci-dessus:
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
C'est à peu près hors de portée pour la question car il n'est pas spécifique Stomp-spécifique, mais je vais développer sur elle un peu de toute façon, parce que c'est lié à l'utilisation de jetons auth avec le ressort. Lorsque vous utilisez l'authentification par token, la classe Principal
dont vous avez besoin sera généralement une classe JwtAuthentication
personnalisée qui étend la classe AbstractAuthenticationToken
de Spring Security. AbstractAuthenticationToken
implémente l'interface Authentication
qui étend l'interface Principal
, et contient la plupart des machines pour intégrer votre token à la sécurité du ressort.
donc, en code Kotlin (désolé je n'ai pas le temps ou l'inclination pour traduire ce retour à Java), votre JwtAuthentication
pourrait ressembler à quelque chose comme ceci, qui est un enveloppement simple autour 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
}
Maintenant vous avez besoin d'un AuthenticationManager
qui sait comment le gérer. Cela pourrait ressembler à ce qui suit, encore une fois à 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)
}
injectés TokenHandler
abstraction de la JWT jeton de l'analyse, mais doit utiliser un commun JWT jeton de la bibliothèque comme jjwt . Le AuthService
injecté est votre abstraction qui crée réellement votre UserEntity
basé sur les revendications dans le token, et peut parler à votre base de données utilisateur ou autre système d'arrière-plan.
maintenant, en revenant à la ligne que nous avons commencé avec, il pourrait ressembler à quelque chose comme ceci, où authenticationManager
est un AuthenticationManager
injecté dans notre adaptateur par ressort, et est un exemple de CustomTokenAuthenticationManager
nous avons défini ci-dessus:
Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));
ce principe est ensuite joint au message tel que décrit ci-dessus. HTH!
avec la dernière SockJS 1.0.3 vous pouvez passer des paramètres de requête dans le cadre de L'URL de connexion. Ainsi, vous pouvez envoyer un JWT pour autoriser une session.
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
});
maintenant toutes les requêtes relatives à websocket auront un paramètre"?token=AAA "
http://localhost/ws/info Je ne sais pas.token=AAA&t=1446482506843
http://localhost/ws/515/z45wjz24/websocket Je ne sais pas.token=AAA
ensuite avec Spring vous pouvez configurer un filtre qui identifiera une session en utilisant le token fourni.