前言
在上一篇文章{% post_link Shiro是如何拦截未登录请求的(一) %}中提到了,我们在实际的项目中采用了基于token的方式来实现用户的身份鉴权,但是由于开发的时候对shiro的内部机制不太了解导致那一块的代码实现不够完善、整洁并且还对业务造成了影响,经过了对shiro源码的跟踪分析之后,我们已经知道shiro是如何拦截未登录请求的了,那么接下来我们开始来针对问题制定相应的解决方案.
解决方案
第一种方案
由于最初在app端是使用传输cookie的方式来实现身份鉴权的,跨域问题也已经解决了,为了尽量不改动已经写好的代码,我们可以想办法来让h5应用也能在跨域的情况下传输cookie,首先服务端在使用cors协议时需要设置响应消息头Access-Control-Allow-Credentials的值为true即允许在ajax访问时携带cookie,客户端方面也需通过js设置withCredentials为true才能真正实现跨域传输cookie.另外为了安全,在cors标准里不允许Access-Control-Allow-Origin设置为*,而是必须指定明确的、与请求网页一致的域名.cookie也依然遵循“同源策略”,只有用目标服务器域名设置的cookie才会上传,而且使用document.cookie也无法读取目标服务器域名下的cookie.接下来我们来看看代码是怎么实现的:
1.我们原先在springboot中关于支持跨域有多种实现方式,我们采用最后的一种:
@Bean
public FilterRegistrationBean corsFilter() {
return new FilterRegistrationBean(new Filter() {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String method = request.getMethod();
String origin = request.getHeader("Origin");
if(origin == null) {
origin = request.getHeader("Referer");
}
// this origin value could just as easily have come from a database
response.setHeader("Access-Control-Allow-Origin", origin); // 允许指定域访问跨域资源
//response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token");
if ("OPTIONS".equals(method)) {
response.setStatus(HttpStatus.OK.value());
} else {
chain.doFilter(req, res);
}
}
public void init(FilterConfig filterConfig) {
}
public void destroy() {
}
});
}
2.客户端也不再需要在请求头中带上token了,只要登录之后不管调什么接口都会自动带上cookie到后端校验的,代码如下:
$.ajax({
url:'http://localhost:8080/win/api/test/cors',
type:'post',
beforeSend:(xhr)=> {
//xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
//xhr.setRequestHeader("token", "web_session_key-5ce2ae9c-8f79-4f83-9b47-1510da4b2fb0");
xhr.setRequestHeader("device","APP");
},
xhrFields:{
withCredentials:true,
useDefaultXhrHeader:false
},
corssDomain:true,
success:function(data){
console.log(data);
}
});
这样接口就可以正常返回数据了,控制台也不再报错(注意request header中的cookie).
第二种方案
从上一篇文章中我们知道shiro是在其默认的会话管理器DefaultWebSessionManager中获取请求携带过来的cookie的,我们可以通过继承这个类来扩展其中相关的代码来实现我们的需求,之前在项目中我们已经扩展过这个类了,当时是为了重写其中定时验证session有效性的部分以便在session失效时做一些数据清理工作,下面贴出的是shiro从cookie中获取sessionid的主要源代码:
@Override
public Serializable getSessionId(SessionKey key) {
Serializable id = super.getSessionId(key);
if (id == null && WebUtils.isWeb(key)) {
ServletRequest request = WebUtils.getRequest(key);
ServletResponse response = WebUtils.getResponse(key);
id = getSessionId(request, response);
}
return id;
}
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
return getReferencedSessionId(request, response);
}
private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
String id = getSessionIdCookieValue(request, response);
.......
return id;
}
private String getSessionIdCookieValue(ServletRequest request, ServletResponse response) {
.......
//getSessionIdCookie().readValue()操作的是cookie对象.
return getSessionIdCookie().readValue(httpRequest, WebUtils.toHttp(response));
}
那么我们只要在扩展类中覆写这些方法,通过在请求头传输过来的device标识便可以区分出不同的调用端来源,即pc端后台依然采用shiro原有的认证方式,而app端或者h5应用则可以使用基于token的身份认证方式,达到两者共存的目的.下面来看看我们自定义的CustomerWebSessionManager类,其继承了shiro的DefaultWebSessionManager类.
public class CustomerWebSessionManager extends DefaultWebSessionManager {
private static final Logger logger = LoggerFactory.getLogger(CustomerWebSessionManager.class);
private static final String AUTH_TOKEN = "token";
public CustomerWebSessionManager() {
super();
}
@Override
public void validateSessions() {
if (logger.isInfoEnabled()) {
logger.info("Validating all active sessions...");
}
......
}
其中定义的类静态变量AUTH_TOKEN为请求头中需要携带的会话id的名称,validateSessions方法是我们重写的用来实现当session失效时做数据清理的.由于DefaultWebSessionManager中的大部分方法为私有的方法,无法为其子类所继承,所以只好重写其中所有用到的protected方法,代码如下:
/**
* 重写父类获取sessionID的方法,若请求为APP或者H5则从请求头中取出token,若为PC端后台则从cookie中获取
*
* @param request
* @param response
* @return
*/
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
if (!(request instanceof HttpServletRequest)) {
logger.debug("Current request is not an HttpServletRequest - cannot get session ID. Returning null.");
return null;
}
HttpServletRequest httpRequest = WebUtils.toHttp(request);
if (StringHelpUtils.isNotBlank(httpRequest.getHeader("device"))
&& (httpRequest.getHeader("device").equals("APP") || httpRequest
.getHeader("device").equals("H5"))) {
//从header中获取token
String token = httpRequest.getHeader(AUTH_TOKEN);
// 每次读取之后都把当前的token放入response中
HttpServletResponse httpResponse = WebUtils.toHttp(response);
if (StringHelpUtils.isNotEmpty(token)) {
httpResponse.setHeader(AUTH_TOKEN, token);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "header");
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
}
//sessionIdUrlRewritingEnabled的配置为false,不会在url的后面带上sessionID
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, isSessionIdUrlRewritingEnabled());
return token;
}
return getReferencedSessionId(request, response);
}
/**
* shiro默认从cookie中获取sessionId
*
* @param request
* @param response
* @return
*/
private Serializable getReferencedSessionId(ServletRequest request, ServletResponse response) {
String id = getSessionIdCookieValue(request, response);
if (id != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.COOKIE_SESSION_ID_SOURCE);
} else {
//not in a cookie, or cookie is disabled - try the request URI as a fallback (i.e. due to URL rewriting):
//try the URI path segment parameters first:
id = getUriPathSegmentParamValue(request, ShiroHttpSession.DEFAULT_SESSION_ID_NAME);
if (id == null) {
//not a URI path segment parameter, try the query parameters:
String name = getSessionIdName();
id = request.getParameter(name);
if (id == null) {
//try lowercase:
id = request.getParameter(name.toLowerCase());
}
}
if (id != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.URL_SESSION_ID_SOURCE);
}
}
if (id != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
//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());
return id;
}
//copy from DefaultWebSessionManager
private String getSessionIdCookieValue(ServletRequest request, ServletResponse response) {
if (!isSessionIdCookieEnabled()) {
logger.debug("Session ID cookie is disabled - session id will not be acquired from a request cookie.");
return null;
}
if (!(request instanceof HttpServletRequest)) {
logger.debug("Current request is not an HttpServletRequest - cannot get session ID cookie. Returning null.");
return null;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
return getSessionIdCookie().readValue(httpRequest, WebUtils.toHttp(response));
}
//since 1.2.2 copy from DefaultWebSessionManager
private String getUriPathSegmentParamValue(ServletRequest servletRequest, String paramName) {
if (!(servletRequest instanceof HttpServletRequest)) {
return null;
}
HttpServletRequest request = (HttpServletRequest) servletRequest;
String uri = request.getRequestURI();
if (uri == null) {
return null;
}
int queryStartIndex = uri.indexOf('?');
if (queryStartIndex >= 0) { //get rid of the query string
uri = uri.substring(0, queryStartIndex);
}
int index = uri.indexOf(';'); //now check for path segment parameters:
if (index < 0) {
//no path segment params - return:
return null;
}
//there are path segment params, let's get the last one that may exist:
final String TOKEN = paramName + "=";
uri = uri.substring(index + 1); //uri now contains only the path segment params
//we only care about the last JSESSIONID param:
index = uri.lastIndexOf(TOKEN);
if (index < 0) {
//no segment param:
return null;
}
uri = uri.substring(index + TOKEN.length());
index = uri.indexOf(';'); //strip off any remaining segment params:
if (index >= 0) {
uri = uri.substring(0, index);
}
return uri; //what remains is the value
}
//since 1.2.1 copy from DefaultWebSessionManager
private String getSessionIdName() {
String name = this.getSessionIdCookie() != null ? this.getSessionIdCookie().getName() : null;
if (name == null) {
name = ShiroHttpSession.DEFAULT_SESSION_ID_NAME;
}
return name;
}
当shiro取不到sessionid时,会调用DelegatingSubject类中的getSession(true)方法创建一个新的session.
public Session getSession(boolean create) {
if (log.isTraceEnabled()) {
log.trace("attempting to get session; create = " + create +
"; session is null = " + (this.session == null) +
"; session has id = " + (this.session != null && session.getId() != null));
}
if (this.session == null && create) {
//added in 1.2:
if (!isSessionCreationEnabled()) {
String msg = "Session creation has been disabled for the current subject. This exception indicates " +
"that there is either a programming error (using a session when it should never be " +
"used) or that Shiro's configuration needs to be adjusted to allow Sessions to be created " +
"for the current Subject. See the " + DisabledSessionException.class.getName() + " JavaDoc " +
"for more.";
throw new DisabledSessionException(msg);
}
log.trace("Starting session for host {}", getHost());
SessionContext sessionContext = createSessionContext();
Session session = this.securityManager.start(sessionContext);
this.session = decorate(session);
}
return this.session;
}
上面的第22行this.securityManager.start最终调用的是DefaultWebSessionManager中的onStart方法,所以我们要重写这个方法,将产生的sessionid放到response header中.另外当session失效或销毁时的相关方法也需重新实现,具体代码如下:
//存储会话id到response header中
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);
}
String idString = currentId.toString();
if (StringHelpUtils.isNotBlank(request.getHeader("device"))
&& (request.getHeader("device").equals("APP") || request
.getHeader("device").equals("H5"))) {
response.setHeader(AUTH_TOKEN, idString);
} else {
Cookie template = getSessionIdCookie();
Cookie cookie = new SimpleCookie(template);
cookie.setValue(idString);
cookie.saveTo(request, response);
}
logger.trace("Set session ID cookie for session with id {}", idString);
}
//设置deleteMe到response header中
private void removeSessionIdCookie(HttpServletRequest request, HttpServletResponse response) {
if (StringHelpUtils.isNotBlank(request.getHeader("device"))
&& (request.getHeader("device").equals("APP") || request
.getHeader("device").equals("H5"))) {
response.setHeader(AUTH_TOKEN, Cookie.DELETED_COOKIE_VALUE);
} else {
getSessionIdCookie().removeFrom(request, response);
}
}
/**
* 会话创建
* Stores the Session's ID, usually as a Cookie, to associate with future requests.
*
* @param session the session that was just {@link #createSession created}.
*/
@Override
protected void onStart(Session session, SessionContext context) {
super.onStart(session, context);
if (!WebUtils.isHttp(context)) {
logger.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 {
logger.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);
}
//会话失效
@Override
protected void onExpiration(Session s, ExpiredSessionException ese, SessionKey key) {
super.onExpiration(s, ese, key);
onInvalidation(key);
}
@Override
protected void onInvalidation(Session session, InvalidSessionException ise, SessionKey key) {
super.onInvalidation(session, ise, key);
onInvalidation(key);
}
private void onInvalidation(SessionKey key) {
ServletRequest request = WebUtils.getRequest(key);
if (request != null) {
request.removeAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID);
}
if (WebUtils.isHttp(key)) {
logger.debug("Referenced session was invalid. Removing session ID cookie.");
removeSessionIdCookie(WebUtils.getHttpRequest(key), WebUtils.getHttpResponse(key));
} else {
logger.debug("SessionKey argument is not HTTP compatible or does not have an HTTP request/response " +
"pair. Session ID cookie will not be removed due to invalidated session.");
}
}
//会话销毁
@Override
protected void onStop(Session session, SessionKey key) {
super.onStop(session, key);
if (WebUtils.isHttp(key)) {
HttpServletRequest request = WebUtils.getHttpRequest(key);
HttpServletResponse response = WebUtils.getHttpResponse(key);
logger.debug("Session has been stopped (subject logout or explicit stop). Removing session ID cookie.");
removeSessionIdCookie(request, response);
} else {
logger.debug("SessionKey argument is not HTTP compatible or does not have an HTTP request/response " +
"pair. Session ID cookie will not be removed due to stopped session.");
}
}
最后再在springboot中做如下配置:
@Bean
public CustomerWebSessionManager sessionManager() {
CustomerWebSessionManager sessionManager = new CustomerWebSessionManager();
//会话验证器调度时间
sessionManager.setSessionValidationInterval(1800000);
//定时检查失效的session
sessionManager.setSessionValidationSchedulerEnabled(true);
//是否在会话过期后会调用SessionDAO的delete方法删除会话 默认true
sessionManager.setDeleteInvalidSessions(true);
sessionManager.setSessionDAO(redisSessionDAO());
sessionManager.setSessionIdUrlRewritingEnabled(false);
sessionManager.setSessionIdCookie(wapsession());
sessionManager.setSessionIdCookieEnabled(true);
return sessionManager;
}
我们来看看在postman中的接口访问测试结果:
而在h5应用中已无需再支持跨域传输cookie了,但需重新在请求头中传输token,js代码稍微做如下修改:
$.ajax({
url:'http://localhost:8080/win/api/test/cors',
type:'post',
beforeSend:(xhr)=> {
//xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhr.setRequestHeader("token", "web_session_key-26653f18-1d81-4bd3-a039-301870788abb");
xhr.setRequestHeader("device","APP");
},
xhrFields:{
//withCredentials:true,
//useDefaultXhrHeader:false
},
//corssDomain:true,
success:function(data){
console.log(data);
}
});
通过浏览器可以看到已经成功访问接口了,控制台也没有报错,结果如下图(注意其中request header和response header中的token)
好了,到此我们已经完成了对shiro支持token身份认证的全部改造了.