大家好,我是程序員田同學。
公司開始了新項目,新項目的認證採用的是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實現方案到這裏就正式結束了。