JSON веб-маркер (JWT) с пружинной основе SockJS / топать веб-сокет
фон
Я нахожусь в процессе настройки веб-приложения RESTful с помощью Spring Boot (1.3.0.BUILD-SNAPSHOT), который включает в себя Stomp/SockJS WebSocket, который я намерен потреблять из приложения iOS, а также веб-браузеров. Я хочу использовать веб-токены JSON (JWT) для защиты остальных запросов и интерфейса WebSocket, но у меня возникли трудности с последним.
приложение защищено 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);
}
}
в Конфигурация WebSocket стандартная: -
@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();
}
}
у меня также есть подкласс AbstractSecurityWebSocketMessageBrokerConfigurer
для защиты 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;
}
}
есть также несколько @RestController
аннотированные классы для обработки различных бит функциональности, и они успешно защищены с помощью JWTTokenFilter
зарегистрирован в my WebSecurityConfiguration
класса.
3 ответов
похоже, что поддержка строки запроса была добавлена в клиент SockJS, см. https://github.com/sockjs/sockjs-client/issues/72.
Текущая Ситуация
обновление 2016-12-13: проблема, на которую ссылается ниже, Теперь отмечена исправлена, поэтому Хак ниже больше не нужен, который весной 4.3.5 или выше. Смотри https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication.
Предыдущие Ситуации
в настоящее время (Sep 2016), это не поддерживается Spring, за исключением параметра запроса как ответил @Россен-stoyanchev, кто пишет много (все?) поддержки весеннего WebSocket. Мне не нравится подход параметров запроса из-за потенциальной утечки http-реферера и хранения токена в журналах сервера. Кроме того, если вас не беспокоят последствия безопасности, обратите внимание, что я обнаружил, что этот подход работает для истинных соединений WebSocket,но если вы используете SockJS с обратными связями с другими механизмами,determineUser
метод никогда не вызывается для резервного. Видеть Весна 4.X маркер на основе WebSocket SockJS резервная аутентификация.
я создал весеннюю проблему для улучшения поддержки аутентификации WebSocket на основе токенов:https://jira.spring.io/browse/SPR-14690
Взлома
тем временем, я нашел хак, который хорошо работает в тестировании. Обойдите встроенное машинное оборудование auth весны соединени-уровня весны. Вместо этого установите маркер проверки подлинности на уровне сообщений, отправив это в заголовках Stomp на стороне клиента (это красиво отражает то, что вы уже делаете с регулярными вызовами HTTP XHR), например:
stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);
на стороне сервера получите токен из сообщения Stomp с помощью 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)
}
})
это просто и дает нам 85% пути туда, однако, этот подход не поддерживает отправку сообщений конкретным пользователям. Это связано с тем, что механизм Spring для связывания пользователей с сеансами не зависит от результата ChannelInterceptor
. Spring WebSocket предполагает, что аутентификация выполняется на транспортном уровне, а не на уровне сообщений, и поэтому игнорирует аутентификацию на уровне сообщений.
хак, чтобы сделать эту работу в любом случае, чтобы создать наши экземпляры DefaultSimpUserRegistry
и DefaultUserDestinationResolver
, подвергните их воздействию окружающей среды, а затем используйте перехватчик для обновления, как будто это делает сама весна. Другими словами, что-то типа:
@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);
}
})
}
}
теперь Spring полностью осознает аутентификацию, т. е. он вводит Principal
в любые методы контроллера, которые требуют этого, предоставляет его контексту для Spring Security 4.x и связывает пользователя с сеансом WebSocket для отправки сообщений определенным пользователям / сеансам.
Spring Security Messaging
наконец, если вы используете Spring Security 4.X поддержка обмена сообщениями, убедитесь, что установить @Order
вашего AbstractWebSocketMessageBrokerConfigurer
к более высокому значению, чем Spring Security AbstractSecurityWebSocketMessageBrokerConfigurer
(Ordered.HIGHEST_PRECEDENCE + 50
будет работать, как показано выше). Таким образом, перехватчик устанавливает Principal
перед Spring Security выполняет свою проверку и устанавливает контекст безопасности.
создание участника (обновление июнь 2018)
многие люди, похоже, смущены этой строкой в коде выше:
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
это в значительной степени выходит за рамки вопроса, поскольку он не специфичен для Stomp, но я все равно немного расширю его, потому что он связан с использованием токенов auth с Spring. При использовании проверки подлинности на основе маркеров Principal
вам нужно будет настраиваемой JwtAuthentication
класс, который расширяет Spring Security в AbstractAuthenticationToken
класса. AbstractAuthenticationToken
осуществляет Authentication
интерфейс, который расширяет Principal
интерфейс и содержит большую часть оборудования для интеграции вашего токена с Spring Security.
Итак, в коде Котлина (извините, у меня нет времени или склонности переводить это обратно на Java), ваш JwtAuthentication
может выглядеть примерно так, что это простая обертка вокруг 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
}
и AuthenticationManager
это знает, как справиться с этим. Это может выглядеть примерно следующим образом, снова в Котлине:
@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
абстрагирует разбор токена JWT, но должен использовать общую библиотеку токенов JWT, такую как jjwt. Впрыснутый AuthService
- это абстракция, которая на самом деле создает свой UserEntity
на основе утверждений в токене и может разговаривать с вашей базой данных Пользователя или другим бэкэндом система(ы).
теперь, возвращаясь к линии, с которой мы начали, это может выглядеть примерно так, где authenticationManager
это AuthenticationManager
впрыснутый в наш переходнику весной, и экземпляр CustomTokenAuthenticationManager
мы определили выше:
Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));
этот Принципал затем присоединяется к сообщению, как описано выше. НТН!
С последним SockJS 1.0.3 вы можете передавать параметры запроса как часть URL-адреса соединения. Таким образом, вы можете отправить токен JWT для авторизации сеанса.
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
});
теперь все запросы, связанные с websocket, будут иметь параметр "?token=AAA"
http://localhost/ws/info?токен=AAA&t=1446482506843
http://localhost/ws/515/z45wjz24/websocket?токен=AAA
затем с помощью Spring вы можете настроить некоторый фильтр, который будет определите сеанс, используя предоставленный токен.