前言
在上一篇文章{% post_link Shiro是如何攔截未登錄請求的(一) %}中提到了,我們在實際的項目中采用了基于token的方式來實現(xiàn)用戶的身份鑒權,但是由于開發(fā)的時候對shiro的內部機制不太了解導致那一塊的代碼實現(xiàn)不夠完善舍肠、整潔并且還對業(yè)務造成了影響,經過了對shiro源碼的跟蹤分析之后,我們已經知道shiro是如何攔截未登錄請求的了,那么接下來我們開始來針對問題制定相應的解決方案.
解決方案
第一種方案
由于最初在app端是使用傳輸cookie的方式來實現(xiàn)身份鑒權的,跨域問題也已經解決了,為了盡量不改動已經寫好的代碼,我們可以想辦法來讓h5應用也能在跨域的情況下傳輸cookie,首先服務端在使用cors協(xié)議時需要設置響應消息頭Access-Control-Allow-Credentials的值為true即允許在ajax訪問時攜帶cookie,客戶端方面也需通過js設置withCredentials為true才能真正實現(xiàn)跨域傳輸cookie.另外為了安全,在cors標準里不允許Access-Control-Allow-Origin設置為*,而是必須指定明確的、與請求網(wǎng)頁一致的域名.cookie也依然遵循“同源策略”,只有用目標服務器域名設置的cookie才會上傳,而且使用document.cookie也無法讀取目標服務器域名下的cookie.接下來我們來看看代碼是怎么實現(xiàn)的:
1.我們原先在springboot中關于支持跨域有多種實現(xiàn)方式,我們采用最后的一種:
@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);
}
});
這樣接口就可以正常返回數(shù)據(jù)了,控制臺也不再報錯(注意request header中的cookie).
第二種方案
從上一篇文章中我們知道shiro是在其默認的會話管理器DefaultWebSessionManager中獲取請求攜帶過來的cookie的,我們可以通過繼承這個類來擴展其中相關的代碼來實現(xiàn)我們的需求,之前在項目中我們已經擴展過這個類了,當時是為了重寫其中定時驗證session有效性的部分以便在session失效時做一些數(shù)據(jù)清理工作,下面貼出的是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標識便可以區(qū)分出不同的調用端來源,即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...");
}
......
}
其中定義的類靜態(tài)變量AUTH_TOKEN為請求頭中需要攜帶的會話id的名稱,validateSessions方法是我們重寫的用來實現(xiàn)當session失效時做數(shù)據(jù)清理的.由于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)方法創(chuàng)建一個新的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失效或銷毀時的相關方法也需重新實現(xiàn),具體代碼如下:
//存儲會話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);
}
}
/**
* 會話創(chuàng)建
* 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身份認證的全部改造了.