shiro、jwt搭建系统(二)

一、前言

因为最近家里面有一点事情,所以请假了四天,现在准备继续完成**shiro、jwt搭建系统(二)**的内容,下面我就根据(一)中的内容,结合我所学习到的知识,记录搭建系统的基本内容。

我们首先说一说我们系统的基本流程

  • JwtFilter 主要用于拦截请求、校验请求体中是否存在token、校验token是否有效(是否过期、是否合法等等)、生成新的令牌等等操作
  • 根据JwtFilter拦截器的结果(是否过期),如果过期,我们新生成新的令牌,并且将它存储到Redis和ThreadLocalToken中
  • 每次请求(Controller)TokenAspect都需要判断ThreadLocalToken中是否有存储的Token,如果存在令牌,说明是重新新生成的令牌,将它返回给用户存储,并且清空ThreadLocalToken存储的令牌

写到这里,我们需要具体的讲一下登录流程,因为我们使用的是jwt来进行验证,后端关闭session,所以每一次请求都需要携带令牌

  • 传统的验证,我们向后端提交username password,交给Shiro进行验证(subject.login(usernamePasswordToken)),如果验证成功将令牌进行持久化并且返回给用户,下一次登录就不需要再经过自定义的Realm
  • 如果使用jwt shiro的方式,我们无需将登录的请求交给UserRealm处理,我们可以直接通过userId查询用户信息,获取到信息后直接返回令牌,但是每一次访问受限的资源,都需要通过JwtFilter然后将token提交到UserRealm进行校验

二、用ThreadLocal来存储Token

根据上面的流程图,其实我们也可以在JwtFilter拦截时,将新的token返回给用户,但是这样就会涉及到IO流解析、JSON字符串解析的问题,所以我们使用ThreadLocalToken来存储Token,每一次访问Controller都可以进行AOP拦截,将新生成的令牌,放入返回体中,这样就比较优雅

创建ThreadLocalToken

/**
 * 自定义ThreadLocalToken
 */
@Component
public class ThreadLocalToken {

    private ThreadLocal<String> threadLocal = new ThreadLocal<>();

    /**
     * 设置token
     * @param token
     */
    public void setToken(String token){
        threadLocal.set(token);
    }

    /**
     * 获取token
     * @return
     */
    public String getToken(){
        return (String) threadLocal.get();
    }

    /**
     * 移除内容
     */
    public void remove(){
        threadLocal.remove();
    }

}
复制代码

三、创建JwtFilter拦截请求

主要用于拦截请求:

  • 如果token过期,判断redis中的token是否有效,如果有效,生成新的令牌,如果也是同样的无效,就直接提示用户重新登录
  • 判断请求是否需要交给shiro进行处理,如果是OPTIONS请求方法,就直接放行,不需要shiro进行处理
/**
 * 自定义过滤器
 * TODO: 用于拦截请求、校验令牌合法性、生成新的令牌
 */
@Component
@Scope("prototype")
public class JwtFilter extends AuthenticatingFilter {

    @Autowired
    private ThreadLocalToken threadLocalToken;

