jwt和shiro

发布时间 2023-10-06 19:35:39作者: sgj191024

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

Shiro可以利用HttpSession或者Redis存储用户的登陆凭证,以及角色或者身份信息。然后利用过滤器(Filter),对每个Http请求过滤,检查请求对应的HttpSession或者Redis中的认证与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。

也就是说,我们写用户登陆模块的时候,用户登陆成功之后,要调用Shiro保存登陆凭证。然后查询用户的角色和权限,让Shiro存储起来。将来不管哪个方法需要登陆访问,或者拥有特定的角色跟权限才能访问,我们在方法前设置注解即可,非常简单

WT可以用在单点登录的系统中

传统的JavaWeb项目,利用HttpSession保存用户的登陆凭证。如果后端系统采用了负载均衡设计,当用户在A节点成功登陆,那么登陆凭证保存在A节点的HttpSession中。如果用户下一个请求被负载均衡到了B节点,因为B节点上面没有用户的登陆凭证,所以需要用户重新登录,这个体验太糟糕了。

如果用户的登陆凭证经过加密(Token)保存在客户端,客户端每次提交请求的时候,把Token上传给后端服务器节点。即便后端项目使用了负载均衡,每个后端节点接收到客户端上传的Token之后,经过检测,是有效的Token,于是就断定用户已经成功登陆,接下来就可以提供后端服务了

JWT兼容更多的客户端

传统的HttpSession依靠浏览器的Cookie存放SessionId,所以要求客户端必须是浏览器。现在的JavaWeb系统,客户端可以是浏览器、APP、小程序,以及物联网设备。为了让这些设备都能访问到JavaWeb项目,就必须要引入JWT技术。JWT的Token是纯字符串,至于客户端怎么保存,没有具体要求。只要客户端发起请求的时候,附带上Token即可。所以像物联网设备,我们可以用SQLite存储Token数据。

