Token Authentication

Spring Security OAuth 提供了对基于令牌的安全支持,包括 JSON Web 令牌(JWT)。你可以在 Web 应用程序中使用它作为认证机制,包括上一节所述的 STOMP over WebSocket 交互(也就是通过基于 cookie 的会话来维护身份)。

同时,基于 cookie 的会话并不总是最合适的(例如,在不维护服务器端会话的应用中,或者在移动应用中,通常使用头信息进行验证)。

WebSocket 协议,RFC 6455 没有规定服务器在 WebSocket 握手过程中对客户端进行认证的任何特定方式。然而,在实践中,浏览器客户端只能使用标准认证头(即基本 HTTP 认证)或 cookies,而不能(例如)提供自定义头。同样地,SockJS 的 JavaScript 客户端也没有提供与 SockJS 传输请求一起发送 HTTP 头的方法。见 sockjs-client issue196。相反,它确实允许发送查询参数,你可以用它来发送令牌,但这也有自己的缺点(例如,令牌可能会无意中与服务器日志中的 URL 一起被记录)。

:::info 前面的限制是针对基于浏览器的客户端,不适用于基于 Spring Java 的 STOMP 客户端,该客户端确实支持通过 WebSocket 和 SockJS 请求发送头信息。 :::

因此,希望避免使用 cookies 的应用程序在 HTTP 协议层面上可能没有任何好的替代认证。与其使用 cookies,他们可能更喜欢在 STOMP 消息协议层面用头信息进行认证。这样做需要两个简单的步骤。

  1. 使用 STOMP 客户端在连接时传递认证头信息。
  2. 用 ChannelInterceptor 处理认证头信息。

下一个例子使用服务器端的配置来注册一个自定义的认证拦截器。注意,拦截器只需要在 CONNECT 消息上进行认证和设置用户头。Spring 会记录并保存认证的用户,并将其与同一会话中的后续 STOMP 消息相关联。下面的例子展示了如何注册一个自定义认证拦截器:

  1. @Configuration
  2. @EnableWebSocketMessageBroker
  3. public class MyConfig implements WebSocketMessageBrokerConfigurer {
  4. @Override
  5. public void configureClientInboundChannel(ChannelRegistration registration) {
  6. registration.interceptors(new ChannelInterceptor() {
  7. @Override
  8. public Message<?> preSend(Message<?> message, MessageChannel channel) {
  9. StompHeaderAccessor accessor =
  10. MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
  11. if (StompCommand.CONNECT.equals(accessor.getCommand())) {
  12. Authentication user = ... ; // access authentication header(s)
  13. accessor.setUser(user);
  14. }
  15. return message;
  16. }
  17. });
  18. }
  19. }

此外,请注意,当你使用 Spring Security 的消息授权时,目前你需要确保认证 ChannelInterceptor 配置的顺序在 Spring Security 的前面。要做到这一点,最好是在自己的 WebSocketMessageBrokerConfigurer 实现中声明自定义拦截器,并标明@Order(Ordered.HIGHEST_PRECEDENCE + 99)

一个例子

在前面的例子 基础上进行改造

服务端使用拦截器认证

  1. package cn.mrcode.study.springdocsread.websocket;
  2. import org.springframework.context.annotation.Bean;
  3. import org.springframework.context.annotation.Configuration;
  4. import org.springframework.messaging.Message;
  5. import org.springframework.messaging.MessageChannel;
  6. import org.springframework.messaging.simp.config.ChannelRegistration;
  7. import org.springframework.messaging.simp.config.MessageBrokerRegistry;
  8. import org.springframework.messaging.simp.stomp.StompCommand;
  9. import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
  10. import org.springframework.messaging.support.ChannelInterceptor;
  11. import org.springframework.messaging.support.MessageHeaderAccessor;
  12. import org.springframework.util.StringUtils;
  13. import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
  14. import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
  15. import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
  16. import org.springframework.web.socket.server.standard.TomcatRequestUpgradeStrategy;
  17. import java.security.Principal;
  18. /**
  19. * @author mrcode
  20. */
  21. @Configuration
  22. @EnableWebSocketMessageBroker
  23. public class MyWebSocketConfig implements WebSocketMessageBrokerConfigurer {
  24. @Override
  25. public void registerStompEndpoints(StompEndpointRegistry registry) {
  26. // portfolio 是 WebSocket(或 SockJS)的端点的 HTTP URL。客户端需要连接以进行 WebSocket 握手。
  27. registry.addEndpoint("/portfolio")
  28. .setAllowedOriginPatterns("*")
  29. .withSockJS();
  30. }
  31. @Override
  32. public void configureMessageBroker(MessageBrokerRegistry config) {
  33. // 目标标头以 /app 开头的 STOMP 消息会被路由到 @Controller 类中的 @MessageMapping 方法。
  34. config.setApplicationDestinationPrefixes("/app");
  35. // 使用内置的消息代理进行订阅和广播,并且 将目标标头以 /topic 或 /queue 开头的消息路由到代理。
  36. config.enableStompBrokerRelay("/topic", "/queue")
  37. // 默认使用 ReactorNettyTcpClient
  38. // .setTcpClient()
  39. // 所以可以直接设置相关的属性
  40. .setRelayHost("127.0.0.1")
  41. .setRelayPort(61613)
  42. ;
  43. }
  44. /**
  45. * 入栈 通道配置
  46. *
  47. * @param registration
  48. */
  49. @Override
  50. public void configureClientInboundChannel(ChannelRegistration registration) {
  51. // 添加拦截器
  52. registration.interceptors(new ChannelInterceptor() {
  53. @Override
  54. public Message<?> preSend(Message<?> message, MessageChannel channel) {
  55. // 从消息中获取与 stomp 相关的请求头
  56. StompHeaderAccessor accessor =
  57. MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
  58. System.out.println(accessor.getCommand());
  59. // 如果当前的消息命令类型是 链接 类型,则处理 认证相关的信息
  60. if (StompCommand.CONNECT.equals(accessor.getCommand())) {
  61. // 获取登录用户 和 密码 header
  62. final String login = accessor.getLogin();
  63. final String passcode = accessor.getPasscode();
  64. // 如果没有设置 login 头就抛出异常
  65. if (!StringUtils.hasLength(login)) {
  66. throw new RuntimeException("必须设置用户名");
  67. }
  68. // 在这里你可以查询数据库之类的方式验证
  69. // 注意:这个 Authentication 是 security 里面的类
  70. // Authentication user = ... ; // access authentication header(s)
  71. // 但是这里要求的是 java.security.Principal 就可以,所以我们可以自己实现一个
  72. accessor.setUser(new Principal() {
  73. @Override
  74. public String getName() {
  75. return login;
  76. }
  77. });
  78. }
  79. return message;
  80. }
  81. });
  82. }
  83. /**
  84. * spring 为了支持每种容器自己的 websocket 升级策略,抽象了 RequestUpgradeStrategy,
  85. * <p>对 tomcat 提供了 TomcatRequestUpgradeStrategy 策略</p>
  86. * 如果不申明这个,就会在启动的时候抛出异常:No suitable default RequestUpgradeStrategy found
  87. */
  88. @Bean
  89. public TomcatRequestUpgradeStrategy tomcatRequestUpgradeStrategy() {
  90. return new TomcatRequestUpgradeStrategy();
  91. }
  92. }

