programing

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

powerit 2023. 9. 20. 20:48
반응형

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

배경

스프링부트(1.3.0)를 사용하여 RESTful 웹 어플리케이션을 설정하는 중입니다.BUILD-SNAPshot)은 iOS 앱과 웹 브라우저에서 사용하고자 하는 STOMP/SockJS 웹소켓을 포함합니다.REST 요청과 WebSocket 인터페이스를 보안하기 위해 JSON Web Tokens(JWT)를 사용하고 싶은데 후자가 어렵습니다.

이 앱은 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합니다. -합니다.

@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에 등록된WebSecurityConfiguration

문제

하지만 웹소켓을 JWT로 보안할 수 없을 것 같습니다.브라우저에서 SockJS 1.1.0STOMP 1.7.1을 사용하고 있는데 토큰을 전달하는 방법을 모르겠어요.SockJS가 매개 변수를 이니셜과 함께 보내는 것을 허용하지 않는 것으로 보입니다./info그리고/또는 악수 요청.

Spring Security for WebSockets 문서에는 다음과 같이 나와 있습니다.AbstractSecurityWebSocketMessageBrokerConfigurer다음을 보장합니다.

인바운드 CONNECT 메시지에 동일 오리진 정책을 적용하려면 유효한 CSRF 토큰이 필요합니다.

이는 STOMP CONNECT 메시지를 수신하는 시점에서 초기 핸드셰이크가 안전하지 않고 인증이 호출되어야 함을 의미하는 것으로 보입니다.유감스럽게도 이를 실행하는 것과 관련된 어떤 정보도 찾을 수 없는 것 같지 않습니다.또한 웹소켓 연결을 열고 STOMP CONNECT를 전송하지 않는 악성 클라이언트의 연결을 끊으려면 추가 논리가 필요합니다.

봄을 처음 접하게 된 저는 또한 봄 세션이 여기에 들어맞는지 또는 어떻게 들어맞는지 잘 모르겠습니다.설명서는 매우 상세하지만, 다양한 구성 요소가 서로 어떻게 결합/상호 작용하는지에 대한 멋지고 간단한(일명 바보) 가이드는 없는 것 같습니다.

질문.

JSON 웹 토큰을 제공하여 SockJS 웹소켓을 보호하려면 어떻게 해야 합니까? 가급적이면 악수할 때(가능하기도 하나요?)

현재 상황

UPDATE 2016-12-13 : 아래 참조된 문제는 수정된 것으로 표시되어 있으므로 Spring 4.3.5 이상에서는 아래 해킹이 더 이상 필요 없습니다.https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication 를 참조하십시오.

이전 상황

현재(2016년 9월) Spring WebSocket 지원 내용을 많이 작성한 @rosen-stoyanchev에서 답변한 쿼리 매개 변수를 제외하고는 Spring에서 지원하지 않습니다.HTTP 레퍼러 유출 가능성과 토큰을 서버 로그에 저장하기 때문에 쿼리 파라미터 접근 방식이 마음에 들지 않습니다.또한 보안상의 영향이 없다면, 이 접근 방식이 진정한 웹소켓 연결에 효과가 있다는 것을 알았지만, 다른 메커니즘에 대한 폴백과 함께 SockJS를 사용하는 경우,determineUser메서드는 폴백에 대해 호출되지 않습니다.Spring 4.x 토큰 기반 WebSocket SockJS 폴백 인증 참조.

토큰 기반 WebSocket 인증에 대한 지원을 개선하기 위해 Spring호를 만들었습니다. https://jira.spring.io/browse/SPR-14690

해킹 잇

그동안 테스트에서 잘 작동하는 해킹을 발견했습니다.내장된 Spring connection-level Spring 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);
      }
    })
  }
}

은 , 합니다, 에 대해 있습니다.Principal필요한 모든 컨트롤러 메소드에 액세스하고 Spring Security 4.x의 컨텍스트에 노출하며 특정 사용자/세션에 메시지를 보낼 수 있도록 웹소켓 세션에 사용자를 연결합니다.

스프링 시큐리티 메시지

으로인 Spring Security 4.x Messaging 를 해야 합니다.@Orderf의AbstractWebSocketMessageBrokerConfigurerAbstractSecurityWebSocketMessageBrokerConfigurer(Ordered.HIGHEST_PRECEDENCE + 50위와 같이 효과가 있을 것입니다.의 인터셉트가PrincipalSpring Security가 체크를 실행하고 보안 컨텍스트를 설정하기 전에.

주체 생성 (2018년 6월 업데이트)

많은 사람들이 위의 코드에 있는 이 행으로 혼란스러워 하는 것 같습니다.

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

Stomp에 특화된 것이 아니기 때문에 질문의 범위를 상당히 벗어났지만, 어쨌든 조금 더 자세히 설명하겠습니다. Spring과 함께 auth token을 사용하는 것과 관련이 있기 때문입니다.Principal가 될 입니다.JwtAuthentications을 확장하는 AbstractAuthenticationToken급.AbstractAuthenticationToken현을 합니다.Authentication스를 .Principalinterface, 및 토큰을 Spring Security와 통합하기 위한 대부분의 기계를 포함하고 있습니다.

의 , Kotlin 로( 자바 로), JwtAuthenticationAbstractAuthenticationToken:

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)
}

