一、跨域问题
web 开发中跨域问题是一个老生常谈的问题,根本原因是浏览器基于安全原因考虑对非同源的脚本操作和 ajax 访问进行了限制,介绍的文章网上有很多,这里不做赘述。
二、解决方案
跨域问题有多种解决方案,笔者认为最简单的办法的就是用 nginx 反向代理将不同源的静态站点和后端 rest 接口转换为同源,这样在浏览器端打开就不存在跨域问题了,当然这并不是接下来介绍的解决方案。
Spring MVC 添加跨域配置支持,集成 Shiro,并且使用自定义的 SessionManager 替代容器自带的 Session 管理功能,主要代码如下:
package com.qiwen.base.config.shiro;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.SessionContext;
import org.apache.shiro.web.servlet.Cookie;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.Serializable;
/**
* 扩展了 SessionId 保存逻辑
*/
@Slf4j
public class QWWebSessionManager extends DefaultWebSessionManager {
/**
* 保存新 sessionId 默认实现
*/
public interface SessionIdManager {
/**
* 从请求中读取 sessionId 信息
* @param sessionManager QWWebSessionManager 对象引用
* @param request
* @param response
* @return
*/
Serializable getSessionId(QWWebSessionManager sessionManager, ServletRequest request, ServletResponse response);
/**
* 保存 sessionId 到客户端
* @param currentId 新创建的 sessionId,保证客户端能够获取到此值
* @param realCookie 需要保存到客户端的 cookie 信息, 按照需求决定是否返回给客户端
* @param request
* @param response
*/
void storeSessionId(Serializable currentId, Cookie realCookie, HttpServletRequest request, HttpServletResponse response);
}
private SessionIdManager sessionIdManager;
public QWWebSessionManager() {
}
public QWWebSessionManager(SessionIdManager sessionIdManager) {
this.sessionIdManager = sessionIdManager;
}
public SessionIdManager getSessionIdManager() {
return sessionIdManager;
}
public void setSessionIdManager(SessionIdManager sessionIdManager) {
this.sessionIdManager = sessionIdManager;
}
private void storeSessionId(Serializable currentId, HttpServletRequest request, HttpServletResponse response) {
if (currentId == null) {
String msg = "sessionId cannot be null when persisting for subsequent requests.";
throw new IllegalArgumentException(msg);
}
// 不能调用 template.saveTo 方法保存 sessionId, 因为 template 是共享的,直接调用在多线程是不安全的
Cookie template = getSessionIdCookie();
Cookie cookie = new SimpleCookie(template);
String idString = currentId.toString();
cookie.setValue(idString);
if(this.sessionIdManager != null) {
sessionIdManager.storeSessionId(currentId, cookie, request, response);
} else {
cookie.saveTo(request, response);
}
log.trace("Set session ID cookie for session with id {}", idString);
}
@Override
protected void onStart(Session session, SessionContext context) {
if (!WebUtils.isHttp(context)) {
log.debug("SessionContext argument is not HTTP compatible or does not have an HTTP request/response " +
"pair. No session ID cookie will be set.");
return;
}
HttpServletRequest request = WebUtils.getHttpRequest(context);
HttpServletResponse response = WebUtils.getHttpResponse(context);
if (isSessionIdCookieEnabled()) {
Serializable sessionId = session.getId();
storeSessionId(sessionId, request, response);
} else {
log.debug("Session ID cookie is disabled. No cookie has been set for new session with id {}", session.getId());
}
request.removeAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_IS_NEW, Boolean.TRUE);
}
public Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
return super.getSessionId(request, response);
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
Serializable sessionId;
if(this.sessionIdManager != null) {
sessionId = this.sessionIdManager.getSessionId(this, request, response);
if (sessionId != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
//automatically mark it valid here. If it is invalid, the
//onUnknownSession method below will be invoked and we'll remove the attribute at that time.
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
}
// always set rewrite flag - SHIRO-361
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
} else {
sessionId = super.getSessionId(request, response);
}
return sessionId;
}
}
上文的中的代码定义了 QWWebSessionManager.SessionIdManager 作为自定义 sessionId 读取和保存的逻辑扩展点,下面是一个 QWWebSessionManager.SessionIdManager 接口的示例实现,如下:
package com.qiwen.sms.config;
import cn.hutool.core.util.StrUtil;
import com.qiwen.base.config.shiro.QWWebSessionManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.web.servlet.Cookie;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSessionAttributeListener;
import javax.servlet.http.HttpSessionBindingEvent;
import java.io.Serializable;
@Slf4j
@Configuration
public class SmsBeanConfig {
/**
* 跨域配置
*/
@Bean
public WebMvcConfigurer crosConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/sms/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS")
// 跨域预检测请求头, 默认允许所有请求头, 如果有特殊需求自定义配置
// .allowedHeaders("*")
// 允许的响应头,这里默认需要添加 set-custom-token 响应头,
// 以保证响应的 sessionId 不浏览器拦截
.exposedHeaders("set-custom-token")
.allowCredentials(true)
.maxAge(3600 * 30);
}
};
}
/**
* 实现一个简单的 SessionIdManager
*/
@Bean
public QWWebSessionManager.SessionIdManager sessionIdManager() {
return new QWWebSessionManager.SessionIdManager() {
@Override
public Serializable getSessionId(QWWebSessionManager sessionManager, ServletRequest request, ServletResponse response) {
// getReferencedSessionId 表示使用原来的 sessionId 读取逻辑,如果不需要,可以删除以下的相关逻辑
Serializable sessionId = sessionManager.getReferencedSessionId(request, response);
if(sessionId != null) {
return sessionId;
}
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
String token = httpServletRequest.getHeader("custom-token");
if(StrUtil.isEmpty(token)) {
return token;
}
// 校验 token 是否有效,避免上传无效 token 导致 session 校验抛出异常
if(sessionManager.isValid(new DefaultSessionKey(token))) {
return token;
}
return null;
}
@Override
public void storeSessionId(Serializable currentId, Cookie realCookie, HttpServletRequest request, HttpServletResponse response) {
// 调用此方法添加 Set-Cookie 响应头,如果前端访问后端接口都是跨域请求,
// 则可以注释下面的代码
realCookie.saveTo(request, response);
// currentId 则代表新创建的 sessionId,和 realCookie.getValue() 值一致。
// 下面简单的为 response 设置 set-custom-token, 值为 currentId
response.addHeader("set-custom-token", currentId.toString());
}
};
}
}
Spring Boot + Shiro 配置网上有许多教程,大同小异,这里不做赘述,只要将使用 QWWebSessionManger 替换 DefaultWebSessionManger 即可。
以上配置可以实现使用 custom-token/set-custom-token 替换 cookie/set-cookie 的简单功能,上述配置完成之后,项目中其他地方使用 Session 的逻辑不需要做任何更改,也不用使用拦截器或者在自己的业务代码里面主动返回 sessionId 给前端。
下面提供了一个简单的示例:
后端代码
// 每次将 session 里面的 count 增加 1
@ResponseBody
@GetMapping("/sms/index2")
public String index2(HttpSession session) {
Object count = session.getAttribute("count");
if(count == null) {
session.setAttribute("count", 1);
} else {
int intCount = (int) count;
session.setAttribute("count", ++intCount);
}
return Result.ok().putData("count", session.getAttribute("count")).json();
}
前端全局处理 token 逻辑,使用了 jQuery 实现,如下:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<title>跨域测试</title>
<script type="text/javascript" src="https://libs.cdnjs.net/vue/2.6.10/vue.js"></script>
<script type="text/javascript" src="https://cdn.bootcss.com/jquery/2.1.1/jquery.js"></script>
<style type="text/css">
* {margin:0;padding:0;list-style:none}
</style>
</head>
<body>
<div id="app">
<div style="text-align: center; padding-top: 20px;">
<h1 style="text-align: center;">{{ count }}</h1>
<button @click="sendRequest">click Me</button>
</div>
</div>
<script type="text/javascript">
$.ajaxSetup({
// 用户没有发送 custom-token 头,就自动添加
beforeSend: function(xhr, options) {
if(!options.headers || !options.headers['custom-token']) {
xhr.setRequestHeader('custom-token', localStorage.getItem('token-info'));
}
},
// 请求完成之后发现响应头里面有 set-custom-token,就直接存在 localStorage 里面。
complete: function(xhr, status) {
token = xhr.getResponseHeader('set-custom-token');
if(!!token) {
localStorage.setItem('token-info', token);
}
}
});
new Vue({
el: '#app',
data: {
url: 'http://localhost:8443/sms/index2',
count: -1
},
methods: {
sendRequest () {
$.getJSON(this.url, (data) => {
this.count = data.body.count;
});
}
}
})
</script>
</body>
</html>
源码地址参考:ShiroConfig.java,具体文档参看:后端文档#跨域配置支持
另外一个跨域的问题是图片验证码的问题,不跨域的情况下直接访问验证码地址浏览器能够自动附带 cookie 信息,但是跨域情况下此种方法会失效,解决办法使直接使用 xhr 访问图片地址,将后端返回的 blob 转换为 base64 data-url 的形式展示即可,或者是后端直接转换为 base64 返回给前端(适用于 jQuery,因为 jQuery 不支持读取 blob 类型)。
三、总结
网上有许多 Shiro 跨域 session 解决方案,但是 sessionId 保存至客户端这个步骤几乎都是下面两个方法:
- 使用拦截器,将 sessionId 读取出来返回给客户端
- 在业务代码中将 sessionId 读取出来返回给客户端
笔者觉得比较优雅的方式还是直接简单的扩展 Shiro DefaultWebSessionManger 读取和保存 sessionId,只要配置一处就能解决 session 跨域失效的问题。
并且在获取 sessionId 的时候几乎都没有做 sessionId 有效性校验,但是这样潜在的风险就是客户端上传了一个失效的 seesionId,服务器读取 sessionId 之后报错。
此方案解决的 session 跨域问题,不是 session 跨域共享问题,Session 跨域共享可以使用单点登录方式解决。