복붙노트

[SPRING] 스프링 기반 SockJS / STOMP 웹 소켓이있는 JSON 웹 토큰 (JWT)

SPRING

스프링 기반 SockJS / STOMP 웹 소켓이있는 JSON 웹 토큰 (JWT)

STOMP / SockJS WebSocket이 포함 된 Spring Boot (1.3.0.BUILD-SNAPSHOT)를 사용하여 RESTful 웹 응용 프로그램을 설정하는 중입니다. 웹 응용 프로그램뿐만 아니라 웹 브라우저에서도 사용할 예정입니다. JSON 웹 토큰 (JWT)을 사용하여 REST 요청과 WebSocket 인터페이스를 보호하려고하지만 후자와 어려움을 겪고 있습니다.

앱은 스프링 보안으로 보안됩니다.

@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();
    }

}

WebSocket 보안을 위해 AbstractSecurityWebSocketMessageBrokerConfigurer 하위 클래스가 있습니다.

@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 annotated 클래스가 다양한 기능 비트를 처리하며, 이는 WebSecurityConfiguration 클래스에 등록 된 JWTTokenFilter를 통해 성공적으로 보안됩니다.

그러나 WebSocket을 JWT로 보안화할 수는 없습니다. 브라우저에서 SockJS 1.1.0 및 STOMP 1.7.1을 사용하고 있는데 토큰을 전달하는 방법을 알 수 없습니다. SockJS는 매개 변수가 초기 / 정보 및 / 또는 핸드 셰이크 요청과 함께 전송되는 것을 허용하지 않는 것으로 보입니다.

Spring Security for WebSockets 문서에는 AbstractSecurityWebSocketMessageBrokerConfigurer가 다음을 보장한다고 명시되어있다.

STOMP CONNECT 메시지 수신 시점에 초기 핸드 셰이크가 보안 해제되어야하고 인증이 호출되어야 함을 의미하는 것으로 보입니다. 불행히도 나는 이것을 구현하는 것과 관련하여 어떤 정보도 찾을 수없는 것 같습니다. 또한이 방법을 사용하면 WebSocket 연결을 열고 STOMP CONNECT를 보내지 않는 가짜 클라이언트를 연결 해제하는 추가 논리가 필요합니다.

Spring을 처음 접하는 것은 Spring Session이 이것에 어떻게 들어갈 지, 또는 어떻게 쓰는지에 대해서 확신 할 수 없습니다. 설명서가 매우 자세하지만 다양한 구성 요소가 서로 어울리거나 서로 상호 작용하는 방법에 대한 간단하고 훌륭한 (일명 바보 스) 지침은 나타나지 않습니다.

JSON 웹 토큰을 제공하여 SockJS WebSocket을 안전하게 보호하려면 어떻게해야합니까? (가능하더라도 가능합니까?)