주사를 맞은 사람은TokenHandlerJWT 토큰 구문 분석을 추상화하지만 jjwt와 같은 공통 JWT 토큰 라이브러리를 사용해야 합니다.주사를 맞은 사람은AuthService입니다를 냅니다.UserEntity토큰의 클레임을 기반으로 사용자 데이터베이스 또는 다른 백엔드 시스템과 대화할 수 있습니다.

가 처음 , , , 서, 가 수 있습니다.authenticationManager입니다.AuthenticationManager이다,의 한 입니다.CustomTokenAuthenticationManager위에서 정의했습니다.

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

그러면 이 principle은 위에 설명한 대로 메시지에 첨부됩니다. HTH!

최신 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
  });

이제 웹소켓과 관련된 모든 요청에 매개 변수 "? token=이(가) 표시됩니다.AAA"

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

http://localhost/ws/515/z45wjz24/웹소켓?token=aaa

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

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

로서는 auth token 하거나, 하고, auth token 할 수 .CONNECT요격기에 명령을 내립니다.

가장 좋은 것은 헤더를 사용하는 것이지만 문제는 핸드셰이크 단계에서 네이티브 헤더에 접근할 수 없기 때문에 그때는 인증을 처리할 수 없다는 것입니다.

예를 들어 코드 몇 가지를 말씀드리겠습니다.

구성:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-test")
                .setHandshakeHandler(new SecDefaultHandshakeHandler())
                .addInterceptors(new HttpHandshakeInterceptor())
                .withSockJS()
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new JwtChannelInterceptor())
    }
}

악수 인터셉터:

public class HttpHandshakeInterceptor implements HandshakeInterceptor {
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Map<String, Object> attributes) {
        attributes.put("token", request.getServletRequest().getParameter("auth_token")
        return true
    }
}

악수 처리기:

public class SecDefaultHandshakeHandler extends DefaultHandshakeHandler {
    @Override
    public Principal determineUser(ServerHttpRequest request, WebSocketHandler handler, Map<String, Object> attributes) {
        Object token = attributes.get("token")
        //handle authorization here
    }
}

채널 인터셉터:

public class JwtChannelInterceptor implements ChannelInterceptor {
    @Override
    public void postSend(Message message, MessageChannel channel, Boolean sent) {
        MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class)

        if (StompCommand.DISCONNECT == accessor.getCommand()) {
            //retrieve Principal here via accessor.getUser()
            //or get auth header from the accessor and handle authorization
        }
    }
}

컴파일 실수 가능성 죄송합니다, Kotlin 코드에서 수동으로 변환하고 있었습니다 =)

웹소켓용 웹 클라이언트와 모바일 클라이언트를 모두 보유하고 있다고 말씀하신 것처럼, 모든 클라이언트에 대해 동일한 코드베이스를 유지하는 데 어려움이 있습니다. 스레드를 확인하십시오: Spring Websocket ChannelInterceptor not fireing CONNECT 이벤트

저는 간단한 해결책을 찾기 위해 많은 시간을 보냅니다.나에게는 라만의 해결책이 통하지 않았습니다.사용자 지정 베어러만 정의하면 됩니다.TokenResolver 메서드 및 액세스 토큰을 쿠키 또는 매개 변수에 넣습니다.

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.cors()
                    .and()
                    .authorizeRequests()
                    .antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
                    .hasAuthority("SCOPE_read")
                    .antMatchers(HttpMethod.POST, "/api/foos")
                    .hasAuthority("SCOPE_write")
                    .anyRequest()
                    .authenticated()
                    .and()
                    .oauth2ResourceServer()
                .jwt().and().bearerTokenResolver(this::tokenExtractor);
}
...
}

public String tokenExtractor(HttpServletRequest request) {
    String header = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (header != null)
        return header.replace("Bearer ", "");
    Cookie cookie = WebUtils.getCookie(request, "access_token");
    if (cookie != null)
        return cookie.getValue();
    return null;
}

언급URL : https://stackoverflow.com/questions/30887788/json-web-token-jwt-with-spring-based-sockjs-stomp-web-socket

반응형