Shiro是如何拦截未登录请求的(二)

前言

在上一篇文章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中关于支持跨域有多种实现方式,我们采用最后的一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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到后端校验的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$.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).
image.png
第二种方案
从上一篇文章中我们知道shiro是在其默认的会话管理器DefaultWebSessionManager中获取请求携带过来的cookie的,我们可以通过继承这个类来扩展其中相关的代码来实现我们的需求,之前在项目中我们已经扩展过这个类了,当时是为了重写其中定时验证session有效性的部分以便在session失效时做一些数据清理工作,下面贴出的是shiro从cookie中获取sessionid的主要源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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类.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
* 重写父类获取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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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失效或销毁时的相关方法也需重新实现,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//存储会话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中做如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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中的接口访问测试结果:
image.png
而在h5应用中已无需再支持跨域传输cookie了,但需重新在请求头中传输token,js代码稍微做如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$.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)
image.png
好了,到此我们已经完成了对shiro支持token身份认证的全部改造了.
`