JEECG shiro验证实现分析

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用法,这时我小心假设它是创建缓存的方法,并通过百度印证了猜想。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Shiro是一个用于身份验证、授权和会话管理的Java安全框架。它提供了一种简单而强大的方式来保护应用程序,同时保持易于理解和使用。 在Shiro中,实现登录验证通常有以下几个步骤: 1. 配置Shiro安全管理器,包括Realm、Session管理器等组件。 2. 实现自定义RealmRealmShiro中进行身份验证和授权的核心组件。可以使用其中的几个接口来实现认证和授权逻辑。 3. 在Realm实现认证逻辑,包括从数据库或其他数据源中获取用户信息,并将其与用户输入的用户名和密码进行比较。 4. 在应用程序中处理登录请求,并将用户输入的用户名和密码传递给Shiro,让其进行身份验证。 5. 根据身份验证结果,决定是否允许用户访问应用程序的资源。 以下是一个简单的示例代码,演示了如何使用Shiro进行基本的身份验证: ```java // 创建安全管理器 SecurityManager securityManager = new DefaultSecurityManager(); // 配置Realm Realm realm = new MyRealm(); // 自定义Realm ((DefaultSecurityManager) securityManager).setRealm(realm); // 将安全管理器绑定到当前线程 SecurityUtils.setSecurityManager(securityManager); // 处理登录请求 Subject currentUser = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken("username", "password"); try { currentUser.login(token); // 身份验证成功 } catch (AuthenticationException e) { // 身份验证失败 } ``` 在这个示例中,首先创建了一个安全管理器并配置了自定义的Realm。然后,处理登录请求时,使用Shiro提供的`Subject`对象来进行身份验证。`UsernamePasswordToken`表示用户输入的用户名和密码,将其传递给`Subject`的`login`方法,即可进行身份验证。 如果身份验证成功,可以在应用程序中允许用户访问需要认证的资源。如果身份验证失败,则需要返回错误信息或者重新跳转到登录页面。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值