Springboot整合之Shiro和JWT技术实现无感刷新

一、Shiro简介

        Shiro是Java领域非常知名的认证( Authentication )与授权 ( Authorization )框架,用以替代JavaEE中的JAAS功能。相 较于其他认证与授权框架,Shiro设计的非常简单,所以广受好 评。任意JavaWeb项目都可以使用Shiro框架,而Spring Security 必须要使用在Spring项目中。所以Shiro的适用性更加广泛。像什么 JFinal Nutz Spring框架都可以使用Shiro,而不能使用Spring Security框架。

        1.1 什么是认证?

        认证就是要核验用户的身份,比如说通过用户名和密码来检验用户的身份。说简单一些,认证就是登陆。登陆之后Shiro要记录用户成功登陆的凭证。

        1.2 什么是授权?

        授权是比认证更加精细度的划分用户的行为。比如说一个教务管理系统中,学生登陆之后只能查 看信息,不能修改信息。而班主任就可以修改学生的信息。这就是利用授权来限定不同身份用户 的行为。

        1.3 Shiro靠什么做认证与授权的?

        Shiro可以利用 HttpSession 或者 Redis 存储用户的登陆凭证,以及角色或者身份信息。然后利 用过滤器(Filter),对每个Http请求过滤,检查请求对应的 HttpSession 或者 Redis 中的认证 与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。 也就是说,我们写用户登陆模块的时候,用户登陆成功之后,要调用Shiro保存登陆凭证。然后查 询用户的角色和权限,让Shiro存储起来。将来不管哪个方法需要登陆访问,或者拥有特定的角色跟权限才能访问,我们在方法前设置注解即可,非常简单。