    @Value("${gofly.jwt.cache-expire}")
    private Integer cacheExpire;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 创建令牌
     * @param servletRequest
     * @param servletResponse
     * @return
     * @throws Exception
     */
    @Override
    protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        String token = getRequestToken((HttpServletRequest) servletRequest);
        if(StrUtil.hasBlank(token)){
            return null;
        }
        return new ShiroToken(token);
    }

    /**
     * 判断请求是否需要shiro进行处理
     * @param servletRequest
     * @param servletResponse
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) {
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        //判断请求方法是否为OPTIONS
        //因为ajax发起的请求,首先会发起OPTIONS请求,如果为OPTIONS请求,直接放行,如果不是,就交给Shiro进行处理
        if(request.getMethod().equals(RequestMethod.OPTIONS.name())){
            return true;
        }
       return false;
    }

    /**
     * 用于所有需要shiro处理的请求
     * @param servletRequest
     * @param servletResponse
     * @return
     * @throws Exception
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //将响应头设置为application/json格式,返回值为json字符串
        response.setHeader("Content-Type", "application/json;charset=UTF-8");
        //允许跨域请求
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));

        //清空ThreadLocal存储的令牌
        threadLocalToken.remove();

        //校验令牌是否存在请求头或者请求参数中
        String token = getRequestToken(request);

        if(StrUtil.hasBlank(token)){
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            String message = JSONUtil.toJsonStr(R.error("令牌无效").put("code", HttpStatus.HTTP_UNAUTHORIZED));
            response.getWriter().write(message);
            return false;
        }

        //验证令牌合法性
        try{
            JwtUtil.verifierToken(token);
        }catch (TokenExpiredException e){ //令牌过期
            //1、判断Redis中的token是否过期,因为我们在配置的时候Redis存储的token过期时间是Jwt token的两倍,所以令牌过期,不一定redis存储的token也过期
            if(redisTemplate.hasKey(token)){
                //2、如果redis中存储的令牌未过期,我们就删除Redis存储的令牌,重新生成新的令牌
                redisTemplate.delete(token);
                int userId = JwtUtil.getUserId(token);//获取用户id
                String newToken = JwtUtil.createToken(userId); //生成新的令牌
                redisTemplate.opsForValue().set(newToken,userId+"",cacheExpire, TimeUnit.DAYS); //将新的令牌存储到Redis中
                threadLocalToken.setToken(newToken);//将新的令牌存储到ThreadLocal中
            }else{
                //3、还有一种情况,jwt token过期,redis中的token也过期,需要提示用户重新登录
                response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
                String message = JSONUtil.toJsonStr(R.error("令牌过期请重新登录").put("code", HttpStatus.HTTP_UNAUTHORIZED));
                response.getWriter().write(message);
                return false;
            }
        }catch (JWTDecodeException e){ //Jwt token解析失败
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            String message = JSONUtil.toJsonStr(R.error("令牌无效").put("code", HttpStatus.HTTP_UNAUTHORIZED));
            response.getWriter().write(message);
            return false;
        }

        /**
         * 执行登录流程
         * 也就是提交到{@link UserRealm}中进行校验
         */
        boolean b = executeLogin(request, response);
        return b;
    }

    /**
     * 登录失败,调用
     * @param token
     * @param e
     * @param servletRequest
     * @param servletResponse
     * @return
     */
    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest servletRequest, ServletResponse servletResponse) {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        try {
            /**
             * 这里接收的是{@link UserRealm}返回的错误信息
             */
            response.getWriter().print(e.getMessage());
        } catch (IOException exception) {

        }
        return false;
    }

    /**
     * 获取请求体中的令牌
     * @param request
     * @return
     */
    private String getRequestToken(HttpServletRequest request){
        String token = null;
        //请求头中获取令牌
        token = request.getHeader("token");
        //如果请求头中没有令牌,就从请求参数中获取
        if(StrUtil.hasBlank(token)){
            token = request.getParameter("token");
        }
        return token;
    }

    /**
     * 将自定义的过滤器,添加到过滤器链中
     * @param request
     * @param response
     * @param chain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
        super.doFilterInternal(request, response, chain);
    }
}
复制代码

四、ShiroConfig-Shiro配置类

/**
 * Shiro配置类
 */
@Configuration
public class ShiroConfig {

    /**
     * 安全管理器
     * @return
     */
    @Bean("securityManager")
    public SecurityManager securityManager(UserRealm userRealm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(userRealm);
        securityManager.setRememberMeManager(null);
        return securityManager;
    }

