公司開始了新項目,新項目的認證采用的是Shiro實作。由于涉及到多端登入使用者,而且多端使用者還是來自不同的表。
這就涉及到了Shiro的多realm,今天的demo主要是介紹Shiro的多realm實作方案,文中包含所有的代碼,需要的朋友可以無縫copy。
大家好,我是程式員田同學。
公司開始了新項目,新項目的認證采用的是Shiro實作。由于涉及到多端登入使用者,而且多端使用者還是來自不同的表。
這就涉及到了Shiro的多realm,今天的demo主要是介紹Shiro的多realm實作方案,文中包含所有的代碼,需要的朋友可以無縫copy。
前後端分離的背景下,在認證的實作中主要是兩方面的内容,一個是使用者登入擷取到token,二是從請求頭中拿到token并檢驗token的有效性和設定緩存。
1、使用者登入擷取token
登入和以往單realm實作邏輯一樣,使用使用者和密碼生成token傳回給前端,前端每次請求接口的時候攜帶token。
@ApiOperation(value="登入", notes="登入")
public Result<JSONObject> wxappLogin(String username,String password){
Result<JSONObject> result = new Result<JSONObject>();
JSONObject obj = new JSONObject();
// 生成token
String password="0";
String token = JwtUtil.sign(username, password);
obj.put("token", token);
result.setResult(obj);
result.success("登入成功");
return result;
}
生成token的工具類
/**
* 生成簽名,5min後過期
*
* @param username 使用者名
* @param secret 使用者的密碼
* @return 加密的token
*/
public static String sign(String username, String secret) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附帶username資訊
return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);
}
以上就實作了簡單的登入邏輯,和Shiro的單realm設定和SpringSecurity的登入邏輯都沒有什麼差別。
2、鑒權登入攔截器(驗證token有效性)
使用Shiro登入攔截器的隻需要繼承Shiro的 BasicHttpAuthenticationFilter 類 重寫 isAccessAllowed()方法,在該方法中我們從ServletRequest中擷取到token和login_type。
需要特别指出的是,由于是多realm,我們在請求頭中加入一個login_type來區分不同的登入類型。
通過token和login_type我們生成一個JwtToken對象送出給getSubject。
JwtFilter過濾器
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 預設開啟跨域設定(使用單體)
*/
private boolean allowOrigin = true;
public JwtFilter(){}
public JwtFilter(boolean allowOrigin){
this.allowOrigin = allowOrigin;
}
/**
* 執行登入認證
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
JwtUtil.responseError(response,401,CommonConstant.TOKEN_IS_INVALID_MSG);
return false;
//throw new AuthenticationException("Token失效,請重新登入", e);
}
}
/**
*
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader(CommonConstant.X_ACCESS_TOKEN);
String loginType = httpServletRequest.getHeader(CommonConstant.LOGIN_TYPE);
// update-begin--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token驗證,擷取token參數
if (oConvertUtils.isEmpty(token)) {
token = httpServletRequest.getParameter("token");
}
// update-end--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token驗證,擷取token參數
JwtToken jwtToken = new JwtToken(token,loginType);
// 送出給realm進行登入,如果錯誤他會抛出異常并被捕獲
getSubject(request, response).login(jwtToken);
// 如果沒有抛出異常則代表登入成功,傳回true
return true;
}
}
JwtToken類
public class JwtToken implements AuthenticationToken {
private static final long serialVersionUID = 1L;
private String token;
private String loginType;
// public JwtToken(String token) {
// this.token = token;
// }
public JwtToken(String token,String loginType) {
this.token = token;
this.loginType=loginType;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getLoginType() {
return loginType;
}
public void setLoginType(String loginType) {
this.loginType = loginType;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
再往下的邏輯肯定會先根據我們的login_type來走不同的realm了,然後在各自的realm中去檢查token的有效性了,那Shiro怎麼知道我們的Realm都是哪些呢?
接下來就該引出使用Shiro的核心配置檔案了——ShiroConfig.java類
shiro的配置檔案中會注入名字為securityManager的Bean。
在該bean中首先注入ModularRealmAuthenticator,ModularRealmAuthenticator會根據配置的AuthenticationStrategy(身份驗證政策)進行多Realm認證過程。
由于是多realm我們需要重寫ModularRealmAuthenticator類,ModularRealmAuthenticator類中用于判斷邏輯走不同的realm,接着注入我們的兩個realm,分别是myRealm和clientShiroRealm。
重新注入 ModularRealm類
@Bean
public ModularRealm ModularRealm(){
//自己重寫的ModularRealmAuthenticator
ModularRealm modularRealm = new ModularRealm();
// modularRealmAuthenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());//這裡為預設政策:如果有一個或多個Realm驗證成功,所有的嘗試都被認為是成功的,如果沒有一個驗證成功,則該次嘗試失敗
return modularRealm;
}
securityManager-bean。
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(ShiroRealm myRealm,
ClientShiroRealm clientShiroRealm,ModularRealm modularRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// securityManager.setRealm(myRealm);
securityManager.setAuthenticator(modularRealm);
List<Realm> realms = new ArrayList<>();
//添加多個Realm
realms.add(myRealm);
realms.add(clientShiroRealm);
securityManager.setRealms(realms);
/*
* 關閉shiro自帶的session,詳情見文檔
* http://shiro.apache.org/session-management.html#SessionManagement-
* StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
//自定義緩存實作,使用redis
securityManager.setCacheManager(redisCacheManager());
return securityManager;
}
ModularRealm實作類
public class ModularRealm extends ModularRealmAuthenticator {
@Override
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
assertRealmsConfigured();
Collection<Realm> realms = getRealms();
// 登入類型對應的所有Realm
HashMap<String, Realm> realmHashMap = new HashMap<>(realms.size());
for (Realm realm : realms) {
// 這裡使用的realm中定義的Name屬性來進行區分,注意realm中要加上
realmHashMap.put(realm.getName(), realm);
}
JwtToken token = (JwtToken) authenticationToken;
if (StrUtil.isEmpty(token.getLoginType())){
return doSingleRealmAuthentication(realmHashMap.get(LoginType.DEFAULT.getType()),token);
} else {
return doSingleRealmAuthentication(realmHashMap.get(token.getLoginType()),token);
}
// return super.doAuthenticate(authenticationToken);
}
}
然後會根據不同的login_type到不同的realm,下面為我的Shiro認證realm。
myrealm類.
@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
@Lazy
@Resource
private CommonAPI commonApi;
@Lazy
@Resource
private RedisUtil redisUtil;
@Override
public String getName() {
return LoginType.DEFAULT.getType();
}
/**
* 必須重寫此方法,不然Shiro會報錯
*/
@Override
public boolean supports(AuthenticationToken token) {
// return token instanceof JwtToken;
if (token instanceof JwtToken){
return StrUtil.isEmpty(((JwtToken) token).getLoginType()) || LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType());
} else {
return false;
}
}
/**
* 權限資訊認證(包括角色以及權限)是使用者通路controller的時候才進行驗證(redis存儲的此處權限資訊)
* 觸發檢測使用者權限時才會調用此方法,例如checkRole,checkPermission
*
* @param principals 身份資訊
* @return AuthorizationInfo 權限資訊
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.debug("===============Shiro權限認證開始============ [ roles、permissions]==========");
String username = null;
if (principals != null) {
LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal();
username = sysUser.getUsername();
}
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 設定使用者擁有的角色集合,比如“admin,test”
Set<String> roleSet = commonApi.queryUserRoles(username);
System.out.println(roleSet.toString());
info.setRoles(roleSet);
// 設定使用者擁有的權限集合,比如“sys:role:add,sys:user:add”
Set<String> permissionSet = commonApi.queryUserAuths(username);
info.addStringPermissions(permissionSet);
System.out.println(permissionSet);
log.info("===============Shiro權限認證成功==============");
return info;
}
/**
* 使用者資訊認證是在使用者進行登入的時候進行驗證(不存redis)
* 也就是說驗證使用者輸入的賬号和密碼是否正确,錯誤抛出異常
*
* @param auth 使用者登入的賬号密碼資訊
* @return 傳回封裝了使用者資訊的 AuthenticationInfo 執行個體
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
log.debug("===============Shiro身份認證開始============doGetAuthenticationInfo==========");
String token = (String) auth.getCredentials();
if (token == null) {
HttpServletRequest req = SpringContextUtils.getHttpServletRequest();
log.info("————————身份認證失敗——————————IP位址: "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI());
throw new AuthenticationException("token為空!");
}
// 校驗token有效性
LoginUser loginUser = null;
try {
loginUser = this.checkUserTokenIsEffect(token);
} catch (AuthenticationException e) {
JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage());
e.printStackTrace();
return null;
}
return new SimpleAuthenticationInfo(loginUser, token, getName());
}
/**
* 校驗token的有效性
*
* @param token
*/
public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException {
// 解密獲得username,用于和資料庫進行對比
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token非法無效!");
}
// 查詢使用者資訊
log.debug("———校驗token是否有效————checkUserTokenIsEffect——————— "+ token);
LoginUser loginUser = TokenUtils.getLoginUser(username,commonApi,redisUtil);
//LoginUser loginUser = commonApi.getUserByName(username);
if (loginUser == null) {
throw new AuthenticationException("使用者不存在!");
}
// 判斷使用者狀态
if (loginUser.getStatus() != 1) {
throw new AuthenticationException("賬号已被鎖定,請聯系管理者!");
}
// 校驗token是否逾時失效 & 或者賬号密碼是否錯誤
if (!jwtTokenRefresh(token, username, loginUser.getPassword())) {
throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG);
}
//update-begin-author:taoyan date:20210609 for:校驗使用者的tenant_id和前端傳過來的是否一緻
String userTenantIds = loginUser.getRelTenantIds();
if(oConvertUtils.isNotEmpty(userTenantIds)){
String contextTenantId = TenantContext.getTenant();
String str ="0";
if(oConvertUtils.isNotEmpty(contextTenantId) && !str.equals(contextTenantId)){
//update-begin-author:taoyan date:20211227 for: /issues/I4O14W 使用者租戶資訊變更判斷漏洞
String[] arr = userTenantIds.split(",");
if(!oConvertUtils.isIn(contextTenantId, arr)){
throw new AuthenticationException("使用者租戶資訊變更,請重新登陸!");
}
//update-end-author:taoyan date:20211227 for: /issues/I4O14W 使用者租戶資訊變更判斷漏洞
}
}
//update-end-author:taoyan date:20210609 for:校驗使用者的tenant_id和前端傳過來的是否一緻
return loginUser;
}
/**
* JWTToken重新整理生命周期 (實作: 使用者線上操作不掉線功能)
* 1、登入成功後将使用者的JWT生成的Token作為k、v存儲到cache緩存裡面(這時候k、v值一樣),緩存有效期設定為Jwt有效時間的2倍
* 2、當該使用者再次請求時,通過JWTFilter層層校驗之後會進入到doGetAuthenticationInfo進行身份驗證
* 3、當該使用者這次請求jwt生成的token值已經逾時,但該token對應cache中的k還是存在,則表示該使用者一直在操作隻是JWT的token失效了,程式會給token對應的k映射的v值重新生成JWTToken并覆寫v值,該緩存生命周期重新計算
* 4、當該使用者這次請求jwt在生成的token值已經逾時,并在cache中不存在對應的k,則表示該使用者賬戶空閑逾時,傳回使用者資訊已失效,請重新登入。
* 注意: 前端請求Header中設定Authorization保持不變,校驗有效性以緩存中的token為準。
* 使用者過期時間 = Jwt有效時間 * 2。
*
* @param userName
* @param passWord
* @return
*/
public boolean jwtTokenRefresh(String token, String userName, String passWord) {
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
if (oConvertUtils.isNotEmpty(cacheToken)) {
// 校驗token有效性
if (!JwtUtil.verify(cacheToken, userName, passWord)) {
//生成token
String newAuthorization = JwtUtil.sign(userName, passWord);
// 設定逾時時間
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization);
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000);
log.debug("——————————使用者線上操作,更新token保證不掉線—————————jwtTokenRefresh——————— "+ token);
}
//update-begin--Author:scott Date:20191005 for:解決每次請求,都重寫redis中 token緩存問題
// else {
// // 設定逾時時間
// redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, cacheToken);
// redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000);
// }
//update-end--Author:scott Date:20191005 for:解決每次請求,都重寫redis中 token緩存問題
return true;
}
//redis中不存在此TOEKN,說明token非法傳回false
return false;
}
/**
* 清除目前使用者的權限認證緩存
*
* @param principals 權限資訊
*/
@Override
public void clearCache(PrincipalCollection principals) {
super.clearCache(principals);
}
}
ClientShiroRealm類.
@Component
@Slf4j
public class ClientShiroRealm extends AuthorizingRealm {
@Lazy
@Resource
private ClientAPI clientAPI;
@Lazy
@Resource
private RedisUtil redisUtil;
@Override
public String getName() {
return LoginType.CLIENT.getType();
}
/**
* 必須重寫此方法,不然Shiro會報錯
*/
@Override
public boolean supports(AuthenticationToken token) {
// return token instanceof JwtToken;
if (token instanceof JwtToken){
return LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType());
} else {
return false;
}
}
/**
* 權限資訊認證(包括角色以及權限)是使用者通路controller的時候才進行驗證(redis存儲的此處權限資訊)
* 觸發檢測使用者權限時才會調用此方法,例如checkRole,checkPermission
*
* @param principals 身份資訊
* @return AuthorizationInfo 權限資訊
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.debug("===============Shiro權限認證開始============ [ roles、permissions]==========");
//String username = null;
//if (principals != null) {
// LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal();
// username = sysUser.getUsername();
//}
//SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//// 設定使用者擁有的角色集合,比如“admin,test”
//Set<String> roleSet = commonApi.queryUserRoles(username);
//System.out.println(roleSet.toString());
//info.setRoles(roleSet);
//
//// 設定使用者擁有的權限集合,比如“sys:role:add,sys:user:add”
//Set<String> permissionSet = commonApi.queryUserAuths(username);
//info.addStringPermissions(permissionSet);
//System.out.println(permissionSet);
log.info("===============Shiro權限認證成功==============");
return null;
}
/**
* 使用者資訊認證是在使用者進行登入的時候進行驗證(不存redis)
* 也就是說驗證使用者輸入的賬号和密碼是否正确,錯誤抛出異常
*
* @param auth 使用者登入的賬号密碼資訊
* @return 傳回封裝了使用者資訊的 AuthenticationInfo 執行個體
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
log.debug("===============Shiro身份認證開始============doGetAuthenticationInfo==========");
String token = (String) auth.getCredentials();
if (token == null) {
HttpServletRequest req = SpringContextUtils.getHttpServletRequest();
log.info("————————身份認證失敗——————————IP位址: "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI());
throw new AuthenticationException("token為空!");
}
// 校驗token有效性
LoginUser loginUser = null;
try {
loginUser = this.checkUserTokenIsEffect(token);
} catch (AuthenticationException e) {
JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage());
e.printStackTrace();
return null;
}
return new SimpleAuthenticationInfo(loginUser, token, getName());
}
/**
* 校驗token的有效性
*
* @param token
*/
public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException {
// 解密獲得username,用于和資料庫進行對比
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token非法無效!");
}
// 查詢使用者資訊
log.debug("———校驗token是否有效————checkUserTokenIsEffect——————— "+ token);
LoginUser loginUser = TokenUtils.getClientLoginUser(username,clientAPI,redisUtil);
//LoginUser loginUser = commonApi.getUserByName(username);
if (loginUser == null) {
throw new AuthenticationException("使用者不存在!");
}
// 校驗token是否逾時失效 & 或者賬号密碼是否錯誤
if (!jwtTokenRefresh(token, username, loginUser.getPassword())) {
throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG);
}
return loginUser;
}
/**
* JWTToken重新整理生命周期 (實作: 使用者線上操作不掉線功能)
* 1、登入成功後将使用者的JWT生成的Token作為k、v存儲到cache緩存裡面(這時候k、v值一樣),緩存有效期設定為Jwt有效時間的2倍
* 2、當該使用者再次請求時,通過JWTFilter層層校驗之後會進入到doGetAuthenticationInfo進行身份驗證
* 3、當該使用者這次請求jwt生成的token值已經逾時,但該token對應cache中的k還是存在,則表示該使用者一直在操作隻是JWT的token失效了,程式會給token對應的k映射的v值重新生成JWTToken并覆寫v值,該緩存生命周期重新計算
* 4、當該使用者這次請求jwt在生成的token值已經逾時,并在cache中不存在對應的k,則表示該使用者賬戶空閑逾時,傳回使用者資訊已失效,請重新登入。
* 注意: 前端請求Header中設定Authorization保持不變,校驗有效性以緩存中的token為準。
* 使用者過期時間 = Jwt有效時間 * 2。
*
* @param userName
* @param passWord
* @return
*/
public boolean jwtTokenRefresh(String token, String userName, String passWord) {
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
if (oConvertUtils.isNotEmpty(cacheToken)) {
// 校驗token有效性
if (!JwtUtil.verify(cacheToken, userName, passWord)) {
//生成token
String newAuthorization = JwtUtil.sign(userName, passWord);
// 設定逾時時間
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization);
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000);
log.debug("——————————使用者線上操作,更新token保證不掉線—————————jwtTokenRefresh——————— "+ token);
}
return true;
}
//redis中不存在此TOEKN,說明token非法傳回false
return false;
}
/**
* 清除目前使用者的權限認證緩存
*
* @param principals 權限資訊
*/
@Override
public void clearCache(PrincipalCollection principals) {
super.clearCache(principals);
}
}
這兩個realm更多的是需要實作我們自身的realm,我把我的全部代碼貼上,讀者可根據自己的需要進行修改,兩個方法大緻的作用都是檢驗token的有效性,隻是查詢的使用者從不同的使用者表中查出來的。
至此,Shiro的多Realm實作方案到這裡就正式結束了。