这就可以生效了, 直接访问测试的 html 页面,可以发现后端控制台输出了以下两个命令

  1. CONNECT
  2. DISCONNECT

链接阶段后,就进入了断开链接阶段,而页面上的控制台中输出了如下的错误信息
image.png

前端捕获这个异常

关于 stomp js 的 api 可以参考它的文档。可以通过 connect 语法传入,这个也是在 API 文档中查看到的
image.png

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>STOMP</title>
  6. </head>
  7. <body>
  8. </body>
  9. <script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
  10. <script type="text/javascript"
  11. src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
  12. <script>
  13. // 这里使用 sockJs 库链接
  14. var socket = new SockJS("http://localhost:8080/portfolio");
  15. // 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
  16. // var socket = new WebSocket("ws://localhost:8080/portfolio");
  17. var stompClient = webstomp.over(socket);
  18. // 链接上 服务器时
  19. stompClient.connect(
  20. // 头信息
  21. {},
  22. // 链接成功回调函数
  23. function (frame) {
  24. console.log(frame)
  25. // 订阅消息
  26. stompClient.subscribe("/topic/greeting", msg => {
  27. console.log("收到订阅的消息广播:" + msg.body)
  28. })
  29. // 订阅消息
  30. stompClient.subscribe("/app/greeting2", msg => {
  31. console.log("收到初始化的订阅消息:" + msg.body)
  32. })
  33. // 链接上服务器时,像服务器发送一个消息
  34. stompClient.send("/app/greeting", "我的第一个消息")
  35. },
  36. // 链接失败函数
  37. function (frame) {
  38. console.log("链接失败:", frame)
  39. // 如果是一个 stomp 帧,这判定 command ,如果没有这可能是一个 CloseEvent 对象
  40. if (frame.command && frame.command == 'ERROR') {
  41. console.log("链接失败原因:", frame.headers.message)
  42. }
  43. }
  44. )
  45. </script>
  46. </html>

前端添加认证信息

只需要添加上头信息就可以了

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>STOMP</title>
  6. </head>
  7. <body>
  8. </body>
  9. <script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
  10. <script type="text/javascript"
  11. src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
  12. <script>
  13. // 这里使用 sockJs 库链接
  14. var socket = new SockJS("http://localhost:8080/portfolio");
  15. // 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
  16. // var socket = new WebSocket("ws://localhost:8080/portfolio");
  17. var stompClient = webstomp.over(socket);
  18. // 链接上 服务器时
  19. stompClient.connect(
  20. // 头信息
  21. {
  22. login: 'user1',
  23. passcode: '123456',
  24. },
  25. // 链接成功回调函数
  26. function (frame) {
  27. console.log(frame)
  28. // 订阅消息
  29. stompClient.subscribe("/topic/greeting", msg => {
  30. console.log("收到订阅的消息广播:" + msg.body)
  31. })
  32. // 订阅消息
  33. stompClient.subscribe("/app/greeting2", msg => {
  34. console.log("收到初始化的订阅消息:" + msg.body)
  35. })
  36. // 链接上服务器时,像服务器发送一个消息
  37. stompClient.send("/app/greeting", "我的第一个消息")
  38. },
  39. // 链接失败函数
  40. function (frame) {
  41. console.log("链接失败:", frame)
  42. // 如果是一个 stomp 帧,这判定 command ,如果没有这可能是一个 CloseEvent 对象
  43. if (frame.command && frame.command == 'ERROR') {
  44. console.log("链接失败原因:", frame.headers.message)
  45. }
  46. }
  47. )
  48. </script>
  49. </html>

image.png