    /**
     * shiro过滤器链
     * @return
     */
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager,JwtFilter jwtFilter){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        //自定义过滤器
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt",jwtFilter);
        shiroFilterFactoryBean.setFilters(filters);

        Map<String,String> filterMap = new LinkedHashMap<>();
        filterMap.put("/**/doc.html","anon");
        filterMap.put("/pub/**","anon");
      	//除以上连接,都需要被jwtFilter拦截
        filterMap.put("/**","jwt");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
        return shiroFilterFactoryBean;
    }


    /**
     * shiro生命周期
     * @return
     */
    @Bean("lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}
复制代码

四、创建TokenAspect-用于返回最新的令牌

我们采用的是aop技术,将controller作为切点,主要完成以下工作:

  • 通过环绕通知获取返回值
  • 获取ThreadLocalToken中存储的token,如果存在,说明令牌被更新,如果不存在说明没有更新令牌,直接返回
/**
 * 自定义Aop
 */
public class TokenAspect {

    @Autowired
    private ThreadLocalToken threadLocalToken;

    @Pointcut("execution(public * com.yangzinan.wx.goflywxapi.controller.*.*(..))")
    public void aspect(){

    }

    @Around("aspect()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        R r = (R) joinPoint.proceed();//获取返回值
        String token = threadLocalToken.getToken(); //获取token
        //如果不为空,将token添加到返回体中
        if(!StrUtil.hasBlank(token)){
            r.put("token",token);
            threadLocalToken.remove();
        }
      //如果为空,说明没有可更新的令牌,直接返回响应内容
        return r;
    }

}
复制代码

五、完善UserRealm

上一章节,我们在写UserRealm只是大概的搭建了一个结构,没有具体的实现认证、授权的内容,我们这一章节把它完善

/**
 * 自定义Realm
 */
@Component
public class UserRealm extends AuthorizingRealm {

    /**
     * 自定义token认证对象之后,必须重写该方法,不然会报错
     * @param token
     * @return
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof ShiroToken;
    }

    /**
     * 授权
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        /**
         * 1.TODO:通过token获取用户id
         *              ---> 因为我们在ShiroToken中已经实现了getPrincipal方法,返回的是token,所以 
                             principalCollection.getPrimaryPrincipal()获取的就是token
         *              ---> 我们只需要调用JWTUtil工具类获取userId即可
         * 2.TODO: 通过userId获取用户权限信息
         *              ---> 将权限注册到SimpleAuthorizationInfo中
         */
        return info;
    }

    /**
     * 认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo();
        /**
         * TODO: 认证操作
         */
        return info;
    }
}
复制代码

完善UserRealm,因为我们这里只使用到认证,所以我们只实现认证操作:

/**
 * 认证
 * @param authenticationToken
 * @return
 * @throws AuthenticationException
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    log.info("用户认证操作");
    //获取令牌
    String token = (String) authenticationToken.getPrincipal();
    //通过jwtUtil解析用户id
    int userId = jwtUtil.getUserId(token);
    //查询用户信息
    TbUser tbUser = userService.searchById(userId);
    if(null == tbUser){
        throw new LockedAccountException("账号已被锁定,请联系管理员");
    }
    SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(tbUser,token,getName());
    return info;
}
复制代码

六、Dao Service Controller

mapper xml

<!--查询用户信息-->
<select id="searchById" parameterType="int" resultType="com.yangzinan.wx.goflywxapi.model.TbUser">
  SELECT
    id, open_id, nickname, photo, name, sex, tel, role, root, dept_id, status, create_time
  FROM tb_user WHERE id=#{userId} AND status = 1
</select>
复制代码

Dao

@Mapper
public interface TbUserDao {
    int deleteByPrimaryKey(Integer id);

    int insert(TbUser record);

    int insertSelective(TbUser record);

    TbUser selectByPrimaryKey(Integer id);

    int updateByPrimaryKeySelective(TbUser record);

    int updateByPrimaryKey(TbUser record);
		//查询用户信息
    TbUser searchById(Integer userId);
}
复制代码

service

@Service("iTbUserService")
public class TbUserServiceImpl implements ITbUserService {

    @Autowired
    private TbUserDao userDao;

    /**
     * 通过id查询用户信息
     * @param userId 用户id
     * @return
     */
    @Override
    public TbUser searchById(Integer userId) {
        TbUser user = userDao.searchById(userId);
        return user;
    }
}
复制代码

controller

@RestController
@RequestMapping("/pub")
public class PubController {

    @Autowired
    private JwtUtil jwtUtil;

    @GetMapping("/login")
    public R login(){
        return R.error(HttpStatus.HTTP_UNAUTHORIZED,"用户未登录,请登录");
    }

    @PostMapping("/doLogin")
    public R login(Integer userId){
        String token = jwtUtil.createToken(userId);
        return R.ok("登录成功").put("token",token);
    }

}
复制代码

因为今天中午下班回来,吃好饭已经一点多了,抽出一点时间把它写完,可能不够完善,我会在后面进行补充,谢谢!