1、什麼是Shiro
Shiro是Java領域非常知名的認證( Authentication )與授權 ( Authorization )框架,用以替代JavaEE中的JAAS功能。相較於其他認證與授權框架,Shiro設計的非常簡單,所以廣受好評。任意JavaWeb項目都可以使用Shiro框架,而Spring Security 必須要使用在Spring項目中。所以Shiro的適用性更加廣泛。像什 麼 JFinal 和 Nutz 非Spring框架都可以使用Shiro,而不能使用Spring Security框架。
- 兩大功能: 認證 與 授權
- 適用於JavaWeb項目
- 不是Spring框架下的產品
1.1 什麼是認證和授權
- 認證:認證就是要核驗用户的身份,比如説通過用户名和密碼來檢驗用户的身份。説簡單一些,認證就
是登陸。登陸之後Shiro要記錄用户成功登陸的憑證。
- 授權:授權是比認證更加精細度的劃分用户的行為。比如説在我們開發的系統中,對於數據庫普通的開發人員可能只有讀寫權限,而數據庫管理員可以有讀寫刪等更高級的權限。這就是利用授權來限定不同身份用户 的行為。
1.2 shrio怎麼上述兩個功能?
Shiro可以利用 HttpSession 或者 Redis 存儲用户的登陸憑證,以及角色或者身份信息。然後利用過濾器(Filter),對每個Http請求過濾,檢查請求對應的HttpSession 或者 Redis 中的認證與授權信息。如果用户沒有登陸,或者權限不夠,那麼Shiro會向客户端返回錯誤信息。 也就是説,我們寫用户登陸模塊的時候,用户登陸成功之後,要調用Shiro保存登陸憑證。然後查詢用户的角色和權限,讓Shiro存儲起來。將來不管哪個方法需要登陸訪問,或者擁有特定的角色 跟權限才能訪問,我們在方法前設置註解即可,非常簡單。
2、什麼是JWT
JWT(Json Web Token), 是為了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標 準。JWT一般被用來在身份提供者和服務提供者間傳遞被認證的用户身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
- JWT可以用在單點登錄的系統中
如果用户的登陸憑證經過加密( Token )保存在客户端,客户端每次提交請求的時候,把 Token 上傳給後端服務器節點。即便後端項目使用了負載均衡,每個後端節點接收到客户端上傳 的Token之後,經過檢測,是有效的 Token ,於是就斷定用户已經成功登陸,接下來就可以提供後端服務了
- JWT兼容更多的客户端
傳統的 HttpSession 依靠瀏覽器的 Cookie 存放 SessionId ,所以要求客户端必須是瀏覽器。現在的JavaWeb系統,客户端可以是瀏覽器、APP、小程序,以及物聯網設備。為了讓這些設備都能訪問到JavaWeb項目,就必須要引入JWT技術。JWT的 Token 是純字符串,至於客户端怎麼保存,沒有具體要求。只要客户端發起請求的時候,附帶上 Token 即可。所以像物聯網設備,我 們可以用 SQLite 存儲 Token 數據。
3、JWT的實戰
通過上面的介紹,我們已經瞭解了JWT的相關信息。説白了,JWT就是對我們的token進行加密,保存在客户端,每一個客户端的請求過來我們就對這個token進行校驗,來判斷用户的信息
3.1 創建JWT工具類
用於對Token進行加密,解密,生成...
- 導入依賴
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.13</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 為了維護的方便,我們可以給token在配置文件中定義好過期時間,秘鑰,緩存時間。後面通過屬性注入即可
- 創建JWT工具類
@Component
@Slf4j
public class JWTUtil {
@Value("${emos.jwt.secret}")
private String secret;
@Value("${emos.jwt.expire}")
private int expire;
/**
* 通過UserId,創建一個token
* @param userId
* @return
*/
public String createToken(int userId){
DateTime offset = DateUtil.offset(new Date(), DateField.DAY_OF_YEAR, expire);
Algorithm algorithm = Algorithm.HMAC256(secret);//使用算法加密secret密鑰
JWTCreator.Builder builder = JWT.create();
String token = builder.withClaim("userId", userId)
.withExpiresAt(offset)
.sign(algorithm);
return token;
}
/**
* 通過token獲取userId
* @param token
* @return
*/
public int getUserId(String token){
DecodedJWT decodedJWT = JWT.decode(token);
int userId = decodedJWT.getClaim("userId").asInt();
return userId;
}
/**
*驗證密鑰
* @param token
*/
public void verifierToken(String token){
Algorithm algorithm =Algorithm.HMAC256(secret);//對密鑰進行加密算法處理
JWTVerifier verifier = JWT.require(algorithm).build();//創建一個jwt的verifier對象
verifier.verify(token);//驗證密鑰和token(加密後的密鑰部分)是否一致---sign
}
}
4、對接JWT和Shiro框架
現在我們可以用過JWTUtil工具類生成token並對其進行一些操作,而token是不能直接交給Shiro的,按照Shiro框架,我們還需要對token進行封裝。
4.1 封裝token對象
public class OAuth2Token implements AuthenticationToken {
private String token;
/**
* 創建生成token對象的構造器
*
* @param token
*/
public OAuth2Token(String token){
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
4.2 創建Realm類
根據上面實現shiro框架的步驟,我們需要創建OAuth2Realm類,這個類需要繼承AuthorizingRealm
OAuth2Realm 類是 AuthorizingRealm 的實現類,我們要在這個實現類中定義認證和授權的方法。因為認證與授權模塊設計到用户模塊和權限模塊,現在我們還沒有真正的開發業務模塊,所 以我們這裏先暫時定義空的認證去授權方法,把Shiro和JWT整合起來,在後續章節我們再實現 認證與授權。
@Component
public class OAuth2Realm extends AuthorizingRealm {
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof OAuth2Token;
}
/**
* 授權(驗證權限時調用)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//TODO 查詢用户的權限列表
//TODO 把權限列表添加到info對象中
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
throws AuthenticationException {
//TODO 從令牌中獲取userId,然後檢測該賬户是否被凍結。
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo();
//TODO 往info對象中添加用户信息、Token字符串
return info;
}
}
4.3 如何設計token(令牌)的過期時間
我們在定義JwtUtil工具類的時候,生成的 Token 都有過期時間。那麼問題來了,假設 Token 過 期時間為15天,用户在第14天的時候,還可以免登錄正常訪問系統。但是到了第15天,用户的 Token過期,於是用户需要重新登錄系統。
HttpSession 的過期時間比較優雅,默認為15分鐘。如果用户連續使用系統,只要間隔時間不超 過15分鐘,系統就不會銷燬 HttpSession 對象。
JWT的令牌過期時間能不能做成 HttpSession 那樣超時時間,只要用户間隔操作時間不超過15天,系統就不需要用户重新登錄系統。實現這種 效果的方案有兩種: 雙Token 和 Token緩存 ,這裏重點講一下 Token 緩存方案。
簡單的來説,就是當我們第一次登錄的時候,此時我們有效的登錄時間是15天,如果我在第15天的時候登錄,那麼此時還不應該需要重新登錄,只有説在第15天過後,也就是我們15天內沒有操作,且再過15天之後過期,這就是為什麼我們緩存的時間是過期時間的1倍。
Token緩存方案是把 Token 緩存到Redis,然後設置Redis裏面緩存的 Token 過期時間為正常 Token 的1倍,然後根據情況刷新 Token 的過期時間。
Token失效,緩存也不存在的情況:當第15天,用户的 Token 失效以後,我們讓Shiro程序到Redis查看是否存在緩存的 Token ,如果這個 Token 不存在於Redis裏面,就説明用户的操作間隔了15天,需要重新登錄。
Token失效,但是緩存還存在的情況 如果Redis中存在緩存的 Token ,説明當前 Token 失效後,間隔時間還沒有超過15天,不應該讓用户重新登錄。所以要生成新的 Token 返回給客户端,並且把這個 Token 緩存到Redis裏面,這種操作稱為刷新 Token 過期時間。
我們定義 OAuth2Filter 類攔截所有的HTTP請求:
一方面它會把請求中的 Token 字符串提取出 來,封裝成對象交給Shiro框架;
另一方面,它會檢查 Token 的有效性。如果 Token 過期,那麼 會生成新的 Token ,分別存儲在 ThreadLocalToken 和 Redis 中。
之所以要把 新令牌 保存到 ThreadLocalToken 裏面,是因為要向 AOP切面類 傳遞這個 新令牌 。 雖然 OAuth2Filter 中有 doFilterInternal() 方法,我們可以得到響應並且寫入 新令牌 。但是 這個做非常麻煩,首先我們要通過IO流讀取響應中的數據,然後還要把數據解析成JSON對象, 最後再放入這個新令牌。如果我們定義了 AOP切面類 ,攔截所有Web方法返回的 R對象 ,然後 在 R對象 裏面添加 新令牌 ,這多簡單啊。但是 OAuth2Filter 和 AOP 切面類之間沒有調用關 系,所以我們很難把 新令牌 傳給 AOP切面類 。 這裏我想到了 ThreadLocal ,只要是同一個線程,往 ThreadLocal 裏面寫入數據和讀取數據是 完全相同的。在Web項目中,從 OAuth2Filter 到 AOP切面類 ,都是由同一個線程來執行的,中途不會更換線程。所以我們可以放心的把新令牌保存都在 ThreadLocal 裏面, AOP切面類 可以成 功的取出新令牌,然後往 R對象 裏面添加新令牌即可。 ThreadLocalToken 是我自定義的類,裏面包含了 ThreadLocal 類型的變量,可以用來保存線程 安全的數據,而且避免了使用線程鎖
4.4 設計ThreadLocalToken類
@Component
public class ThreadLocalToken {
private ThreadLocal<String> threadLocal;
public void setToken(String token){
threadLocal.set(token);
}
public String getToken(){
return threadLocal.get();
}
public void clear(){
threadLocal.remove();
}
}
4.5 創建OAuth2Filter類
@Component
@Scope("prototype")//在spring的IOC容器中使用多例模式創建實例
public class OAuth2Filter extends AuthenticatingFilter {
@Autowired
private RedisTemplate redisTemplate;//將token保存到redis
@Autowired
private ThreadLocalToken threadLocalToken;//保存token的封裝類
@Value("${emos.jwt.cache-expire}")
private int expireDate;//令牌過期時間
@Autowired
private JWTUtil jwtUtil; //用於令牌加密的工具類
/**
* 生成token
* @param servletRequest
* @param servletResponse
* @return
* @throws Exception
*/
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest,
ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//從請求中獲取token並進行判斷
String tokenStr = getTokenByRequest(request);
if(StrUtil.isBlank(tokenStr)){
return null;
}
return new OAuth2Token(tokenStr);
}
/**
* 是否允許過濾
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response,
Object mappedValue) {
// 判斷是否是Option請求
HttpServletRequest req = (HttpServletRequest) request;
if(req.getMethod().equals(HttpMethod.OPTIONS.name())){
return true;//放過OPTIONS請求
}
return false;
}
/**
* 處理所有應該有shiro處理的請求
* 也就是不被放過的請求
* @param servletRequest
* @param servletResponse
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse)
throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setHeader("Content-Type","text/html;charset=UTF-8");
//允許跨域請求
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", response.getHeader("Origin"));
//清空當前ThreadLocalToken中的token,因為我們後面可能要新生成token
threadLocalToken.clear();
//從請求中獲取令牌
String tokenStr = getTokenByRequest(request);
//如果令牌為空,在響應中返回前端相關信息
if(StrUtil.isBlank(tokenStr)){
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("no useful token");
return false;
}
//如果令牌存在,刷新令牌
try{
//校驗令牌
jwtUtil.verifierToken(tokenStr);
}catch (TokenExpiredException e) { //發現令牌過期
//去redis中找
if (redisTemplate.hasKey("token")) {
//redis存在,重新為客户生成一個新的token
redisTemplate.delete("token");
int userId = jwtUtil.getUserId(tokenStr);
String token = jwtUtil.createToken(userId);
redisTemplate.opsForValue().set(token, userId + "", expireDate, TimeUnit.DAYS);
threadLocalToken.setToken(token);
}
//redis令牌不存在,需要重新登錄
else {
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("令牌已經過期");
return false;
}
}catch (JWTDecodeException e){
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("無效的令牌");
return false;
}
boolean bool = executeLogin(request, response);
return bool;
}
/**
* 處理登錄失敗的情況
* @param token
* @param e
* @param request
* @param response
* @return
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token,
AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.setContentType("application/json;charset=utf-8");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
try {
resp.getWriter().print(e.getMessage());
} catch (IOException exception) {
}
return false;
}
private String getTokenByRequest(HttpServletRequest httpServletRequest){
String token = httpServletRequest.getHeader("token");
if(StrUtil.isBlank(token)){
//如果請求頭中不存在就去請求體中獲取
token = httpServletRequest.getParameter("token");
}
return token;
}
}
4.6 創建ShiroConfig類
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm);
securityManager.setRememberMeManager(null);
return securityManager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
OAuth2Filter oAuth2Filter) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
HashMap<String, Filter> filters = new HashMap<>();
filters.put("oauth2", oAuth2Filter);
shiroFilter.setFilters(filters);
//因為LinkedHashMap可以保證存取得有序性
LinkedHashMap<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/test/**", "anon");
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
4.7 利用AOP,把更新的令牌返回給客户端
@Aspect
@Component
public class tokenAop {
@Autowired
private ThreadLocalToken localToken;
@Pointcut("execution(public * com.example.emos.wx.controller.*.*(..)))")
public void aspect(){
}
@Around("aspect()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
R r = (R) proceedingJoinPoint.proceed();
String token = localToken.getToken();
if(token != null){
r.put("token",token);
localToken.clear();
}
return r;
}
}
這樣就完成了我們Jwt+Shiro和我們項目的集成!!