Pertanyaan JSON Web Token (JWT) dengan SockJS / STOMP Web Socket berbasis Spring


Latar Belakang

Saya sedang dalam proses menyiapkan aplikasi web yang tenang menggunakan Spring Boot (1.3.0.BUILD-SNAPSHOT) yang menyertakan STOMP / SockJS WebSocket, yang ingin saya konsumsi dari aplikasi iOS dan juga browser web. Saya ingin menggunakannya Token Web JSON (JWT) untuk mengamankan permintaan REST dan antarmuka WebSocket tetapi saya mengalami kesulitan dengan yang terakhir.

Aplikasi ini dijamin dengan Keamanan Musim Semi: -

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

}

Konfigurasi WebSocket adalah standar: -

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

}

Saya juga memiliki subkelas AbstractSecurityWebSocketMessageBrokerConfigurer untuk mengamankan 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;
    }

}

Ada juga beberapa @RestController kelas anotasi untuk menangani berbagai bit fungsionalitas dan ini berhasil diamankan melalui JWTTokenFilter terdaftar di saya WebSecurityConfiguration kelas.

Masalah

Namun saya tidak bisa mendapatkan WebSocket untuk diamankan dengan JWT. saya menggunakan SockJS 1.1.0 dan STOMP 1.7.1 di browser dan tidak tahu cara meneruskan token. Saya t akan muncul itu SockJS tidak mengijinkan parameter untuk dikirim dengan yang awal /info dan / atau permintaan jabat tangan.

Itu Keamanan Musim Semi untuk dokumentasi WebSockets menyatakan bahwa AbstractSecurityWebSocketMessageBrokerConfigurer memastikan bahwa:

Setiap pesan CONNECT masuk membutuhkan token CSRF yang valid untuk menegakkan Kebijakan Asal yang Sama

Yang tampaknya menyiratkan bahwa jabat tangan awal harus tidak aman dan otentikasi dipanggil pada titik menerima pesan STOMP CONNECT. Sayangnya saya tidak dapat menemukan informasi apa pun terkait dengan penerapan ini. Selain itu, pendekatan ini akan membutuhkan logika tambahan untuk memutuskan klien nakal yang membuka koneksi WebSocket dan tidak pernah mengirim STOMP CONNECT.

Menjadi (sangat) baru di Spring Saya juga tidak yakin apakah atau bagaimana Spring Sessions cocok dengan ini. Sementara dokumentasi sangat rinci, tidak ada panduan yang bagus dan sederhana (alias idiot) tentang bagaimana berbagai komponen cocok bersama / berinteraksi satu sama lain.

Pertanyaan

Bagaimana cara saya mengamankan SockJS WebSocket dengan menyediakan Token Web JSON, sebaiknya pada titik jabat tangan (apakah mungkin)?


32
2018-06-17 09:37


asal


Jawaban:


Sepertinya dukungan untuk string kueri telah ditambahkan ke klien SockJS, lihat https://github.com/sockjs/sockjs-client/issues/72.


3
2018-06-29 20:19



Situasi saat ini

UPDATE 2016-12-13 : masalah yang dirujuk di bawah ini sekarang ditandai tetap, sehingga peretasan di bawah tidak lagi diperlukan yang Musim Semi 4.3.5 atau lebih tinggi. Lihat https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication.

Situasi Sebelumnya

Saat ini (Sep 2016), ini tidak didukung oleh Spring kecuali melalui parameter kueri seperti dijawab oleh @ rossen-stoyanchev, yang menulis banyak (semua?) Dari dukungan Spring WebSocket. Saya tidak suka pendekatan parameter kueri karena kebocoran perujuk HTTP potensial dan penyimpanan token di log server. Selain itu, jika konsekuensi keamanan tidak mengganggu Anda, perhatikan bahwa saya telah menemukan pendekatan ini berfungsi untuk sambungan WebSocket yang sebenarnya, tapi jika Anda menggunakan SockJS dengan fallbacks ke mekanisme lain, itu determineUser metode tidak pernah dipanggil untuk mundur. Lihat Spring 4.x token berbasis WebSocket SockJS fallback authentication.

Saya telah membuat edisi Musim Semi untuk meningkatkan dukungan otentikasi WebSocket berbasis token: https://jira.spring.io/browse/SPR-14690 

Meretasnya

Sementara itu, saya menemukan peretasan yang berfungsi baik dalam pengujian. Lewati Spring auth semi-koneksi tingkat Semi mesin. Sebagai gantinya, atur token otentikasi pada tingkat pesan dengan mengirimnya ke tajuk Stomp di sisi klien (ini dengan baik mencerminkan apa yang sudah Anda lakukan dengan panggilan HTTP XHR reguler) misalnya:

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