二、JWT简介

        JWT(Json Web Token, 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标 准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服 务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用 于认证,也可被加密。

三: 实战

        3.1 创建JWT工具类

        JWT的 Token 要经过加密才能返回给客户端,包括客户端上传的 Token ,后端项目需要验证核 实。于是我们需要一个JWT工具类,用来 加密Token 验证Token 的有效性。

        3.1.1 导入依赖库
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-web</artifactId>
            <version>1.5.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.5.3</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.10.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
            <version>4.4.13</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        3.1.2 定义密钥和过期时间

        我建议大家把密钥和过期时间定义到SpringBoot 配置文件中,然后再值注入到 JavaBean 中,这样 维护起来比较方便。
emos:
  jwt:
    #密钥
    secret: abc123456
    #令牌过期时间(天)
    expire: 5
    #令牌缓存时间(天数)
    cache-expire: 10
        3.1.3 创建JWT工具类
package com.example.emos.wx.config.shiro;

import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
@Slf4j
public class JwtUtil {
    @Value("${emos.jwt.secret}")
    private String secret;

    @Value("${emos.jwt.expire}")
    private int expire;

    public String createToken(int userId){
        Date date=DateUtil.offset(new Date(), DateField.DAY_OF_YEAR,5);
        Algorithm algorithm=Algorithm.HMAC256(secret);
        JWTCreator.Builder builder= JWT.create();
        String token=builder.withClaim("userId",userId).withExpiresAt(date).sign(algorithm);
        return token;
    }

    public int getUserId(String token){
        DecodedJWT jwt=JWT.decode(token);
        int userId=jwt.getClaim("userId").asInt();
        return userId;
    }

    public void verifierToken(String token){
        Algorithm algorithm=Algorithm.HMAC256(secret);
        JWTVerifier verifier=JWT.require(algorithm).build();
        verifier.verify(token);
    }
}

        3.2 把令牌封装成认证对象

        我们通过JwtUtil类可以生成 Token ,这个 Token 我们是要返回给客户端的。接下来 我们要把 JWT Shiro框架 对接起来,这样 Shiro框架 就会拦截所有的Http请求,然后验证请求 提交的 Token 是否有效。

        客户端提交的Token 不能直接交给 Shiro 框架,需要先封装成 AuthenticationToken 类型的对象, 所以我们我们需要先创建 AuthenticationToken 的实现类。
        
package com.example.emos.wx.config.shiro;

import org.apache.shiro.authc.AuthenticationToken;

public class OAuth2Token implements AuthenticationToken {
    private String token;

    public OAuth2Token(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

        3.3 创建OAuth2Realm

        OAuth2Realm 类是 AuthorizingRealm 的实现类,我们要在这个实现类中定义认证和授权的方 法。因为认证与授权模块设计到用户模块和权限模块,现在我们还没有真正的开发业务模块,所 以我们这里先暂时定义空的认证去授权方法,把ShiroJWT整合起来,在后续章节我们再实现 认证与授权。

package com.example.emos.wx.config.shiro;

import com.example.emos.wx.db.pojo.TbUser;
import com.example.emos.wx.service.UserService;
import org.apache.shiro.authc.*;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Set;

@Component
public class OAuth2Realm extends AuthorizingRealm {
    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserService userService;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof OAuth2Token;
    }

    /**
     * 授权(验证权限时调用)
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection collection) {
        //TODO 查询用户的权限列表
        //TODO 把权限列表添加到info对象中
        TbUser user= (TbUser) collection.getPrimaryPrincipal();
        int userId=user.getId();
        Set<String> permsSet=userService.searchUserPermissions(userId);
        SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
        info.setStringPermissions(permsSet);
        return info;
    }

    /**
     * 认证(验证登录时调用)
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //TODO 从令牌中获取userId,然后检测该账户是否被冻结。
        String accessToken=(String)token.getPrincipal();
        int userId=jwtUtil.getUserId(accessToken);
        TbUser user=userService.searchById(userId);

        if(user==null){
            throw new LockedAccountException("账号已被锁定,请联系管理员");
        }
        //TODO 往info对象中添加用户信息、Token字符串
        SimpleAuthenticationInfo info=new SimpleAuthenticationInfo(user,accessToken,getName());
        return info;
    }
}

四:刷新令牌应该如何设计?  

        4.1 为什么要刷新Token的过期时间?

        我们在定义JwtUtil工具类的时候,生成的 Token 都有过期时间。那么问题来了,假设 Token 过 期时间为15天,用户在第14天的时候,还可以免登录正常访问系统。但是到了第15天,用户的 Token过期,于是用户需要重新登录系统。

        HttpSession 的过期时间比较优雅,默认为15分钟。如果用户连续使用系统,只要间隔时间不超 过15分钟,系统就不会销毁 HttpSession 对象。JWT的令牌过期时间能不能做成 HttpSession 那样超时时间,只要用户间隔操作时间不超过15天,系统就不需要用户重新登录系统。实现这种 效果的方案有两种: Token Token缓存 ,这里重点讲一下 Token 缓存方案。

        Token缓存方案是把 Token 缓存到 Redis ,然后设置 Redis 里面缓存的 Token 过期时间为正常
Token 1 , 然后根据情况刷新 Token 的过期时间。
        Token 失效,缓存也不存在的情况
        
        当第15 天,用户的 Token 失效以后,我们让 Shiro 程序到 Redis 查看是否存在缓存的 Token ,如 果这个 Token 不存在于 Redis 里面,就说明用户的操作间隔了 15 天,需要重新登录。
        Token 失效,但是缓存还存在的情况
        如果Redis中存在缓存的 Token ,说明当前 Token 失效后,间隔时间还没有超过15天,不应该让用户重新登录。所以要生成新的 Token 返回给客户端,并且把这个 Token 缓存到Redis里面,这种操作成为刷新 Token 过期时间。

        4.2 客户端如何更新令牌?

        在我们的方案中,服务端刷新 Token 过期时间,其实就是生成一个新的 Token 给客户端。那么 客户端怎么知道这次响应带回来的Token是更新过的呢?这个问题很容易解决。

        只要用户成功登陆系统,当后端服务器更新 Token 的时候,就在响应中添加 Token 。客户端那边判断每次Ajax响应里面是否包含 Token ,如果包含,就把 Token 保存起来就可以了。

        4.3 如何在响应中添加令牌?

        

       我们定义 OAuth2Filter 类拦截所有的 HTTP 请求,一方面它会把请求中的 Token 字符串提取出
来,封装成对象交给 Shiro 框架;另一方面,它会检查 Token 的有效性。如果 Token 过期,那么
会生成新的 Token ,分别存储在 ThreadLocalToken Redis 中。
        
        之所以要把 新令牌 保存到 ThreadLocalToken 里面,是因为要向 AOP 切面类 传递这个 新令牌 。 虽然 OAuth2Filter 中有 doFilterInternal() 方法,我们可以得到响应并且写入 新令牌 。但是
这个做非常麻烦,首先我们要通过 IO 流读取响应中的数据,然后还要把数据解析成 JSON 对象,
最后再放入这个新令牌。如果我们定义了 AOP 切面类 ,拦截所有 Web 方法返回的 R 对象 ,然后
R 对象 里面添加 新令牌 ,这多简单啊。但是 OAuth2Filter AOP 切面类之间没有调用关
系,所以我们很难把 新令牌 传给 AOP 切面类
        
        这里我想到了 ThreadLocal ,只要是同一个线程,往 ThreadLocal 里面写入数据和读取数据是 完全相同的。在Web 项目中,从 OAuth2Filter AOP 切面类 ,都是由同一个线程来执行的,中 途不会更换线程。所以我们可以放心的把新令牌保存都在 ThreadLocal 里面, AOP 切面类 可以成 功的取出新令牌,然后往 R 对象 里面添加新令牌即可。
        
ThreadLocalToken 是我自定义的类,里面包含了 ThreadLocal 类型的变量,可以用来保存线程
安全的数据,而且避免了使用线程锁。

        4.4 创建ThreadLocalToken

package com.example.emos.wx.config.shiro;

import org.springframework.stereotype.Component;

@Component
public class ThreadLocalToken {
    private ThreadLocal<String> local=new ThreadLocal<>();

    public void setToken(String token){
        local.set(token);
    }

    public String getToken(){
        return local.get();
    }

    public void clear(){
        local.remove();
    }
}

        4.5 创建OAuth2Filter

        注意事项:

        因为在 OAuth2Filter 类中要读写 ThreadLocal 中的数据,所以 OAuth2Filter 类必 须要设置成多例的,否则 ThreadLocal 将无法使用。

package com.example.emos.wx.config.shiro;

import cn.hutool.core.util.StrUtil;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import org.apache.http.HttpStatus;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Scope;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Component
@Scope("prototype")
public class OAuth2Filter extends AuthenticatingFilter {
    @Autowired
    private ThreadLocalToken threadLocalToken;

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

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
    * 拦截请求之后,用于把令牌字符串封装成令牌对象
    */
    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req= (HttpServletRequest) request;
        String token=getRequestToken(req);
        if(StrUtil.isBlank(token)){
            return null;
        }
        return new OAuth2Token(token);
    }

    /**
    * 拦截请求,判断请求是否需要被Shiro处理
    */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // Ajax提交application/json数据的时候,会先发出Options请求
        HttpServletRequest req= (HttpServletRequest) request;
        if(req.getMethod().equals(RequestMethod.OPTIONS.name())){
            return true;
        }
        return false;
    }

    /**
    *     该方法用于处理所有应该被Shiro处理的请求
    */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req= (HttpServletRequest) request;
        HttpServletResponse resp= (HttpServletResponse) response;
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        resp.setHeader("Access-Control-Allow-Credentials", "true");
        resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));

        threadLocalToken.clear();
        //获取请求token,如果token不存在,直接返回401
        String token=getRequestToken(req);
        if(StrUtil.isBlank(token)){
            resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
            resp.getWriter().print("无效的令牌");
            return false;
        }
        try{
            jwtUtil.verifierToken(token);
        }catch (TokenExpiredException e){
            //客户端令牌过期,查询Redis中是否存在令牌,如果存在令牌就重新生成一个令牌给客户端
            if(redisTemplate.hasKey(token)){
                redisTemplate.delete(token);
                int userId=jwtUtil.getUserId(token);
                token=jwtUtil.createToken(userId);
                redisTemplate.opsForValue().set(token,userId+"",cacheExpire, TimeUnit.DAYS);
                //把新令牌绑定到线程
                threadLocalToken.setToken(token);
            }
            else{
                //如果Redis不存在令牌,让用户重新登录
                resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
                resp.getWriter().print("令牌已过期");
                return false;
            }
        }catch (Exception e){
            resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
            resp.getWriter().print("无效的令牌");
            return false;
        }
        boolean bool=executeLogin(request,response);
        return bool;
    }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        HttpServletRequest req= (HttpServletRequest) request;
        HttpServletResponse resp= (HttpServletResponse) response;
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        resp.setHeader("Access-Control-Allow-Credentials", "true");
        resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
        resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
        try{
            resp.getWriter().print(e.getMessage());
        }catch (Exception exception){

        }

        return false;
    }

    @Override
    public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
        HttpServletRequest req= (HttpServletRequest) request;
        HttpServletResponse resp= (HttpServletResponse) response;
        resp.setContentType("text/html");
        resp.setCharacterEncoding("UTF-8");
        resp.setHeader("Access-Control-Allow-Credentials", "true");
        resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
        super.doFilterInternal(request, response, chain);

    }
    /**
    * 获取请求头里面的token
    */
    private String getRequestToken(HttpServletRequest request){
        String token=request.getHeader("token");
        if(StrUtil.isBlank(token)){
            token=request.getParameter("token");
        }
        return token;
    }
}

        4.6 创建ShiroConfig

        我们要创建的 ShiroConfig 类,是用来把 OAuth2Filter OAuth2Realm 配置到Shiro框架,这 样我们辛苦搭建的Shiro+JWT才算生效。

        

