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 消息协议层面用头信息进行认证。这样做需要两个简单的步骤。
- 使用 STOMP 客户端在连接时传递认证头信息。
- 用 ChannelInterceptor 处理认证头信息。
下一个例子使用服务器端的配置来注册一个自定义的认证拦截器。注意,拦截器只需要在 CONNECT 消息上进行认证和设置用户头。Spring 会记录并保存认证的用户,并将其与同一会话中的后续 STOMP 消息相关联。下面的例子展示了如何注册一个自定义认证拦截器:
@Configuration
@EnableWebSocketMessageBroker
public class MyConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
Authentication user = ... ; // access authentication header(s)
accessor.setUser(user);
}
return message;
}
});
}
}
此外,请注意,当你使用 Spring Security 的消息授权时,目前你需要确保认证 ChannelInterceptor 配置的顺序在 Spring Security 的前面。要做到这一点,最好是在自己的 WebSocketMessageBrokerConfigurer 实现中声明自定义拦截器,并标明@Order(Ordered.HIGHEST_PRECEDENCE + 99)
。
一个例子
在前面的例子 基础上进行改造
服务端使用拦截器认证
package cn.mrcode.study.springdocsread.websocket;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.util.StringUtils;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.server.standard.TomcatRequestUpgradeStrategy;
import java.security.Principal;
/**
* @author mrcode
*/
@Configuration
@EnableWebSocketMessageBroker
public class MyWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// portfolio 是 WebSocket(或 SockJS)的端点的 HTTP URL。客户端需要连接以进行 WebSocket 握手。
registry.addEndpoint("/portfolio")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 目标标头以 /app 开头的 STOMP 消息会被路由到 @Controller 类中的 @MessageMapping 方法。
config.setApplicationDestinationPrefixes("/app");
// 使用内置的消息代理进行订阅和广播,并且 将目标标头以 /topic 或 /queue 开头的消息路由到代理。
config.enableStompBrokerRelay("/topic", "/queue")
// 默认使用 ReactorNettyTcpClient
// .setTcpClient()
// 所以可以直接设置相关的属性
.setRelayHost("127.0.0.1")
.setRelayPort(61613)
;
}
/**
* 入栈 通道配置
*
* @param registration
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 添加拦截器
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 从消息中获取与 stomp 相关的请求头
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
System.out.println(accessor.getCommand());
// 如果当前的消息命令类型是 链接 类型,则处理 认证相关的信息
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// 获取登录用户 和 密码 header
final String login = accessor.getLogin();
final String passcode = accessor.getPasscode();
// 如果没有设置 login 头就抛出异常
if (!StringUtils.hasLength(login)) {
throw new RuntimeException("必须设置用户名");
}
// 在这里你可以查询数据库之类的方式验证
// 注意:这个 Authentication 是 security 里面的类
// Authentication user = ... ; // access authentication header(s)
// 但是这里要求的是 java.security.Principal 就可以,所以我们可以自己实现一个
accessor.setUser(new Principal() {
@Override
public String getName() {
return login;
}
});
}
return message;
}
});
}
/**
* spring 为了支持每种容器自己的 websocket 升级策略,抽象了 RequestUpgradeStrategy,
* <p>对 tomcat 提供了 TomcatRequestUpgradeStrategy 策略</p>
* 如果不申明这个,就会在启动的时候抛出异常:No suitable default RequestUpgradeStrategy found
*/
@Bean
public TomcatRequestUpgradeStrategy tomcatRequestUpgradeStrategy() {
return new TomcatRequestUpgradeStrategy();
}
}
这就可以生效了, 直接访问测试的 html 页面,可以发现后端控制台输出了以下两个命令
CONNECT
DISCONNECT
链接阶段后,就进入了断开链接阶段,而页面上的控制台中输出了如下的错误信息
前端捕获这个异常
关于 stomp js 的 api 可以参考它的文档。可以通过 connect 语法传入,这个也是在 API 文档中查看到的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>STOMP</title>
</head>
<body>
</body>
<script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
<script type="text/javascript"
src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
<script>
// 这里使用 sockJs 库链接
var socket = new SockJS("http://localhost:8080/portfolio");
// 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
// var socket = new WebSocket("ws://localhost:8080/portfolio");
var stompClient = webstomp.over(socket);
// 链接上 服务器时
stompClient.connect(
// 头信息
{},
// 链接成功回调函数
function (frame) {
console.log(frame)
// 订阅消息
stompClient.subscribe("/topic/greeting", msg => {
console.log("收到订阅的消息广播:" + msg.body)
})
// 订阅消息
stompClient.subscribe("/app/greeting2", msg => {
console.log("收到初始化的订阅消息:" + msg.body)
})
// 链接上服务器时,像服务器发送一个消息
stompClient.send("/app/greeting", "我的第一个消息")
},
// 链接失败函数
function (frame) {
console.log("链接失败:", frame)
// 如果是一个 stomp 帧,这判定 command ,如果没有这可能是一个 CloseEvent 对象
if (frame.command && frame.command == 'ERROR') {
console.log("链接失败原因:", frame.headers.message)
}
}
)
</script>
</html>
前端添加认证信息
只需要添加上头信息就可以了
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>STOMP</title>
</head>
<body>
</body>
<script type="text/javascript" src="/node_modules/webstomp-client/dist/webstomp.min.js"></script>
<script type="text/javascript"
src="https://cdnjs.loli.net/ajax/libs/sockjs-client/1.6.0/sockjs.js"></script>
<script>
// 这里使用 sockJs 库链接
var socket = new SockJS("http://localhost:8080/portfolio");
// 文章上说可以使用 WebSocket 链接。 实际上我这里测试不可以,会报错
// var socket = new WebSocket("ws://localhost:8080/portfolio");
var stompClient = webstomp.over(socket);
// 链接上 服务器时
stompClient.connect(
// 头信息
{
login: 'user1',
passcode: '123456',
},
// 链接成功回调函数
function (frame) {
console.log(frame)
// 订阅消息
stompClient.subscribe("/topic/greeting", msg => {
console.log("收到订阅的消息广播:" + msg.body)
})
// 订阅消息
stompClient.subscribe("/app/greeting2", msg => {
console.log("收到初始化的订阅消息:" + msg.body)
})
// 链接上服务器时,像服务器发送一个消息
stompClient.send("/app/greeting", "我的第一个消息")
},
// 链接失败函数
function (frame) {
console.log("链接失败:", frame)
// 如果是一个 stomp 帧,这判定 command ,如果没有这可能是一个 CloseEvent 对象
if (frame.command && frame.command == 'ERROR') {
console.log("链接失败原因:", frame.headers.message)
}
}
)
</script>
</html>