前後分離,使用自定義token作為shiro認證標識,實現springboot整合shiro
直接進入主題,專案是使用springboot,框架用的shiro做許可權,mybatis做orm框架,專案需要做前後分離,這樣就會導致一個問題,shiro是根據sessionID來識別是不是同一個request,但如果前後分離的話,就會出現跨域的問題,session很可能就會發生變化,這樣就需要用一個標記來表明是同一個請求。初步的方案就是用token來代替session,但本質上說,現在的這種方式,還是用的session的那一套,不過是對中間進行了處理,下面上程式碼:
我們要先解決的是跨域的問題:
springboot解決跨域很好解決,如下即可。
package com.common.config.cors; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; /** * @author :LX * 建立時間: 2019/5/30. 14:03 * 地點:廣州 * 目的: 跨域訪問控制 * 做前後分離的話,這個也是必配的 * 備註說明: */ @Configuration public class CorsConfig { private CorsConfiguration buildConfig() { CorsConfiguration corsConfiguration = new CorsConfiguration(); // 允許任何域名使用 corsConfiguration.addAllowedOrigin("*"); // 允許任何頭 corsConfiguration.addAllowedHeader("*"); // 允許任何方法(post、get等) corsConfiguration.addAllowedMethod("*"); return corsConfiguration; } @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // 對介面配置跨域設定 source.registerCorsConfiguration("/**", buildConfig()); return new CorsFilter(source); } }
然後自定義 realm ,簡單點說,就是你實現查詢使用者角色和許可權的類。這一步就省略了,不外乎查詢資料庫,查詢當前使用者的角色和許可權。
接著自定義token,簡單的說,這裡其實就是讓前端請求的時候在請求頭中帶一個特定的標識,然後根據這個標識找到vlues,匹配上我們的sessionId。
package com.common.config.shiro; import org.apache.shiro.web.servlet.ShiroHttpServletRequest; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.util.WebUtils; import org.springframework.util.StringUtils; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import java.io.Serializable; /** * @author :LX * 建立時間: 2019/5/30. 18:08 * 地點:廣州 * 目的: shiro 的 session 管理 * 自定義session規則,實現前後分離,在跨域等情況下使用token 方式進行登入驗證才需要,否則沒必須使用本類。 * shiro預設使用 ServletContainerSessionManager 來做 session 管理,它是依賴於瀏覽器的 cookie 來維護 session 的,呼叫 storeSessionId 方法儲存sesionId 到 cookie中 * 為了支援無狀態會話,我們就需要繼承 DefaultWebSessionManager * 自定義生成sessionId 則要實現 SessionIdGenerator * 備註說明: */ public class ShiroSession extends DefaultWebSessionManager { /** * 定義的請求頭中使用的標記key,用來傳遞 token */ private static final String AUTH_TOKEN = "authToken"; private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request"; public ShiroSession() { super(); //設定 shiro session 失效時間,預設為30分鐘,這裡現在設定為15分鐘 //setGlobalSessionTimeout(MILLIS_PER_MINUTE * 15); } /** * 獲取sessionId,原本是根據sessionKey來獲取一個sessionId * 重寫的部分多了一個把獲取到的token設定到request的部分。這是因為app呼叫登陸介面的時候,是沒有token的,登陸成功後,產生了token,我們把它放到request中,返回結 * 果給客戶端的時候,把它從request中取出來,並且傳遞給客戶端,客戶端每次帶著這個token過來,就相當於是瀏覽器的cookie的作用,也就能維護會話了 * @param request * @param response * @return */ @Override protected Serializable getSessionId(ServletRequest request, ServletResponse response) { //獲取請求頭中的 AUTH_TOKEN 的值,如果請求頭中有 AUTH_TOKEN 則其值為sessionId。shiro就是通過sessionId 來控制的 String sessionId = WebUtils.toHttp(request).getHeader(AUTH_TOKEN); if (StringUtils.isEmpty(sessionId)){ //如果沒有攜帶id引數則按照父類的方式在cookie進行獲取sessionId return super.getSessionId(request, response); } else { //請求頭中如果有 authToken, 則其值為sessionId request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE); //sessionId request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE); return sessionId; } } }
大家看上面的程式碼,其實就是我們的請求頭中獲取 authToken 的值,然後塞入到sessionId中,代替了session.
如果大家還需要自定義這個token,或者說自定義生成的seesionId,就需要看下面的這個方法。
根據我的研究,最終找到 JavaUuidSessionIdGenerator 這個類,然後可以找到
public Serializable generateId(Session session) {
return UUID.randomUUID().toString();
}
上面的程式碼,其實就是生成了一串UUID,我們可以實現SessionIdGenerator介面來完成自定義的sessionID生成
public class UuidSessionIdGenerator implements SessionIdGenerator{
@Override
public Serializable generateId(Session session) {
Serializable uuid = new JavaUuidSessionIdGenerator().generateId(session);
GGLogger.info("生成的sessionid是:"+uuid);
return uuid;
}
}
###自定義生成sessionid
sessionIdGenerator=ggauth.shiro.user.common.UuidSessionIdGenerator
securityManager.sessionManager.sessionDAO.sessionIdGenerator=$sessionIdGenerator
上面自定義生成的程式碼是 參考的 https://blog.csdn.net/yaomingyang/article/details/78142763 的程式碼,未經過驗證,但應該是沒問題的。我這裡是沒有去修改生成UUID的邏輯。
其實配置了這2個東西之後,就可以弄shiro最終的配置了。
package com.common.config.shiro;
import com.yunji.kwxt.common.Constant;
import com.yunji.kwxt.common.filter.CORSAuthenticationFilter;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @author :LX
* 建立時間: 2019/5/27. 11:39
* 地點:廣州
* 目的: shiro配置
* 備註說明:
*/
@Configuration
public class ShiroConfig {
private static Logger log = LoggerFactory.getLogger(ShiroConfig.class);
/**
* 對shiro的攔截器進行注入
* <p>
* securityManager:
* 所有Subject 例項都必須繫結到一個SecurityManager上,SecurityManager 是 Shiro的核心,初始化時協調各個模組執行。然而,一旦 SecurityManager協調完畢,
* SecurityManager 會被單獨留下,且我們只需要去操作Subject即可,無需操作SecurityManager 。 但是我們得知道,當我們正與一個 Subject 進行互動時,實質上是
* SecurityManager在處理 Subject 安全操作
*
* @param securityManager
* @return
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(org.apache.shiro.mgt.SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
//設定遇到未登入、未授權等情況時候,請求這些地址,返回相應的錯誤
shiroFilter.setLoginUrl("/user/shiroError?errorId=" + Constant.NEED_LOGIN);
shiroFilter.setUnauthorizedUrl("/user/shiroError?errorId=" + Constant.NO_UNAUTHORIZED);
//攔截器,配置訪問許可權 必須是LinkedHashMap,因為它必須保證有序。濾鏈定義,從上向下順序執行,一般將 /**放在最為下邊
Map<String, String> filterMap = new LinkedHashMap<String, String>();
// 配置不會被攔截的連結 順序判斷
filterMap.put("/user/login", "anon");
filterMap.put("/user/shiroError", "anon");
filterMap.put("/user/reg", "anon");
//剩餘的請求shiro都攔截
filterMap.put("/**/*", "authc");
shiroFilter.setFilterChainDefinitionMap(filterMap);
//自定義攔截器
Map<String, Filter> customFilterMap = new LinkedHashMap<>();
customFilterMap.put("corsAuthenticationFilter", new CORSAuthenticationFilter());
shiroFilter.setFilters(customFilterMap);
return shiroFilter;
}
/**
* securityManager 核心配置
* 安全控制層
* @return
*/
@Bean
public org.apache.shiro.mgt.SecurityManager securityManager(){
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
//設定自定義的realm
defaultWebSecurityManager.setRealm(myRealm());
//自定義的shiro session 快取管理器
defaultWebSecurityManager.setSessionManager(sessionManager());
//將快取物件注入到SecurityManager中
defaultWebSecurityManager.setCacheManager(ehCacheManager());
return defaultWebSecurityManager;
}
/**
* 自定義的realm
* @return
*/
@Bean
public MyRealm myRealm() {
return new MyRealm();
}
/**
* 開啟shiro 的AOP註解支援
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* shiro快取管理器
* 1 新增相關的maven支援
* 2 註冊這個bean,將快取的配置檔案匯入
* 3 在securityManager 中註冊快取管理器,之後就不會每次都會去查詢資料庫了,相關的許可權和角色會儲存在快取中,但需要注意一點,更新了許可權等操作之後,需要及時的清理快取
*/
@Bean
public EhCacheManager ehCacheManager() {
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:config/ehcache.xml");
return cacheManager;
}
/**
* 自定義的 shiro session 快取管理器,用於跨域等情況下使用 token 進行驗證,不依賴於sessionId
* @return
*/
@Bean
public SessionManager sessionManager(){
//將我們繼承後重寫的shiro session 註冊
ShiroSession shiroSession = new ShiroSession();
//如果後續考慮多tomcat部署應用,可以使用shiro-redis開源外掛來做session 的控制,或者nginx 的負載均衡
shiroSession.setSessionDAO(new EnterpriseCacheSessionDAO());
return shiroSession;
}
}
完成了上面的流程,基本就已經大功告成了,對了,還要加上下面的程式碼。
package com.common.filter;
import com.alibaba.fastjson.JSONObject;
import com.yunji.kwxt.common.Constant;
import com.yunji.kwxt.common.enums.ResultEnum;
import com.yunji.kwxt.common.model.ResultJson;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
/**
* @author :LX
* 建立時間: 2019/5/31. 10:25
* 地點:廣州
* 目的: 過濾OPTIONS請求
* 繼承shiro 的form表單過濾器,對 OPTIONS 請求進行過濾。
* 前後端分離專案中,由於跨域,會導致複雜請求,即會發送preflighted request,這樣會導致在GET/POST等請求之前會先發一個OPTIONS請求,但OPTIONS請求並不帶shiro
* 的'authToken'欄位(shiro的SessionId),即OPTIONS請求不能通過shiro驗證,會返回未認證的資訊。
*
* 備註說明: 需要在 shiroConfig 進行註冊
*/
public class CORSAuthenticationFilter extends FormAuthenticationFilter {
/**
* 直接過濾可以訪問的請求型別
*/
private static final String REQUET_TYPE = "OPTIONS";
public CORSAuthenticationFilter() {
super();
}
@Override
public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (((HttpServletRequest) request).getMethod().toUpperCase().equals(REQUET_TYPE)) {
return true;
}
return super.isAccessAllowed(request, response, mappedValue);
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletResponse res = (HttpServletResponse)response;
res.setHeader("Access-Control-Allow-Origin", "*");
res.setStatus(HttpServletResponse.SC_OK);
res.setCharacterEncoding("UTF-8");
PrintWriter writer = res.getWriter();
ResultJson resultJson = new ResultJson(Constant.ERROR_CODE_NO_LOGIN, ResultEnum.ERROR.getStatus(), "請先登入系統!", null);
writer.write(JSONObject.toJSONString(resultJson));
writer.close();
return false;
}
}
為什麼要過濾,上面的註釋說的很清楚了,建議大家還是加上,這個類最終在shiro的攔截器那裡配置了。
當然還有登入那裡要說一下,很多的新手不然就搞不懂了。
/**
* 使用者登入
* @param username 使用者名稱
* @param password 使用者密碼
* @return
*/
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public ResultJson login(String username, String password, HttpServletRequest request){
//TODO 驗證碼驗證
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
User user = userService.login(username, password);
SecurityUtils.getSubject().login(token);
//更新登入資訊
user.setIp(HttpTool.getIpAddr(request));
user.setOs(HttpTool.getOs(request));
user.setUpdateUserId(user.getId());
user.setUpdateTime(CommonTool.getTimestamp());
//設定session時間
//SecurityUtils.getSubject().getSession().setTimeout(1000*60*30);
//token資訊
Subject subject = SecurityUtils.getSubject();
Serializable tokenId = subject.getSession().getId();
return new ResultJson(null, ResultEnum.SUCCESS.getStatus(), "登入認證成功", tokenId);
}
我們最終從shiro的session中取到了sessionId,回傳給前端,前端後續的請求都要帶這個token。
這樣就實現了token方式的shiro整合springboot。
如果為了安全,還可以建議大家,獲取到sessionId 之後,我們進行一次加密,然後返回給前端,前端返回給我們的時候,我們可以在shirosession 類中對加密的sessionId解密,這樣就更安全了。
最後還有一個問題需要說明一下,上述的程式碼中shiro使用了快取,但我的快取相關的配置卻沒有貼出來,因為我這裡用的是java的快取框架,建議使用redis的快取框架,如果使用了快取框架,細心的小夥伴就會發現,如果登入後,在一定時間沒有和後臺進行互動,這個sessionId就會失效。
這是因為,當我們登入後如果走了快取,session的存活時間就被快取管理起來,我們即使設定了shiro的快取時間,設定應用的快取時間都無法管理到第三方的快取,shiro的sesssion和server的session不是同一個東西。他並不是servlet來管理的,故而設定了也沒有作用,需要去設定快取中這個物件存活時間才有用,比如我們弄了redis來管理sessionId,只有設定了在redis中session的存活時間才行,我們直接設定
SecurityUtils.getSubject().getSession().setTimeout(1000*60*30);
#session過期時間,單位秒 server.servlet.session.timeout=30000
都沒有任何用。比如我上面用ehcache來管理快取,那只有在該快取框架中設定這個引數才有用
我這裡設定了120S,那如果120S沒有任何互動,那這個快取sessionId就會