package com.example.emos.wx.config.shiro;

import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Bean("securityManager")
    public SecurityManager securityManager(OAuth2Realm realm){
        DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
        securityManager.setRealm(realm);
        securityManager.setRememberMeManager(null);
        return securityManager;
    }

    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager,OAuth2Filter filter){
        ShiroFilterFactoryBean shiroFilter=new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);

        Map<String , Filter> map=new HashMap<>();
        map.put("oauth2",filter);
        shiroFilter.setFilters(map);

        Map<String,String> filterMap=new LinkedHashMap<>();
        filterMap.put("/webjars/**", "anon");
        filterMap.put("/druid/**", "anon");
        filterMap.put("/app/**", "anon");
        filterMap.put("/sys/login", "anon");
        filterMap.put("/swagger/**", "anon");
        filterMap.put("/v2/api-docs", "anon");
        filterMap.put("/swagger-ui.html", "anon");
        filterMap.put("/swagger-resources/**", "anon");
        filterMap.put("/captcha.jpg", "anon");
        filterMap.put("/user/register", "anon");
        filterMap.put("/user/login", "anon");
        filterMap.put("/test/**", "anon");
        filterMap.put("/meeting/recieveNotify", "anon");
        filterMap.put("/**", "oauth2");

        shiroFilter.setFilterChainDefinitionMap(filterMap);

        return shiroFilter;

    }

    @Bean("lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
        return new LifecycleBeanPostProcessor();
    }

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

        4.7 利用AOP,把更新的令牌返回给客户端

        我们在写 OAuth2Filter 的时候,把更新后的令牌写到 ThreadLocalToken 里面的 ThreadLocal 。那么这个小节,我们要创建 AOP切面类 ,拦截所有Web方法的返回值,在返回 的 R对象 中添加更新后的令牌。

package com.example.emos.wx.aop;

import com.example.emos.wx.common.util.R;
import com.example.emos.wx.config.shiro.ThreadLocalToken;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TokenAspect {
    @Autowired
    private ThreadLocalToken threadLocalToken;

    @Pointcut("execution(public * com.example.emos.wx.controller.*.*(..))")
    public void aspect(){

    }

    @Around("aspect()")
    public Object around(ProceedingJoinPoint point) throws Throwable{
        R r=(R)point.proceed();
        String token=threadLocalToken.getToken();
        if(token!=null){
            r.put("token",token);
            threadLocalToken.clear();
        }
        return r;
    }
}

   

猜你喜欢

转载自blog.csdn.net/qq_67801847/article/details/133181982