这里有三个问题:
1. http session支持分布式;
2. session 同时支持 cookie 和 header 传递;
3. websocket 连接 共享 http session。
对于第一个问题,很简单:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 600)
原理:session 放在公共的地方,所有服务都能共享。
第二个问题,本来也是很简单的事情,想着肯定有现成方案,结果在国内外网上查了一圈,没找到可以直接复制黏贴的。这个也有些 org.springframework.session:spring-session 版本更新导致的因素在里面,我目前用的是 springboot 2.2.5.RELEASE,已经和 spring-session 不那么配套了,现在 springboot 是以 HttpSessionIdResolver 来决定 http session 的存取,而不是以前的 HttpSessionStrategy,但是IoC核心没变,做类似的解决方案就行了,网上没有,那就自己动手,”我还是从前那个少年,没有一丝丝改变“。
Springboot 默认只提供:CookieHttpSessionIdResolver(默认被配置)和 HeaderHttpSessionIdResolver 两个 HttpSessionIdResolver,且一般只配置一个,所谓 HttpSessionIdResolver 就是 HttpSession id 的解析器。现在把这两个合并成一个,然后使用就行了:
public class HeaderCookieHttpSessionIdResolver implements HttpSessionIdResolver {
private static final String WRITTEN_SESSION_ID_ATTR = CookieHttpSessionIdResolver.class.getName().concat(".WRITTEN_SESSION_ID_ATTR");
private static final String HEADER_X_AUTH_TOKEN = "X-AUTH-TOKEN";
private CookieSerializer cookieSerializer = new DefaultCookieSerializer();
/**
* Sets the {@link CookieSerializer} to be used.
*
* @param cookieSerializer the cookieSerializer to set. Cannot be null.
*/
public void setCookieSerializer(CookieSerializer cookieSerializer) {
if (cookieSerializer == null) {
throw new IllegalArgumentException("cookieSerializer cannot be null");
}
this.cookieSerializer = cookieSerializer;
}
@Override
public List resolveSessionIds(HttpServletRequest request) {
String headerValue = request.getHeader(HEADER_X_AUTH_TOKEN);
if (!StringUtils.isEmpty(headerValue)) return Collections.singletonList(headerValue);
return this.cookieSerializer.readCookieValues(request);
}
@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
response.setHeader(HEADER_X_AUTH_TOKEN, sessionId);
if (!sessionId.equals(request.getAttribute(WRITTEN_SESSION_ID_ATTR))) {
request.setAttribute(WRITTEN_SESSION_ID_ATTR, sessionId);
this.cookieSerializer.writeCookieValue(new CookieValue(request, response, sessionId));
}
}
@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
response.setHeader(HEADER_X_AUTH_TOKEN, "");
this.cookieSerializer.writeCookieValue(new CookieValue(request, response, ""));
}
}
这里需要注意的是:我把 header 验证放在了 cookie 之前,因为一般的浏览器或者 http 工具都会默认打开 cookie 存储,所以每一个客户端的请求带的 cookie 是不同的,就会被识别成不同的会话。先验证 header,如果不包含 X-AUTH-TOKEN 字段,那么就认为是从同一个客户端发来的请求,去验证 cookie。
(顺便吐槽一下:springboot 内置类代码也真是各种风格,也有一些不怎么标准嘛。。)
把这个解析器放入 IoC 容器:
// session策略,这里同时提供Header,Cookie方式
@Bean("httpSessionIdResolver")
public HeaderCookieHttpSessionIdResolver httpSessionIdResolver() {
return new HeaderCookieHttpSessionIdResolver();
}
原理: 重写默认处理,合并处理逻辑。
第三个问题,就是把 http session id 给前端,连 websocket 的时候,带在 url 后面参数或者 header 里都可以,然后认证的时候取出来看看这个 http session 死没死 在不在。
首先,http 登录成功后,返回 http session id:
@Slf4j
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private SupportConfiguration supportConfiguration;
public MyAuthenticationSuccessHandler() {
}
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication)
throws IOException, ServletException {
MyResponse response = new MyResponse();
HttpSession httpSession = httpServletRequest.getSession();
httpSession.setAttribute("http_principal", authentication);
// String csrfTokenKey = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");
// String csrfToken = ((CsrfToken) httpSession.getAttribute(csrfTokenKey)).getToken();
log.info("http session id: " + httpSession.getId());
// log.info("csrf token: " + csrfToken);
response.setData(new JSONObject() //
// .fluentPut("csrf_token", csrfToken) // csrf token
.fluentPut("auth_token", httpSession.getId()) // auth token = session id
.fluentPut("cmconnect", supportConfiguration.getCmconnectUrl()) // cmconnect address
.fluentPut("websocket", supportConfiguration.getWebsocketUrl()) // websocket connection address
);
httpServletResponse.setHeader("Content-Type", "application/json;charset=UTF-8");
httpServletResponse.getWriter().write(response.toString());
}
}
然后配置 websocket 端点及握手时期过滤器:
/**
*
* 兼容web端 SockJS,如果是安卓直接Websocket访问,url后再加 /websocket
* 即:ws://x.x.x.x/stomp/websocket
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) {
stompEndpointRegistry // ------------------------------------------------------------------
.addEndpoint("/stomp") // 将/serviceName/stomp/websocket路径注册为STOMP的端点
.setAllowedOrigins("*") // 可以跨域
.addInterceptors(handshakeInterceptor) // 自己定义的获取httpsession的拦截器
.setHandshakeHandler(handshakeHandler) // 封装认证用户信息
.withSockJS() // 支持socktJS访问
; // --------------------------------------------------------------------------------------
}
最后编写握手处理:
/**
* <设置认证用户信息的握手拦截器>
**/
@Slf4j
@Service
public class MyHandshakeInterceptor implements HandshakeInterceptor {
@Resource(name = "sessionRepository")
private SessionRepository sessionRepository;
@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler wsHandler,
Map attributes) {
log.debug("websocket principal: " + serverHttpRequest.getPrincipal());
log.debug("websocket connect, request uri: " + serverHttpRequest.getURI());
String token = getToken(serverHttpRequest);
if (StringUtils.isEmpty(token)) {
log.error("Token empty!");
return false;
}
Session session = sessionRepository.findById(token);
if (session == null) {
log.error("Session not exist!");
return false;
}
Principal httpPrincipal = (Principal) session.getAttribute("http_principal");
log.debug("http principal: " + httpPrincipal);
attributes.put("http_principal", httpPrincipal);
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
private String getToken(ServerHttpRequest serverHttpRequest) {
String token = "";
// 从http请求的url参数中获取
MultiValueMap parameters = UriComponentsBuilder.fromUri(serverHttpRequest.getURI()).build().getQueryParams();
List authParameter = parameters.get("_auth");
if (authParameter != null) token = authParameter.get(0);
// 从http请求的header中获取
if (token == null && serverHttpRequest.getHeaders().get("X-AUTH-TOKEN") != null)
token = serverHttpRequest.getHeaders().get("X-AUTH-TOKEN").get(0);
return token;
}
}
原理:websocket 连接是建立在 http 上的,在 ws 连接前,会以 http 握手一次,这时即可以当普通 http 请求就行。