해결법

  1. ==============================

    1.쿼리 문자열에 대한 지원이 SockJS 클라이언트에 추가 된 것 같습니다 (https://github.com/sockjs/sockjs-client/issues/72 참조).

    쿼리 문자열에 대한 지원이 SockJS 클라이언트에 추가 된 것 같습니다 (https://github.com/sockjs/sockjs-client/issues/72 참조).

  2. ==============================

    2.업데이트 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을 참조하십시오.

    업데이트 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을 참조하십시오.

    현재 (2016 년 9 월), Spring WebSocket 지원을 많이 작성한 @ rossen-stoyanchev가 응답 한 via 매개 ​​변수를 제외하고는 Spring에서 지원하지 않습니다. 잠재적 인 HTTP 참조 자의 누출 및 서버 로그에 토큰의 저장소가 있기 때문에 쿼리 매개 변수 방식이 마음에 들지 않습니다. 또한 보안 효과가 문제가되지 않는다면이 접근법이 실제 WebSocket 연결에서 작동한다는 것을 알았지 만 다른 메커니즘에 대한 대체와 함께 SockJS를 사용하는 경우에는 fallback에 대해 decideUser 메소드가 호출되지 않습니다. 스프링 4.x 토큰 기반 WebSocket SockJS 대체 인증을 참조하십시오.

    토큰 기반 WebSocket 인증에 대한 지원을 향상시키기 위해 Spring 문제를 만들었습니다 : https://jira.spring.io/browse/SPR-14690

    그동안 테스트에서 잘 작동하는 해킹을 발견했습니다. 빌트인 스프링 연결 수준 스프링 인증 기계를 건너 뜁니다. 대신 클라이언트 쪽의 Stomp 헤더에서 보내서 메시지 수준에서 인증 토큰을 설정합니다 (예 : 일반 HTTP XHR 호출로 이미 수행중인 작업을 멋지게 미러링 함). 예 :

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

    서버 측에서는 ChannelInterceptor를 사용하여 Stomp 메시지에서 토큰을 가져옵니다.

    @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의 인스턴스를 생성하여 환경에 노출시킨 다음, 인터셉터를 사용하여 Spring 자체에서 수행하는 것처럼 인터셉터를 업데이트하는 것입니다. 다른 말로하면 다음과 같습니다.

    @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 4.x Messaging 지원을 사용하는 경우 AbstractWebSocketMessageBrokerConfigurer의 @Order를 Spring Security의 AbstractSecurityWebSocketMessageBrokerConfigurer보다 높은 값으로 설정해야합니다 (위와 같이 Ordered.HIGHEST_PRECEDENCE + 50이 작동 함). 이렇게하면 인터셉터는 스프링 보안이 체크를 실행하고 보안 컨텍스트를 설정하기 전에 프린시 펄을 설정합니다.

    위의 코드에서 많은 사람들이이 줄로 혼란스러워합니다.

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

    이것은 스톰프 스페셜이 아니기 때문에 질문의 범위를 벗어납니다. 그렇지만 어쨌든 스프링으로 인증 토큰을 사용하는 것과 관련되어 있기 때문에 조금 더 자세히 설명하겠습니다. 토큰 기반 인증을 사용할 때 필요한 Principal은 일반적으로 Spring Security의 AbstractAuthenticationToken 클래스를 확장하는 커스텀 JwtAuthentication 클래스이다. AbstractAuthenticationToken은 Principal 인터페이스를 확장하는 Authentication 인터페이스를 구현하고 토큰을 Spring Security와 통합하는 대부분의 기계를 포함합니다.

    따라서, Kotlin 코드 (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가 필요합니다. 이것은 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는 JWT 토큰 파싱을 추상화하지만 jjwt와 같은 일반적인 JWT 토큰 라이브러리를 사용해야합니다. 주입 된 AuthService는 토큰의 클레임에 따라 UserEntity를 실제로 생성하고 사용자 데이터베이스 또는 다른 백엔드 시스템과 대화 할 수있는 추상화입니다.

    이제 우리가 처음 시작한 줄로 돌아가서, 다음과 같이 보일 것입니다. 여기서 authenticationManager는 Spring에 의해 어댑터에 삽입 된 AuthenticationManager이고 위에서 정의한 CustomTokenAuthenticationManager의 인스턴스입니다.

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

    이 교장은 위에서 설명한대로 메시지에 첨부됩니다. HTH!

  3. ==============================

    3.최신 SockJS 1.0.3을 사용하면 연결 URL의 일부로 쿼리 매개 변수를 전달할 수 있습니다. 따라서 세션을 승인하기 위해 JWT 토큰을 보낼 수 있습니다.

    최신 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? token = AAA & t = 1446482506843

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

    그런 다음 Spring을 사용하면 제공된 토큰을 사용하여 세션을 식별 할 수있는 필터를 설정할 수 있습니다.

  4. from https://stackoverflow.com/questions/30887788/json-web-token-jwt-with-spring-based-sockjs-stomp-web-socket by cc-by-sa and MIT license