jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroRealm.java
package org.jeecg.config.shiro;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.jeecg.common.api.CommonAPI;
import org.jeecg.common.config.TenantContext;
import org.jeecg.common.constant.CacheConstant;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.RedisUtil;
import org.jeecg.common.util.SpringContextUtils;
import org.jeecg.common.util.TokenUtils;
import org.jeecg.common.util.oConvertUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Set;
/**
* @Description: 用户登录鉴权和获取用户授权
* @Author: Scott
* @Date: 2019-4-23 8:13
* @Version: 1.1
*/
@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
@Lazy
@Resource
private CommonAPI commonApi;
@Lazy
@Resource
private RedisUtil redisUtil;
/**
* 必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 权限信息认证(包括角色以及权限)是用户访问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();
log.debug("登录租户:" + contextTenantId);
log.debug("用户拥有那些租户:" + userTenantIds);
//登录用户无租户,前端header中租户ID值为 0
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)){
boolean isAuthorization = false;
//========================================================================
// 查询用户信息(如果租户不匹配从数据库中重新查询一次用户信息)
String loginUserKey = CacheConstant.SYS_USERS_CACHE + "::" + username;
redisUtil.del(loginUserKey);
LoginUser loginUserFromDb = commonApi.getUserByName(username);
if (oConvertUtils.isNotEmpty(loginUserFromDb.getRelTenantIds())) {
String[] newArray = loginUserFromDb.getRelTenantIds().split(",");
if (oConvertUtils.isIn(contextTenantId, newArray)) {
isAuthorization = true;
}
}
//========================================================================
//*********************************************
if(!isAuthorization){
log.info("租户异常——登录租户:" + contextTenantId);
log.info("租户异常——用户拥有租户组:" + userTenantIds);
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)) {
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);
}
}
ShiroRealm是用户认证时调用的关键判断逻辑,这部分代码不是shiro库中的公共代码,而是项目开发者自己实现的。
可以通过debug的代码栈窗口,查看调用流程。
把debug断点设在org.jeecg.modules.system.service.impl.SysUserServiceImpl#getEncodeUserInfo方法内,运行到断点处停止,可以看到代码栈窗口显示了以往调用的方法,从下至上排列,最先调用的方法在最下方,最后调用的方法在最上方,其中通过接口调用的方法(比如org.jeecg.modules.system.service.ISysUserService#getEncodeUserInfo),还夹着invoke方法,盲猜是在执行注释,执行invoke后redis中出现了相应的缓存数据。
@Cacheable(cacheNames=CacheConstant.SYS_USERS_CACHE, key="#username")
流程顺序为:
1、从org.jeecg.config.shiro.ShiroRealm#doGetAuthenticationInfo调用
this.checkUserTokenIsEffect(token)
2、从org.jeecg.config.shiro.ShiroRealm#checkUserTokenIsEffect调用
TokenUtils.getLoginUser(username, commonApi, redisUtil)
3、从org.jeecg.common.util.TokenUtils#getLoginUser调用
commonApi.getUserByName(username)
4、从org.jeecg.modules.system.service.impl.SysBaseApiImpl#getUserByName调用
sysUserService.getEncodeUserInfo(username)
5、从org.jeecg.modules.system.service.impl.SysUserServiceImpl#getEncodeUserInfo调用
userMapper.getUserByName(username)
一、用户登录时,经过一系列前期流程,shiro最终调用开发者自定义的ShiroRealm的org.jeecg.config.shiro.ShiroRealm#doGetAuthenticationInfo方法。
该方法的执行流程是:
1、调用同一文件下的checkUserTokenIsEffect()具体进行用户验证。
loginUser = this.checkUserTokenIsEffect(token);
2、验证成功时返回SimpleAuthenticationInfo对象,失败则抛异常或返回null。
二、org.jeecg.config.shiro.ShiroRealm#checkUserTokenIsEffect内部流程:
1、先通过token解密出用户名: String username = JwtUtil.getUsername(token);
2、再通过用户名从redis或mysql获取用户信息(优先查redis,redis速度快):
LoginUser loginUser = TokenUtils.getLoginUser(username, commonApi, redisUtil);
3、调用jwtTokenRefresh(token, username, loginUser.getPassword())校验用户
4、最后校验用户的tenant_id和前端传过来的是否一致,关于这部分的作用,可能属于saas功能,有待进一步学习。
三、org.jeecg.common.util.TokenUtils#getLoginUser涉及redis缓存的使用,第一次通过token(解密出来的用户名)查询用户信息时,redis未缓存该用户信息,需要去数据库中查询,查询后返回的结果直接缓存到redis中
public static LoginUser getLoginUser(String username, CommonAPI commonApi, RedisUtil redisUtil) {
LoginUser loginUser = null;
String loginUserKey = CacheConstant.SYS_USERS_CACHE + "::" + username;
//【重要】此处通过redis原生获取缓存用户,是为了解决微服务下system服务挂了,其他服务互调不通问题---
if (redisUtil.hasKey(loginUserKey)) {
try {
loginUser = (LoginUser) redisUtil.get(loginUserKey);
//解密用户
SensitiveInfoUtil.handlerObject(loginUser, false);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
} else {
// 查询用户信息
loginUser = commonApi.getUserByName(username);
}
return loginUser;
}
在执行commonApi.getUserByName(username);(org.jeecg.modules.system.service.impl.SysBaseApiImpl#getUserByName)之前,redis中不存在key为sys:cache:encrypt:user::admin的键值对,执行后出现了,继续深入getUserByName内:
@Override
//@SensitiveDecode
public LoginUser getUserByName(String username) {
//update-begin-author:taoyan date:2022-6-6 for: VUEN-1276 【v3流程图】测试bug 1、通过我发起的流程或者流程实例,查看历史,流程图预览问题
if (oConvertUtils.isEmpty(username)) {
return null;
}
//update-end-author:taoyan date:2022-6-6 for: VUEN-1276 【v3流程图】测试bug 1、通过我发起的流程或者流程实例,查看历史,流程图预览问题
LoginUser user = sysUserService.getEncodeUserInfo(username);
//相同类中方法间调用时脱敏解密 Aop会失效,获取用户信息太重要,此处采用原生解密方法,不采用@SensitiveDecodeAble注解方式
try {
SensitiveInfoUtil.handlerObject(user, false);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return user;
}
getUserByName()的主要功能代码是调用
sysUserService.getEncodeUserInfo(username);
(org.jeecg.modules.system.service.impl.SysUserServiceImpl#getEncodeUserInfo)
@Override
@Cacheable(cacheNames=CacheConstant.SYS_USERS_CACHE, key="#username")
@SensitiveEncode
public LoginUser getEncodeUserInfo(String username){
if(oConvertUtils.isEmpty(username)) {
return null;
}
LoginUser loginUser = new LoginUser();
SysUser sysUser = userMapper.getUserByName(username);
//查询用户的租户ids
this.setUserTenantIds(sysUser);
if(sysUser==null) {
return null;
}
BeanUtils.copyProperties(sysUser, loginUser);
return loginUser;
}
在getEncodeUserInfo()方法上有一个注释:
@Cacheable(cacheNames=CacheConstant.SYS_USERS_CACHE, key="#username")
正式创建缓存的关键代码,@Cacheable 注解在方法上,表示该方法的返回结果是可以缓存的。也就是说,该方法的返回结果会放在缓存中,以便于以后使用相同的参数调用该方法时,会返回缓存中的值,而不会实际执行该方法。
在没有看到这个代码前,我是想不到@Cacheable的作用的,但是我知道redis中这一类键值对都是以CacheConstant.SYS_USERS_CACHE("sys:cache:encrypt:user")为key的开头的,通过ctrl+鼠标左键一键定位到SYS_USERS_CACHE定义处,能看到它有24个用法:
点击“24个用法”挨个查看:
就能发现一些规律,@CacheEvict注释是删除相应的redis缓存数据,打开前五个代码中的用法,发现也是在删除redis缓存数据。一一排除删除的用法,就剩下了@Cacheable用法,这时我小心假设它是创建缓存的方法,并通过百度印证了猜想。