<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>

  

 

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 com.example.emos.wx.exception.EmosException;
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, expire).toJdkDate();
        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) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("userId").asInt();
        } catch (Exception e) {
            throw new EmosException("令牌无效");
        }
    }

    public void verifierToken(String token) {
        Algorithm algorithm = Algorithm.HMAC256(secret); //创建加密算法对象
        JWTVerifier verifier = JWT.require(algorithm).build();
        verifier.verify(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;
    }
}

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

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

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;


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

    /**
     * 授权(验证权限时调用)
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        //TODO 查询用户的权限列表
        //TODO 把权限列表添加到info对象中
        return info;
    }

    /**
     * 认证(登录时调用)
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //TODO 从令牌中获取userId,然后检测该账户是否被冻结。
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo();
        //TODO 往info对象中添加用户信息、Token字符串
        return info;
    }
}

  

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

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

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

二、客户端如何更新令牌?

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

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

 

三、如何在响应中添加令牌?

我们定义OAuth2Filter类拦截所有的HTTP请求,一方面它会把请求中的Token字符串提取出来,封装成对象交给Shiro框架;另一方面,它会检查Token的有效性。如果Token过期,那么会生成新的Token,分别存储在ThreadLocalTokenRedis中。

之所以要把新令牌保存到ThreadLocalToken里面,是因为要向AOP切面类传递这个新令牌。虽然OAuth2Filter中有doFilterInternal()方法,我们可以得到响应并且写入新令牌。但是这个做非常麻烦,首先我们要通过IO流读取响应中的数据,然后还要把数据解析成JSON对象,最后再放入这个新令牌。如果我们定义了AOP切面类,拦截所有Web方法返回的R对象,然后在R对象里面添加新令牌,这多简单啊。但是OAuth2FilterAOP切面类之间没有调用关系,所以我们很难把新令牌传给AOP切面类

这里我想到了ThreadLocal,只要是同一个线程,往ThreadLocal里面写入数据和读取数据是完全相同的。在Web项目中,从OAuth2FilterAOP切面类,都是由同一个线程来执行的,中途不会更换线程。所以我们可以放心的把新令牌保存都在ThreadLocal里面,AOP切面类可以成功的取出新令牌,然后往R对象里面添加新令牌即可。

ThreadLocalToken是我自定义的类,里面包含了ThreadLocal类型的变量,可以用来保存线程安全的数据,而且避免了使用线程锁。

 

ThreadLocalToken

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

import org.springframework.stereotype.Component;

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

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

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

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

  

注意事项:

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

 

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

import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import org.apache.commons.lang3.StringUtils;
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 {
        //获取请求token
        String token = getRequestToken((HttpServletRequest) request);

        if (StringUtils.isBlank(token)) {
            return null;
        }

        return new OAuth2Token(token);
    }

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

    /**
	 * 该方法用于处理所有应该被Shiro处理的请求
	 */
    @Override
    protected boolean onAccessDenied(ServletRequest request, 
		ServletResponse response) throws Exception {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;

		resp.setHeader("Content-Type", "text/html;charset=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((HttpServletRequest) request);
        if (StringUtils.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);  //生成新的令牌
                //把新的令牌保存到Redis中
                redisTemplate.opsForValue().set(token, userId + "", cacheExpire, TimeUnit.DAYS);
                //把新令牌绑定到线程
                threadLocalToken.setToken(token);
            } else {
                //如果Redis不存在令牌,让用户重新登录
                resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
                resp.getWriter().print("令牌已经过期");
                return false;
            }

        } catch (JWTDecodeException 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.setStatus(HttpStatus.SC_UNAUTHORIZED);
        resp.setContentType("application/json;charset=utf-8");
        resp.setHeader("Access-Control-Allow-Credentials", "true");
        resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
        try {
            resp.getWriter().print(e.getMessage());
        } catch (IOException exception) {

        }
        return false;
    }

    /**
     * 获取请求头里面的token
     */
    private String getRequestToken(HttpServletRequest httpRequest) {
        //从header中获取token
        String token = httpRequest.getHeader("token");

        //如果header中不存在token,则从参数中获取token
        if (StringUtils.isBlank(token)) {
            token = httpRequest.getParameter("token");
        }
        return token;

    }

    @Override
    public void doFilterInternal(ServletRequest request, 
		ServletResponse response, FilterChain chain) throws ServletException, IOException {
        super.doFilterInternal(request, response, chain);
    }
}

  我们要创建的ShiroConfig类,是用来把OAuth2FilterOAuth2Realm配置到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 oAuth2Realm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(oAuth2Realm);
      // token保存在客户端,不保存在服务端 securityManager.setRememberMeManager(null); return securityManager; } @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager,OAuth2Filter oAuth2Filter) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); //oauth过滤 Map<String, Filter> filters = new HashMap<>(); filters.put("oauth2", oAuth2Filter); shiroFilter.setFilters(filters); Map<String, String> filterMap = new LinkedHashMap<>();
      // anon的过滤掉,其他的交给shiro处理 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("/**", "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; } }

  我们要创建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();
        //如果ThreadLocal中存在Token,说明是更新的Token
        if (token != null) {
            r.put("token", token); //往响应中放置Token
            threadLocalToken.clear();
        }
        return r;
    }
}

  精简异常:

package com.example.emos.wx.config;

import com.example.emos.wx.exception.EmosException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {

    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public String validExceptionHandler(Exception e) {
        log.error("执行异常",e);
        if (e instanceof MethodArgumentNotValidException) {
            MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
            //将错误信息返回给前台
            return exception.getBindingResult().getFieldError().getDefaultMessage();
        }
        else if(e instanceof EmosException){
            EmosException exception=(EmosException)e;
            return exception.getMsg();
        }
        else if(e instanceof UnauthorizedException){
            return "你不具有相关权限";
        }
        else {
            return "后端执行异常";
        }

    }

}