Shiro是一種簡單的安全架構,可以用來處理系統的登入和權限問題。
本篇記錄一下Spring Boot和Shiro內建,并使用Jwt Token進行無狀态登入的簡單例子。
參考Demo位址 ,此Demo适合用于SpringBoot小型項目的快速開發。環境
-
SpringBoot 版本 1.5.15.RELEASE
不建議使用2.x版本的Springboot,與1.x相比很多地方代碼有所改動,很麻煩。
- Shiro 版本 1.4.0
- IntelliJ IDEA
- jjwt 版本 0.9.0
- lombok(可選)精簡代碼
思路
-
使用Jwt Token實作無狀态登入
平時使用者登入後,伺服器将會把使用者資訊存儲到Session裡,在使用者數量很大的時候,伺服器負擔會很大。而使用token方式登入,伺服器不存儲使用者資訊,而是将其加密後生成token發送給請求方,請求方在請求需要權限的資源時,将token帶上,伺服器解析token即可知道登入使用者的資訊。
-
伺服器自動重新整理token
token需要重新整理。對于活躍的使用者,伺服器自動完成重新整理token;對于長期不活躍的使用者,伺服器通過配置的 token有效期 來檢查,如果時間超過有效期的兩倍,則認為該使用者需要重新登入。
- 登入流程
-
使用者通過賬号密碼登入
使用者登入成功後,伺服器将使用者資訊等集合起來做成Jwt Token(字元串),然後将其放入Response裡的header,并發送請求成功的json給請求方。
請求方接收到請求成功的json資訊後,從header中拿出jwt token存儲起來。
-
使用者請求需要驗證的資源
請求方将token放入request的header,并發送請求。
伺服器收到請求,檢查request裡的token,首先驗證token合法性,不合法傳回token不合法的json給請求方。
如果token合法,則檢查token是否過期:
如果token簽發時間到現在,已經超過了有效期,卻沒有超過有效期的兩倍,則伺服器自動生成新token,将其放入response的header,請求方接收到response後,可以檢查header裡是否有token,有則更新一下token預備下次請求。
如果token從簽發時間到現在,已經超過有效期的兩倍,則使用者需要重新登入。
-
內建步驟
注意
- @Slf4j(topic = "xxx")注解是lombok內建的日志子產品,可不使用,參考: 日志處理方案
資料庫建表
思路:
系統裡有多個角色,每個角色對于多個權限。每個權限都是一個請求url,驗證權限時,背景拿到使用者資訊後即可知道該使用者的角色,而後去資料庫查詢該角色所擁有的權限集合,在其中查找是否存在目前請求url,存在說明使用者有通路該url的權限,否則沒有權限
-- Sql
-- Mysql Version 5.7
-- author [email protected]
drop database if exists `rb_demo`;
CREATE DATABASE rb_demo
DEFAULT CHARACTER SET utf8
COLLATE utf8_general_ci;
USE rb_demo;
-- ------------------------------ 使用者部分 ------------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`account` VARCHAR(50) NOT NULL COMMENT '賬号,唯一',
`password` VARCHAR(100) NOT NULL COMMENT '密碼',
`name` VARCHAR(100) DEFAULT '預設使用者名' COMMENT '昵稱',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '所屬角色id',
`status` TINYINT UNSIGNED NOT NULL COMMENT '是否啟用',
`is_deleted` TINYINT UNSIGNED NOT NULL COMMENT '是否删除',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
`gmt_create` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`gmt_modified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`id`),
KEY `idx_id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='使用者表';
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`name` VARCHAR(200) NOT NULL COMMENT '角色名稱',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '所屬角色id',
`name` VARCHAR(200) NOT NULL COMMENT '權限名稱',
`url` VARCHAR(200) NOT NULL COMMENT '比對url',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='權限表';
建立Springboot項目
元件選擇 web、redis和lombok,Springboot版本選擇 1.5.15.RELEASE
連接配接資料庫參考:
Mybatis-Plus編寫Shiro配置類
ShiroConfig.java 這個配置類主要配置了Shiro攔截器、自定義的Realm和禁用了Session。
禁用Session方法參考代碼注釋。
為什麼要禁用?因為我們采用Jwt Token方式完成登入驗證,不需要存使用者資訊到Session。
package com.spz.demo.security.shiro.config;
import com.spz.demo.security.shiro.filter.ShiroLoginFilter;
import com.spz.demo.security.shiro.matcher.PasswordCredentialsMatcher;
import com.spz.demo.security.shiro.realm.UserRealm;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.*;
/**
* Shiro 配置
* 禁用 Shiro Session 步驟:
* 1. SubjectContext 在建立的時候,需要關閉 session 的建立,這個由 DefaultWebSubjectFactory.createSubject 管理。
* 參考自定義類:ASubjectFactory.java
* 2. 禁用使用 Sessions 作為存儲政策的實作,這個由 securityManager 的 subjectDao.sessionStorageEvaluator 管理
* 3. 禁用掉會話排程器,這個由 sessionManager 管理
*/
@Slf4j(topic = "SYSTEM_LOG")
@Configuration
public class ShiroConfig {
@Autowired
private UserRealm userRealm;
/**
* Shiro 安全管理器
*/
@Bean
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 設定自定義的 SubjectFactory
manager.setSubjectFactory(subjectFactory());
// 設定自定義的 SessionManager
manager.setSessionManager(sessionManager());
// 禁用 Session
((DefaultSessionStorageEvaluator)((DefaultSubjectDAO)manager.getSubjectDAO()).getSessionStorageEvaluator())
.setSessionStorageEnabled(false);
// 設定自定義的 Realm
manager.setRealms(getRealms());
return manager;
}
/**
* 設定過濾規則
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//自定義攔截器 參考 ShiroLoginFilter.java
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
filtersMap.put("shiroLoginFilter", new ShiroLoginFilter());//登入驗證攔截器
shiroFilterFactoryBean.setFilters(filtersMap);
// 所有請求給這個攔截器處理
Map<String,String> filterChainDefinitionMap = new LinkedHashMap<String,String>();
filterChainDefinitionMap.put("/**", "shiroLoginFilter");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* 自定義的 subjectFactory
* 禁用了 Session
* @return
*/
@Bean
public DefaultWebSubjectFactory subjectFactory(){
ASubjectFactory mySubjectFactory = new ASubjectFactory();
return mySubjectFactory;
}
/**
* session管理器
* 禁用了 Session
* sessionManager通過sessionValidationSchedulerEnabled禁用掉會話排程器,
* @return
*/
@Bean
public DefaultSessionManager sessionManager(){
DefaultSessionManager sessionManager = new DefaultSessionManager();
sessionManager.setSessionValidationSchedulerEnabled(false);
return sessionManager;
}
/**
* 配置自定義的 Realm
* @return
*/
@Bean
public Collection<Realm> getRealms(){
Collection<Realm> realms = new ArrayList<>();
// 配置自定義 UserRealm
// 由于UserRealm裡使用了自動注入,是以這裡需要注入Realm而不是new建立
userRealm.setAuthenticationTokenClass(UserAuthenticationToken.class);
userRealm.setCredentialsMatcher(new PasswordCredentialsMatcher());//使用自定義的密碼比對器
realms.add(userRealm);
return realms;
}
}
ASubjectFactory.java 和ShiroConfig配套使用,用于禁用Session。
package com.spz.demo.security.shiro.config;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.SubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
/**
* 自定義的 SubjectFactory
* 禁用Session
* 對于無狀态的TOKEN不建立session 這裡都不使用session
*/
public class ASubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context) {
context.setSessionCreationEnabled(Boolean.FALSE);
return super.createSubject(context);
}
}
編寫自定義Shiro攔截器
ShiroLoginFilter.java
- Message類是包裝傳回給請求方的類,需要将Message執行個體轉為json輸出到Response輸出流,參考: [SpringMVC] Web層傳回值包裝JSON
-
WebUtil.isPublicRequest()方法判斷請求是否為公共請求
建議将不需要驗證權限的請求設定一個字首,比如/public/,這樣,isPublicRequest方法就可以檢查請求url裡是否有/public,有則說明是公共請求,直接放行。
-
所有請求(公共請求除外)都給* onAccessDenied*方法處理
在onAccessDenied方法裡,通過檢查請求url的方式來得知目前請求是什麼類型的請求。
如果是登入請求,則直接放行,因為登入邏輯放在了controller層方法。
如果是其他請求,則需要驗證登入和權限。
-
檢查使用者是否具備權限
将請求url和permission表裡的url進行比對,如果存在比對,則說明有權限。
package com.spz.demo.security.shiro.filter;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.spz.demo.security.bean.Message;
import com.spz.demo.security.common.MessageCode;
import com.spz.demo.security.common.RequestMappingConst;
import com.spz.demo.security.common.WebConst;
import com.spz.demo.security.entity.Role;
import com.spz.demo.security.exception.custom.RoleException;
import com.spz.demo.security.util.CommonUtil;
import com.spz.demo.security.util.JwtUtil;
import com.spz.demo.security.util.WebUtil;
import com.spz.demo.security.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 重寫shiro攔截器
* 所有請求由此攔截器攔截
*/
@Slf4j(topic = "USER_LOG")
@Component
public class ShiroLoginFilter extends AccessControlFilter {
//由于項目啟動時,Shiro加載比其他bean快,是以這裡需要加入Lazy注解,在使用時再加載。否則會出現jwtUtil為null的情況
@Autowired
@Lazy
private JwtUtil jwtUtil;
@Override
protected boolean isAccessAllowed(ServletRequest request,ServletResponse response, Object mappedValue) {
// 判斷請求是否是公共請求,通過請求的url判斷
if(WebUtil.isPublicRequest((HttpServletRequest) request)){
return true;
}
return false;// 拒絕,統一交給 onAccessDenied 處理
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest)request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
// ========== 判斷是否是登入請求,是就放行,登入處理放在了controller層 ==========
if(WebUtil.isLoginRequest(httpServletRequest)){
return true;
}
// ========== 其他請求,都需要驗證 ==========
//驗證是否登入(檢查json token)
if(CommonUtil.isBlank(httpServletRequest.getHeader(WebConst.TOKEN))){
// 傳回JSON給請求方
WebUtil.writeStringToResponse(httpServletResponse,JSON.toJSONString(
new Message()
.setErrorMessage("[" + WebConst.TOKEN + "] 不能為空,請将token存入header")
));
return false;
}
String token = httpServletRequest.getHeader(WebConst.TOKEN);
JwtToken jwtToken;
try {
jwtToken = jwtUtil.parseJwt(token);
}catch (RoleException re){//出現異常,說明驗證失敗
Message message = new Message();
if(re.getMessage().equals(RoleException.MSG_TOKEN_ERROR)){//token錯誤異常
message.setMessage(MessageCode.TOKEN_ERROR,RoleException.MSG_TOKEN_ERROR);
}else{//token過期異常
message.setMessage(MessageCode.TOKEN_OVERDUE,RoleException.MSG_TOKEN_OVERDUE);
}
WebUtil.writeStringToResponse((HttpServletResponse) response,JSON.toJSONString(message));//傳回json
return false;
}
if(jwtToken.getIsFlushed()){//需要重新整理token
httpServletResponse.setHeader(WebConst.TOKEN,jwtToken.getToken());// 更新response
}
// 檢查使用者是否具備權限
if(!jwtToken.hasUrl(((HttpServletRequest) request).getRequestURI())){
WebUtil.writeStringToResponse((HttpServletResponse) response,JSON.toJSONString(
new Message()
.setPermissionDeniedMessage("沒有權限")
));
return false;
}else{//登入驗證通過
return true;
}
}
}
編寫自定義的 Realm 類
- Realm類用來給shiro注入認證資訊和授權資訊,我們需要自定義。
- @Value("${jwt.salt}")是從application.yml中讀取配置
package com.spz.demo.security.shiro.realm;
import com.spz.demo.security.common.DatabaseConst;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.service.UserService;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@Slf4j(topic = "USER_LOG")
@Component("userRealm")
public class UserRealm extends AuthorizingRealm{
@Autowired
private UserService userService;
@Value("${jwt.salt}")
private String jwtSalt;
private static final String DEFAULT_JWT_SALT = "asdfh2738yWsdjDfha";//預設的鹽
/**
* 授權處理
* 不使用
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return null;
}
/**
* 身份認證
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 擷取使用者
String account = (String) authenticationToken.getPrincipal();//這裡的user裡隻有賬号和未加密的密碼
User user = userService.getUserByAccount(
account,
DatabaseConst.STATUS_ENABLE,
DatabaseConst.IS_DETETED_NO);
if (user == null) {
return null;
}else{
//這裡這樣做是因為我需要在web層可以拿到userID
((UserAuthenticationToken)authenticationToken).setUserId(user.getId());//指派userId
}
return new SimpleAuthenticationInfo(
user,
user.getPassword().toCharArray(),
ByteSource.Util.bytes((jwtSalt == null ? DEFAULT_JWT_SALT: jwtSalt)),//鹽
getName()
);
}
}
編寫自定義的 Matcher 類
- AuthenticatingRealm使用CredentialsMatcher進行密碼比對,我們需要自定義
package com.spz.demo.security.shiro.matcher;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import com.spz.demo.security.util.CommonUtil;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.credential.CredentialsMatcher;
/**
* 改寫原有的密碼比對器
* 用于賬号密碼登入時的賬密比對
*/
public class PasswordCredentialsMatcher implements CredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
//賬号密碼登入,則token應該是自定義的 AccountPasswordAuthenticationToken
if(token instanceof UserAuthenticationToken){
//這裡檢查賬号和密碼是否比對
//token是登入接口那裡擷取的,info是通過account擷取到資料裡的資訊
//密碼需要進行md5處理,因為資料庫存儲的密碼為密文
if(info.getPrincipals().getPrimaryPrincipal() instanceof User){
User user = (User)info.getPrincipals().getPrimaryPrincipal();
if(token.getPrincipal().equals(user.getAccount()) &&
CommonUtil.md5((String) token.getCredentials()).equals(user.getPassword())){
return true;
}
}
}
return false;
}
}
編寫自定義的AuthenticationToken類
package com.spz.demo.security.shiro.token;
import com.spz.demo.security.entity.User;
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
/**
* 用于登入
* 登入時給此類的account和password(明文)指派
* 然後在UserRealm裡将查詢到的userId指派給此類裡的userId。controller層需要id
*/
@Data
public class UserAuthenticationToken implements AuthenticationToken {
private Long userId;//使用者在資料庫中的id
private String account;
private String password;
public UserAuthenticationToken(String account, String password){
this.account = account;
this.password = password;
}
/**
* 傳回 account
* @return
*/
@Override
public Object getPrincipal() {
return this.account;
}
/**
* 傳回 password
* @return
*/
@Override
public Object getCredentials() {
return this.password;
}
}
編寫Jwt Token工具類
package com.spz.demo.security.util;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spz.demo.security.exception.custom.RoleException;
import com.spz.demo.security.vo.JwtToken;
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultHeader;
import io.jsonwebtoken.impl.DefaultJwsHeader;
import io.jsonwebtoken.impl.TextCodec;
import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver;
import io.jsonwebtoken.lang.Assert;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import sun.java2d.pipe.AlphaPaintPipe;
import javax.swing.event.CaretListener;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.*;
/**
* jwt 工具類
*
* @author zp
*/
@Slf4j(topic = "SYSTEM_LOG")
@Component
public class JwtUtil {
@Value("${jwt.appKey}")
private String appKey;//app key,用于加密
@Value("${jwt.period}")
private Long period;//token有效時間
@Value(("${jwt.issuer}"))
private String issuer;//jwt token 簽發人
public static final long DEFAULT_PERIOD = 60*60*1000;//token預設有效時間,1小時
public static final String DEFAULT_APPKEY = "defaultAppKey";//預設appkey,配置檔案裡讀不到appKey時用此值
public static final String DEFAULT_ISSUER = "Server-System-2333";//預設簽發人
private static final ObjectMapper MAPPER = new ObjectMapper();
private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
/**
* 簽發 JWT Token Token
* @param id 令牌ID
* @param subject subject 使用者ID
* @param issuer 簽發人,自定義
* @param roles 角色
* @param permissions 權限集合,建議傳入權限集合的json字元串
* @param period 有效時間(ms)
* 1. 在 目前時間-簽發時間>有效時間 時攜帶token通路接口,會重新重新整理token
* 在 目前時間-簽發時間>有效時間*2 時,則需要重新登入。
* 2. 這樣可以分離長時間不活躍的使用者和活躍使用者
* 活躍使用者感受不到token的重新整理
* 不活躍使用者需要登入才可以重新擷取token
* @param algorithm 加密算法
* @return
*/
public String issueJWT(String id,
String subject,
String issuer,
String roles,
String permissions,
Long period,
SignatureAlgorithm algorithm) {
// 需要讀取appKey
if(appKey == null || appKey.equals("")){
log.error("appKey無法讀取:" + appKey);
appKey = DEFAULT_APPKEY;
}
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(appKey);// 秘鑰
JwtBuilder jwtBuilder = Jwts.builder();
if (!StringUtils.isEmpty(id)) {
jwtBuilder.setId(id);
}
if (!StringUtils.isEmpty(subject)) {
jwtBuilder.setSubject(subject);
}
if (!StringUtils.isEmpty(issuer)) {
jwtBuilder.setIssuer(issuer);
}
// 設定簽發時間
Date now = new Date();
jwtBuilder.setIssuedAt(now);
// 設定到期時間
if (null != period) {
jwtBuilder.setExpiration(
new Date(now.getTime() + period + period)//簽發時間+有效期*2
);
}
if (!StringUtils.isEmpty(roles)) {
jwtBuilder.claim("roles",roles);
}
if (!StringUtils.isEmpty(permissions)) {
jwtBuilder.claim("perms",permissions);
}
// 壓縮,可選GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密設定
jwtBuilder.signWith(algorithm,secreKeyBytes);
return jwtBuilder.compact();
}
/**
* 驗簽JWT
*
* @param jwt json web token
* @return 如果驗證通過,且重新整理了token,則設定 JwtToken.isFlushed 為true
*/
public JwtToken parseJwt(String jwt) throws RoleException {
if(appKey == null || appKey.equals("")){
log.error("appKey無法讀取:" + appKey);
appKey = DEFAULT_APPKEY;
}
// 檢查 jwt token 合法性
Claims claims;
try{
claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
}catch (ExpiredJwtException ex){//token過期異常 token已經失效需要重新登入
throw new RoleException(RoleException.MSG_TOKEN_OVERDUE);
}catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e){//不支援的token
throw new RoleException(RoleException.MSG_TOKEN_ERROR);
}catch (Exception e){
log.error("驗證token時出現未知錯誤: " + CommonUtil.getDetailExceptionMsg(e));
throw new RoleException(RoleException.MSG_UNKNOWN_ERROR);
}
JwtToken jwtToken = new JwtToken();
// 檢查是否需要重新整理 jwt token
long time = claims.getIssuedAt().getTime();//token簽發時間
long now = new Date().getTime();//目前時間
period = (period == null ? JwtUtil.DEFAULT_PERIOD : period);
if(time + period >= now){//還在有效期内,不需要重新整理token
// log.info("不需要重新整理token");
jwtToken.setToken(jwt);
jwtToken.setIsFlushed(false);
}else if(time + period < now &&//超過有效期,但未超過2倍有效期,此時應該重新整理token
time + period + period >= now){
// log.info("重新整理token");
jwtToken.setToken(issueJWT(// 制作JWT Token
CommonUtil.getRandomString(20),//令牌id
claims.getSubject(),//使用者id
(issuer == null ? DEFAULT_ISSUER : issuer),//簽發人
claims.get("roles", String.class),//通路角色,設定為null,不使用
claims.get("perms", String.class),//權限集合字元串,json
period,//token有效時間*2
SignatureAlgorithm.HS512
));
jwtToken.setIsFlushed(true);
}else{
log.error("未知錯誤 - Jwts.parser() 方法未對過期token抛出異常");
}
// 設定其他字段
jwtToken.setId(claims.getSubject());//使用者id
jwtToken.setPermissions(
JSONObject.parseObject(
claims.get("perms", String.class),
List.class
)
);//使用者權限集合,json轉為list集合
return jwtToken;
}
/* *
* @Description
* @Param [val] 從json資料中讀取格式化map
* @Return java.util.Map<java.lang.String,java.lang.Object>
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> readValue(String val) {
try {
return MAPPER.readValue(val, Map.class);
} catch (IOException e) {
throw new MalformedJwtException("Unable to read JSON value: " + val, e);
}
}
}
controller登入驗證
package com.spz.demo.security.controller;
import com.alibaba.fastjson.JSONArray;
import com.spz.demo.security.bean.Message;
import com.spz.demo.security.common.MessageKeyConst;
import com.spz.demo.security.common.RedisConst;
import com.spz.demo.security.common.RequestMappingConst;
import com.spz.demo.security.common.WebConst;
import com.spz.demo.security.entity.Permission;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.service.UserService;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import com.spz.demo.security.util.CommonUtil;
import com.spz.demo.security.util.JwtUtil;
import com.spz.demo.security.util.RedisUtil;
import com.spz.demo.security.util.WebUtil;
import com.spz.demo.security.vo.JwtToken;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Slf4j(topic = "USER_LOG")
@RestController
public class UserController {
@Value("${jwt.period}")
private Long period;//token有效時間(毫秒)
@Value(("${jwt.issuer}"))
private String issuer;//jwt token 簽發人
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserService userService;
/**
* 使用者登入
* 驗證碼校驗和請求參數校驗功能已去除,完整版參考Demo
* @return
*/
@PostMapping(value = RequestMappingConst.LOGIN)
public Message login(String account,String password,HttpServletRequest request,HttpServletResponse response)throws Exception{
// 使用 Shiro 進行登入
Subject subject = SecurityUtils.getSubject();
UserAuthenticationToken token = new UserAuthenticationToken(account,password);
subject.login(token);
// 登入成功後,擷取userid,查詢該使用者擁有的權限
List<String> permissions = userService.getUserPermissions(token.getUserId());
// 制作JWT Token
String jwtToken = jwtUtil.issueJWT(
CommonUtil.getRandomString(20),//令牌id,必須為整個系統唯一id
token.getUserId() + "",//使用者id
(issuer == null ? JwtUtil.DEFAULT_ISSUER : issuer),//簽發人,可随便定義
null,//通路角色
JSONArray.toJSONString(permissions),//使用者權限集合,json格式
(period == null ? JwtUtil.DEFAULT_PERIOD : period),//token有效時間
SignatureAlgorithm.HS512//簽名算法,我也不知道是啥來的
);
//token存入 response裡的Header
response.setHeader(WebConst.TOKEN,jwtToken);
// 傳回Message的json
Message message = new Message().setSuccessMessage("登入成功,token已存入header");
message.getData().put("account",account);
message.getData().put(MessageKeyConst.LOGIN_TIME,new Date().getTime());
log.info("使用者登入成功 ip=" + WebUtil.getIpAdrress(request));
return message;
}
}
POM檔案參考
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.spz.demo</groupId>
<artifactId>security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>security</name>
<description>登入和權限demo,适用于小項目</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.15.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<fastjson.version>1.2.38</fastjson.version>
<mybatisplus.version>2.2.0</mybatisplus.version>
</properties>
<dependencies>
<!--json-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<!-- Mybatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<!-- Mybatis 代碼生成器(模闆引擎) -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity</artifactId>
<version>1.7</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.28</version>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Kaptcha驗證碼架構 -->
<dependency>
<groupId>com.github.axet</groupId>
<artifactId>kaptcha</artifactId>
<version>0.0.9</version>
</dependency>
<!-- apache -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>
<!-- json 用于web層包裝請求傳回-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.7.4</version>
</dependency>
<!-- lombok 精簡代碼用 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.0</version>
<scope>provided</scope>
</dependency>
<!-- Jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!-- shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>
<!-- Mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml參考
spring:
# AOP Config
aop:
auto: true
redis:
host: 127.0.0.1
password:
port: 6379
database: 0
datasource:
url: jdbc:mysql://xxx.xx.xx.xxx:3306/rb_demo?useUnicode=true&characterEncoding=UTF-8
username: root
password:
driver-class-name: com.mysql.jdbc.Driver
# Jwt Token相關配置
jwt:
appKey: ds[W&dsfa:dfhu12a%W@ // app秘鑰,随便定義即可
appId: 210293ajkw723o@7eh*db //appId,随便定義即可
period: 120000 # 有效期,機關ms
issuer: Server-System # 簽發者,用于制作 jwt token
salt: salt-sdwbhx23i # 鹽,随便定義即可, view UserRealm.doGetAuthenticationInfo()
# Mybatis-Plus 配置,請參考官方文檔
mybatis-plus:
mapper-locations: classpath:/mapper/*Mapper.xml
typeAliasesPackage: com.spz.demo.security.entity
global-config:
id-type: 2
field-strategy: 0
db-column-underline: true
refresh-mapper: true
configuration:
map-underscore-to-camel-case: true
cache-enabled: true
工具類參考
- 通用工具類
package com.spz.demo.security.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/* *
* @Author tomsun28
* @Description 高頻方法工具類
* @Date 14:08 2018/3/12
*/
@Slf4j(topic = "SYSTEM_LOG")
public class CommonUtil {
/**
* 擷取指定位數的随機數
* @param length
* @return
*/
public static String getRandomString(int length) {
String base = "abcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}
/**
* MD5加密
* @param content
* @return
*/
public static String md5(String content) {
// 用于加密的字元
char[] md5String = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
try {
// 使用平台預設的字元集将md5String編碼為byte序列,并将結果存儲到一個新的byte數組中
byte[] byteInput = content.getBytes();
// 資訊摘要是安全的單向哈希函數,它接收任意大小的資料,并輸出固定長度的哈希值
MessageDigest mdInst = MessageDigest.getInstance("MD5");
// MessageDigest對象通過使用update方法處理資料,使用指定的byte數組更新摘要
mdInst.update(byteInput);
//摘要更新後通過調用digest() 執行哈希計算,獲得密文
byte[] md = mdInst.digest();
//把密文轉換成16進制的字元串形式
int j = md.length;
char[] str = new char[j*2];
int k = 0;
for (int i=0;i<j;i++) {
byte byte0 = md[i];
str[k++] = md5String[byte0 >>> 4 & 0xf];
str[k++] = md5String[byte0 & 0xf];
}
// 傳回加密後的字元串
return new String(str);
}catch (Exception e) {
log.error("加密出現錯誤:" + e.toString());
return null;
}
}
/**
* 分割字元串進SET
*/
@SuppressWarnings("unchecked")
public static Set<String> split(String str) {
Set<String> set = new HashSet<>();
if (StringUtils.isEmpty(str))
return set;
set.addAll(CollectionUtils.arrayToList(str.split(",")));
return set;
}
/**
* 檢查字元串是否為空
* @param str
* @return
*/
public static boolean isBlank(String str){
return (str == null || str.equals("") ? true : false);
}
}
- Web請求工具類
package com.spz.demo.security.util;
import com.spz.demo.security.common.RedisConst;
import com.spz.demo.security.common.RequestMappingConst;
import org.apache.commons.lang.StringUtils;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
public class WebUtil {
/**
* 檢查url是否需要登入驗證
* @param url
* @return false 不需要登入即可通路
* true 需要登入才可以通路
*/
public static boolean needLogin(String url){
if(url.indexOf(RequestMappingConst.V_CODE) >= 0 || //驗證碼
url.indexOf(RequestMappingConst.LOGIN) >= 0){//登入
return false;
}
return true;
}
/**
* 擷取Ip位址
* @param request
* @return
*/
public static String getIpAdrress(HttpServletRequest request) {
String Xip = request.getHeader("X-Real-IP");
String XFor = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
//多次反向代理後會有多個ip值,第一個ip才是真實ip
int index = XFor.indexOf(",");
if (index != -1) {
return XFor.substring(0,index);
} else {
return XFor;
}
}
XFor = Xip;
if (StringUtils.isNotEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
return XFor;
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_CLIENT_IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getRemoteAddr();
}
return XFor;
}
/**
* 檢查請求是否為登入請求
* @param request
* @return
*/
public static boolean isLoginRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.LOGIN) >= 0){
return true;
}
return false;
}
/**
* 檢查請求是否為登出請求
* @param request
* @return
*/
public static boolean isLogoutRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.LOGOUT) >= 0){
return true;
}
return false;
}
/**
* 檢查請求是否為公共請求
* @param request
* @return
*/
public static boolean isPublicRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.BASIC_URL_PUBLIC) >= 0){
return true;
}
return false;
}
/**
* 輸出json字元串到 HttpServletResponse
* @param response
* @param str : 字元串
*/
public static void writeJSONToResponse(HttpServletResponse response, String str){
PrintWriter jsonOut = null;
response.setContentType("application/json;charset=UTF-8");
try {
jsonOut = response.getWriter();
jsonOut.write(str);
}catch (Exception e){
e.printStackTrace();
}finally{
if(jsonOut != null){
jsonOut.close();
}
}
}
}