Di sisi server, dapatkan token dari pesan Stomp menggunakan a 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)
    }
  })

Ini sederhana dan memberi kita 85% dari jalan di sana, namun, pendekatan ini tidak mendukung pengiriman pesan ke pengguna tertentu. Ini karena mesin Spring untuk mengasosiasikan pengguna ke sesi tidak dipengaruhi oleh hasil ChannelInterceptor. Spring WebSocket mengasumsikan otentikasi dilakukan pada lapisan transport, bukan lapisan pesan, dan dengan demikian mengabaikan otentikasi tingkat-pesan.

Peretasan untuk membuat ini berfungsi, adalah untuk membuat contoh kami DefaultSimpUserRegistry dan DefaultUserDestinationResolverPaparkan mereka ke lingkungan, dan kemudian gunakan interceptor untuk memperbarui mereka seolah-olah Spring sendiri melakukannya. Dengan kata lain, sesuatu seperti:

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

Sekarang Spring sepenuhnya menyadari otentikasi yang disuntikkan Principal ke dalam metode pengontrol apa pun yang memerlukannya, memaparkannya pada konteks untuk Keamanan Musim Semi 4.x, dan mengaitkan pengguna ke sesi WebSocket untuk mengirim pesan ke pengguna / sesi tertentu.

Keamanan Spring Messaging

Terakhir, jika Anda menggunakan Spring Security 4.x Dukungan olahpesan, pastikan untuk mengatur @Order Anda AbstractWebSocketMessageBrokerConfigurer ke nilai yang lebih tinggi dari Spring Security AbstractSecurityWebSocketMessageBrokerConfigurer (Ordered.HIGHEST_PRECEDENCE + 50 akan bekerja, seperti yang ditunjukkan di atas). Dengan begitu, pencegat Anda mengaturnya Principal sebelum Keamanan Musim Semi mengeksekusi ceknya dan mengatur konteks keamanan.

Menciptakan Prinsipal (Pembaruan Juni 2018)

Banyak orang tampaknya bingung dengan baris ini dalam kode di atas:

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

Ini cukup banyak ruang lingkup untuk pertanyaan karena tidak Stomp-spesifik, tapi saya akan memperluas sedikit, karena terkait dengan menggunakan token auth dengan Spring. Saat menggunakan autentikasi berbasis token, Principal yang Anda butuhkan biasanya akan menjadi kebiasaan JwtAuthentication kelas yang memperpanjang Spring Security AbstractAuthenticationToken kelas. AbstractAuthenticationToken mengimplementasikan Authentication antarmuka yang memanjang Principal antarmuka, dan berisi sebagian besar mesin untuk mengintegrasikan token Anda dengan Keamanan Musim Semi.

Jadi, dalam kode Kotlin (maaf saya tidak punya waktu atau keinginan untuk menerjemahkan ini kembali ke Java), Anda JwtAuthentication mungkin terlihat seperti ini, yang merupakan pembungkus sederhana 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
}

Sekarang Anda membutuhkan sebuah AuthenticationManager yang tahu bagaimana mengatasinya. Ini mungkin terlihat seperti berikut, lagi di 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)
}

Yang disuntikkan TokenHandler abstrak pergi token parsing JWT, tetapi harus menggunakan perpustakaan token JWT umum seperti jjwt. Yang disuntikkan AuthService adalah abstraksi Anda yang benar-benar menciptakan Anda UserEntity berdasarkan klaim dalam token, dan dapat berbicara dengan basis data pengguna Anda atau sistem backend lain (s).

Sekarang, kembali ke garis yang kita mulai, mungkin terlihat seperti ini, di mana authenticationManager adalah AuthenticationManager disuntikkan ke adaptor kami oleh Spring, dan merupakan turunan dari CustomTokenAuthenticationManager kami definisikan di atas:

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

Pokok ini kemudian dilampirkan pada pesan seperti yang dijelaskan di atas. HTH!


28
2017-09-12 18:10



Dengan SockJS 1.0.3 terbaru Anda dapat mengirimkan parameter permintaan sebagai bagian dari URL koneksi. Dengan demikian Anda dapat mengirim beberapa token JWT untuk mengotorisasi sesi.

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

Sekarang semua permintaan yang terkait dengan websocket akan memiliki parameter "? Token = AAA"

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

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

Kemudian dengan Spring Anda dapat mengatur beberapa filter yang akan mengidentifikasi sesi menggunakan token yang disediakan.


7
2017-11-02 17:17