当前位置: 首页 > article >正文

开放标准(RFC 7519):JSON Web Token (JWT)

开放标准:JSON Web Token

  • 前言
    • 基本使用
    • 整合Shiro
      • 登录
      • 自定义JWT认证过滤器
      • 配置Config
      • 自定义凭证匹配规则
      • 接口验证
      • 权限控制
      • 禁用session
      • 缓存的使用
      • 登录退出
      • 单用户登录
      • Token刷新
        • 双Token方案
        • 单Token方案

前言

JSON Web TokenJWT) 是一种开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于将信息作为 JSON 对象在各方之间安全地传输。此信息是经过数字签名的,因此可以验证和信任。可以使用密钥(使用 HMAC 算法)或使用 RSAECDSA 的公钥/私钥对对 JWT 进行签名。

以下是 JSON Web Token 有用的一些情况:

  • 授权:这是使用 JWT 的最常见场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销小,并且能够轻松地跨不同域使用。
  • 信息交换JSON Web Token 是在各方之间安全地传输信息的好方法。由于 JWT 可以签名(例如,使用公钥/私钥对),因此您可以确保发件人是他们所声称的身份。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否未被篡改。

JSON Web Token由三个部分组成,由点 ( . ) 分隔,它们是:

  • Header 页眉:标头通常由两部分组成:令牌的类型(JWT)和签名算法(HMAC SHA256RSA)。
{
  "alg": "HS256",
  "typ": "JWT"
}

Base64对这个JSON编码就得到JWT的第一部分。

  • Payload 有效载荷:令牌的第二部分是有效负载,其中包含声明。声明是关于实体(通常是用户)和其他数据的声明。 有三种类型的声明:registeredpublicprivate

    1. Registered claims:这些是一组预定义的声明,不是强制性的,但建议使用,以提供一组有用的、可互操作的声明。比如:iss(签发者)、exp(过期时间)、sub(主题)、aud(接收者)、iat(签发时间)、nbf(在此之前不可用)等。

    2. Public claims:这些声明可以由用户随意定义。但不建议添加敏感信息,因为该部分在客户端可解密。

    3. Private claims:提供者和消费者所共同定义的声明,一般不建议存放敏感信息

一个示例有效Payload如下(并不需要三个声明都设置):

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Payload进行Base64编码就得到JWT的第二部分。

不要将机密信息放在 JWTpayloadheader 元素中,除非它们是加密的。

  • Signature 签名:要创建签名部分,您必须获取编码的Header、编码的Payload、密钥、标头中指定的算法,并对其进行签名。

例如,如果您想使用 HMAC SHA256 算法,将按以下方式创建签名:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
  1. Header页眉:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  2. Payload 有效载荷:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
  3. 通过HMAC SHA256 算法得到:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  4. 最后,我们将上述的 3 个部分的字符串通过 “.” 进行拼接得到完整JWT

在这里插入图片描述

每当用户想要访问受保护的路由时,它都应该发送 JWT,通常在 Authorization 标头中使用 Bearer 模式。因此,标头的内容应如下所示。

Authorization: Bearer < token >

在某些情况下,这可以是无状态授权机制。服务器的受保护路由将检查 Authorization 标头中的有效 JWT,如果存在,将允许用户访问受保护的资源。跨域资源共享 (CORS) 不会成为问题,因为它不使用 Cookie

请注意,如果您通过 HTTP 标头发送 JWT 令牌,则应尽量防止它们变得太大。某些服务器不接受超过 8 KB 的标头。

下图显示了如何获取 JWT 并用于访问 API 或资源:

在这里插入图片描述

  1. 应用程序或客户端向授权服务器请求授权。这是通过不同的授权流之一执行的。例如,典型的符合 OpenID ConnectWeb 应用程序将使用授权代码流通过 /oauth/authorize 终端节点。
  2. 授予授权后,授权服务器将向应用程序返回访问令牌。
  3. 应用程序使用访问令牌访问受保护的资源(如 API)。

JSON 解析器在大多数编程语言中都很常见,因为它们直接映射到对象。相反,XML 没有自然的文档到对象的映射。这使得使用 JWT 比使用 SAML 断言更容易。

关于使用情况,JWT 用于 Internet 规模。这突出了在多个平台(尤其是移动平台)上对 JSON Web Token进行客户端处理的便利性。

基本使用

引入依赖的方式有很多种:

  • 使用jjwt
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.12.6</version>
</dependency>

该依赖包含了 jjwt-apijjwt-impljjwt-jackson 三个模块的所有功能。

  • 使用java-jwt
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

根据具体的项目需求和偏好进行选择(官网上好像是auth0)。

  • 创建 JWT

要创建一个 JWT,你需要使用 JWT.create() 方法,然后添加必要的声明(claims),最后使用你的密钥和算法进行签名。示例代码如下:

public class Test {
    public static void main(String[] args) {
        // 创建 JWT
        String token = JWT.create()
//                .withHeader(map) 自定义Header,可以传map或json
                .withIssuer("auth0")         // 发行人
                .withSubject("1234567890")    // 主题
                .withAudience("app_audience") // 观众
                .withIssuedAt(new Date())     // 发行时间
                .withExpiresAt(new Date(System.currentTimeMillis() + 3600 * 1000)) // 过期时间(1小时后)
//                .withPayload(map) 自定义payload,可以传map或json
//                .withClaim("test", "test") 自定义payload,指定name和value
                .sign(Algorithm.HMAC256("123345")); // 使用 HMAC256 算法和密钥进行签名,默认用该参数的加密类型当作Header
        System.out.println(token);
        /** Output
         * eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
         * .eyJpc3MiOiJhdXRoMCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJhcHBfYXVkaWVuY2UiLCJpYXQiOjE3MzYxMjc0ODksImV4cCI6MTczNjEzMTA4OX0
         * .O4VD5DOn77tzPUZabPhPhkFKW9vS31dTpTAOPmkRv2o
         */
    }
}

然后去官网可以查看解密的数据,如图所示:

在这里插入图片描述

  • 验证 JWT

验证一个 JWT,我们需要创建一个 JWTVerifier 实例,该实例定义了验证 JWT 时所需的条件(如算法和密钥、发行人、观众等)。然后,我们使用 verify() 方法对 JWT 进行验证,并返回一个 DecodedJWT 实例,该实例包含了 JWT 中的所有声明。

public class Test {
    public static void main(String[] args) {
        // 验证 JWT
        JWTVerifier verifier = JWT.require(Algorithm.HMAC256("123456"))
                .build(); // 可重用验证器实例
        DecodedJWT jwt = verifier.verify("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
                ".eyJpc3MiOiJhdXRoMCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJhcHBfYXVkaWVuY2UiLCJpYXQiOjE3MzYxMjc0ODksImV4cCI6MTczNjEzMTA4OX0" +
                ".O4VD5DOn77tzPUZabPhPhkFKW9vS31dTpTAOPmkRv2o");
        System.out.println("Verified Token: " + jwt);
        System.out.println("Verified Token: " + jwt.getId());
        System.out.println("Verified Token: " + jwt.getIssuer());
        /** Output
         *  Verified Token: com.auth0.jwt.JWTDecoder@2aece37d
         *  Verified Token: null
         *  Verified Token: auth0
         */
    }
}

如果验证过程中出现密钥不匹配或者token过期都会抛出异常,如图所示:

在这里插入图片描述
然后整理成Util工具类,方便调用,示例代码如下:

public class JWTUtil {
    // 过期时间一天
    private static final long EXPIRE_TIME = 24*60*60*1000;
    /**
     * 生成token签名
     *
     * @param username 用户名
     * @param secret 密码
     * @return token字符串
     */
    public static String sign(String username, String secret) {
        String token = JWT.create()
                .withClaim("username", username)
                .withIssuedAt(new Date())     // 发行时间
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME))
                .sign(Algorithm.HMAC256(secret));
        return token;
    }

    /**
     * 校验token是否有效
     *
     * @param token    生成token
     * @param username 用户名
     * @param secret 密码
     * @return
     */
    public static boolean verify(String token, String username, String secret) {
        try {
            // 保证荷载参数一致
            JWTVerifier build = JWT.require(Algorithm.HMAC256(secret)).withClaim("username", username).build();
            DecodedJWT decodedJWT = build.verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace(); // 方便查看错误信息
            return false;
        }
    }

    /**
     * 获取token种的用户名,过期了也可以获取
     * @param token
     * @return
     */
    public static String getUsername(String token){
        try {
            return JWT.decode(token).getClaim("username").asString();
        } catch (Exception e) {
            return null;
        }
    }

    public static void main(String[] args) {
        String admin = sign("admin", "123345");
        System.out.println(verify(admin,"admin","123345"));
        System.out.println(getUsername(admin));
    }
}

有很多的案例在生成Token时,secret参数是固定密钥。

整合Shiro

ShiroJWT 整合可以实现无状态认证和授权,适用于分布式系统和微服务架构。

登录

以登录为切入点,创建一个登录接口,用于生成JWT并返回给客户端。

一般使用UsernamePasswordToken类作为登录参数,我们需要创建一个类似的JWT类(实现AuthenticationToken接口即可),示例代码如下:

public class JWTToken implements AuthenticationToken {
    private String token;

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

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

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

建立一个用于授权时保存信息类,方便访问当前登录的用户信息,示例代码如下:

public class UserPrincipal implements Serializable {
    private User user;
    private String token;

    public UserPrincipal(User user, String token) {
        this.user = user;
        this.token = token;
    }
    // getter and setter ......
}

然后在登陆时,校验用户名和密码后生成对应的Token,使用JWT Token作为登录参数,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password) {
        // 校验用户
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        SimpleHash hash = new SimpleHash("MD5", password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        JWTToken jwtToken = new JWTToken(sign);
        // 对用户信息进行身份认证
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(jwtToken);
        } catch (AuthenticationException e) {
            e.printStackTrace();
        }
        // 返回响应参数
        response.setHeader("Authorization", sign);
        return ResponseEntity.ok(sign);
    }
    @GetMapping("/main")
    public ResponseEntity main() {
        UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();
        User user = userPrincipal.getUser();
        return ResponseEntity.ok("成功");
    }
}

Realm中进行授权操作(授权时需要在查询一次用户信息进行保存),示例代码如下:

@Component
public class UserRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private PermissionService permissionService;

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

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        JWTToken token = (JWTToken) authenticationToken;
        String username = JWTUtil.getUsername((String) token.getPrincipal());
        if (username == null) {
            throw new UnknownAccountException("账号不存在");
        }
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            throw new UnknownAccountException("账号不存在");
        }
        // 密码验证使用token
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user, (String) token.getPrincipal()), user.getPassword(), ByteSource.Util.bytes(username),  getName());
        return simpleAuthenticationInfo;
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        UserPrincipal userPrincipal = (UserPrincipal) principalCollection.getPrimaryPrincipal();
        User user = userPrincipal.getUser();
        List<Role> roleList = roleService.getByUserId(user.getId());
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        roleList.forEach(item ->{
            simpleAuthorizationInfo.addRole(item.getName());
        });
        List<Integer> roleIds = roleList.stream().map(Role::getId).collect(Collectors.toList());
        List<Permission> permissions = permissionService.listByIds(roleIds);
        permissions.forEach(item->{
            simpleAuthorizationInfo.addStringPermission(item.getName());
        });
        return simpleAuthorizationInfo;
    }
}

(1)你会遇到第一个错误,类型转换错误JWTToken无法匹配AuthenticationToken类型,这是因为默认匹配为UsernamePasswordToken,错误如图所示:
在这里插入图片描述
解决该问题的方法有两种,第一种就是Config文件中调用setAuthenticationTokenClass()方法指定匹配类,示例代码如下:

@Configuration
@Component
public class ShiroConfig {
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法
        userRealm.setAuthenticationTokenClass(JWTToken.class);
        return userRealm;
    }
}

第二种方法就是再Realm中重写匹配规则,示例代码如下:

public class UserRealm extends AuthorizingRealm {

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }
    // 省略部分代码... ...
}

(2)第二个错误,如果指定了加密方式就会报 Odd number of characters.错误,如图所示:
在这里插入图片描述
因为你的密码使用Token传输,通过指定的加密方式再给密码加密时不符合规则,比如:加密方式为SHA-256,如图所示:
在这里插入图片描述

(如果设置了指定密码加密)解决办法把ConfigsetCredentialsMatcher()方法设置的加密匹配规则删除即可,示例代码如下:

@Configuration
@Component
public class ShiroConfig {
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        //userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); // 为realm设置指定算法
        userRealm.setAuthenticationTokenClass(JWTToken.class);
        return userRealm;
    }
    /**
     * 指定密码加密算法类型
     */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("SHA-256"); // 设置哈希算法
        return hashedCredentialsMatcher;
    }
}

另外,有思考过用UsernamePasswordToken类作为登录参数,统一再自定义Realm中处理,但在其他接口进入自定义过滤器的时候发现要传入用户名和密码,但是你只能获取Header中的Token,处理起来很繁琐,所以不考虑该思路。

自定义JWT认证过滤器

ShiroJWT在登录验证机制上存在根本的差异:

  • Shiro中,登录成功后,Shiro会创建一个会话(Session),并在这个会话期间维护用户的认证状态。因此,在用户的整个会话期间,只要会话没有过期或被显式销毁,Shiro就不会再次调用login()方法(如图所示,认证时所经过的几个过滤器,当未登录访问时就会进FormAuthenticationFilter,跳转至登录页面)。

在这里插入图片描述

  • 对于 JWT 这种无状态认证机制,它并不依赖于 Session,因此,每次客户端发送请求时,服务端都需要验证JWT令牌的有效性,通过自定义过滤器中需要手动调用 login() 方法。

接下来,我们需要创建一个自定义的 Shiro 过滤器,负责处理请求中的 JWT Token

public class JWTFilter extends BasicHttpAuthenticationFilter {
    /**
     * 执行登录,你可以直接将该段代码写入isAccessAllowed()中
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            return false;
        }
        return true;
    }

    /**
     * 是否允许访问
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {
        if (getHeaderToken(servletRequest) != null) {
            return executeLogin(servletRequest, servletResponse);
        }
        return false;
    }

    /**
     * 访问被拒绝
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        servletResponse.getWriter().println(JSONObject.toJSONString(ResponseEntity.ok("认证失败")));
        return false;
    }

    /**
     * 对进入自定义过滤器的接口跨域提供支持,非全局过滤器
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * 多种方式获取token
     */
    private String getHeaderToken(ServletRequest servletRequest) {
        HttpServletRequest request = WebUtils.toHttp(servletRequest);
        // 获取请求头的token
        String jwtToken = getAuthzHeader(servletRequest);
        // 获取表单参数、地址栏中的token
        if (jwtToken == null) {
            jwtToken = request.getParameter(AUTHORIZATION_HEADER);
        }
        // 获取cookie中的参数
        Cookie[] cookies = request.getCookies();
        if (jwtToken == null && cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName() != null && AUTHORIZATION_HEADER.equals(cookie.getName())) {
                    jwtToken = cookie.getValue();
                }
            }
        }
        return jwtToken;
    }
}

上述示例中,跨域方法注释了,自定义过滤器配置的跨域方案只针对于被拦截的接口,具有一定局限性,当然可以处理成全局过滤器(使用@Component注解,然后注入配置文件中),但是违背了隔离原则。

配置Config

创建一个Shiro配置类,并配置JWT过滤器。

@Configuration
public class ShiroConfig {

    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
       	// 自定义过滤器
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", new JWTFilter());
        shiroFilterFactoryBean.setFilters(filters);
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/user/logout", "logout");
        filterChainDefinitionMap.put("/**", "jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }

    /**
     * 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询
     */
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        return userRealm;
    }
}

自定义凭证匹配规则

再默认情况下进入SimpleCredentialsMatcher.doCredentialsMatch()方法,通过比较两个值是否相等,如图所示:

在这里插入图片描述
所以前面自定义Realm中有一个错误会导致密码不匹配的问题,如图所示:

在这里插入图片描述
因为登录传的Token,自定义Realm中传的是用户密码,示例代码如下:

// 密码验证使用token
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user, (String) token.getPrincipal())
, user.getPassword(), ByteSource.Util.bytes(username),  getName());

需要将它修改为Token,两边保持一致,就能解决密码不匹配的问题,示例代码如下:

SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(new UserPrincipal(user,(String) token.getPrincipal()),
token.getCredentials(), ByteSource.Util.bytes(username),  getName());

但是这种规则具有局限性,无法校验Token的有效时间(除非你的系统不需要设置有效时间,就可以使用上述方式),所以需要使用JWT的校验方式来验证Token是否有效,示例代码如下:

public class JWTCredentialsMatcher implements CredentialsMatcher {
    @Override
    public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
        UserPrincipal userPrincipal = (UserPrincipal) authenticationInfo.getPrincipals().getPrimaryPrincipal();
        User user = userPrincipal.getUser();
        return JWTUtil.verify((String) authenticationToken.getPrincipal(), user.getUsername(), user.getPassword());
    }
}

然后再Config中指定自定义Realm的密码匹配规则,示例代码如下:

@Configuration
@Component
public class ShiroConfig {
	// 省略部分代码... ...
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(new JWTCredentialsMatcher()); // 为realm指定凭证匹配规则
        userRealm.setAuthenticationTokenClass(JWTToken.class);
        return userRealm;
    }
}

接口验证

一切准备就绪后,我们就来验证一下登录和接口访问,我们先来访问示例代码中的/user/main接口,传入错误的Token和不传Token,如图所示:
在这里插入图片描述
可以看到能够正常的拦截。然后再来访问/user/login登录接口,传入不存在用户名或密码,以及正确账号获取Token,如图所示:

在这里插入图片描述

三种不同的测试方式都能正确执行,然后我们用返回的Token,访问/user/main接口,看看能否正确返回数据,如图所示:

在这里插入图片描述
访问后正常返回数据,基本的JWT整合就算完成。

权限控制

Shiro通过角色和权限进行授权,以确定哪些用户可以访问资源。根据代码追踪,登录后不会进入自定义Realm中的doGetAuthorizationInfo()授权方法,要想实现访问控制,有几种方法可以提供:

  1. 可以使用Shiro提供的注解,比如:@RequiresPermissions@RequiresRoles等注解。
  2. 调用subject.isPermitted()subject.checkPermission()等判断角色权限方法。
  3. 配置拦截器链指定访问权限,比如:filterChainDefinitionMap.put("/user/list", "roles[root]");

以上三种方式再访问时触发操作,个人比较偏向第二种方式,注解的方式不够灵活需要每个接口都加上注解,拦截器链配置的方式也不够灵活,而且是再登陆前进行的校验,导致返回的状态码是401未授权,而不是500,所以第二种方式相比之下,更加灵活且友好返回结果。

我们只需要再自定义过滤器中判断即可,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {
    /**
     * 是否允许访问
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {
        HttpServletRequest request = WebUtils.toHttp(servletRequest);
        String requestURI = request.getRequestURI();
        Subject subject = getSubject(servletRequest, servletResponse);
        // 当header不为空且
        if (getHeaderToken(servletRequest) == null || !executeLogin(servletRequest, servletResponse)) {
            return false;
        }
        // 是否拥有访问权限
        if (!subject.isPermitted(requestURI)) {
            return false;
        }
        return true;
    }
    // 省略部分代码... ...
}

假如说现在该用户只有/user/main的权限,当他访问/user/list接口时,就会被拒绝,如图所示:

在这里插入图片描述
当没有权限时,为了更加友好的提示,我们稍作修改,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {
    /**
     * 访问被拒绝
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        PrintWriter writer = servletResponse.getWriter();
        Object principal = getSubject(servletRequest, servletResponse).getPrincipal();
        if (principal == null) {
            writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有登录")));
        } else {
            writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有权限")));
        }
        return false;
    }
    // 省略部分代码... ...
}

禁用session

在前面的介绍中虽然使用的Token认证,但还是会生成Session,如图所示:

在这里插入图片描述

Shirosession管理通常用于跟踪用户的登录状态和会话信息,但在某些情况下,你可能希望禁用它,例如当你使用基于token的认证(如JWT)时。

官方文档,如图所示:

在这里插入图片描述
DefaultWebSecurityManager中设置禁用代码,示例代码如下:

public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {
    @Override
    public Subject createSubject(SubjectContext context) {
        context.setSessionCreationEnabled(false);
        return super.createSubject(context);
    }
}
@Configuration
public class ShiroConfig {
    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        // 每次请求不创建session
        StatelessDefaultSubjectFactory statelessDefaultSubjectFactory = new StatelessDefaultSubjectFactory();
        defaultWebSecurityManager.setSubjectFactory(statelessDefaultSubjectFactory);
        // 登录不创建session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        defaultWebSecurityManager.setSubjectDAO(subjectDAO);
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
}

然后重新登录或每次请求就不会创建Token了,如图所示:

在这里插入图片描述

缓存的使用

Shiro 提供了缓存机制,用于提高性能,对应前后分离项目频繁的调用登录,减少自定义Realm的验证、授权对底层数据源(如数据库)的频繁访问。

实现Shiro提供的Cache类,示例代码如下:

public class RedisCacheManage implements CacheManager {
    private final RedisTemplate<String, Object> redisTemplate;
    public RedisCacheManage(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new RedisCache<>(s, redisTemplate);
    }
}
public class RedisCache<K, V> implements Cache<K, V> {
    private final HashOperations<String, K, V> hashOperations;
    private final String name;
    public RedisCache(String name, RedisTemplate<String, Object> redisTemplate) {
        this.name = name;
        this.hashOperations = redisTemplate.opsForHash();
    }
    @Override
    public V get(K k) throws CacheException {
        return hashOperations.get(name, k);
    }

    @Override
    public V put(K k, V v) throws CacheException {
        hashOperations.put(name, k, v);
        return v;
    }

    @Override
    public V remove(K k) throws CacheException {
        V v = hashOperations.get(name, k);
        hashOperations.delete(name, k);
        return v;
    }

    @Override
    public void clear() throws CacheException {
        hashOperations.delete(name);
    }

    @Override
    public int size() {
        return hashOperations.size(name).intValue();
    }

    @Override
    public Set<K> keys() {
        return hashOperations.keys(name);
    }

    @Override
    public Collection<V> values() {
        return hashOperations.values(name);
    }
}

然后配置Redis并再自定义Realm中配置使用,示例代码如下:

@Configuration
public class ShiroConfig {
    // 省略部分代码... ...
    
    /**
     * 创建Shiro Web应用的整体安全管理
     */
    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(realm());
        // 可以添加其他配置,如缓存管理器、会话管理器等
        return defaultWebSecurityManager;
    }
    /**
     * 注册Realm的对象,用于执行安全相关的操作,如用户认证、权限查询
     */
    @Bean
    public Realm realm() {
        UserRealm userRealm = new UserRealm();
        userRealm.setCredentialsMatcher(new JWTCredentialsMatcher()); // 为realm设置指定算法
        userRealm.setCachingEnabled(true); // 启动全局缓存
        userRealm.setAuthenticationCachingEnabled(true); // 启动验证缓存
        userRealm.setAuthorizationCachingEnabled(true);
        userRealm.setAuthenticationCacheName("Authentication"); // 定义授权缓存名
        userRealm.setAuthorizationCacheName("Authorization"); // 定义认证缓存名
        userRealm.setCacheManager(cacheManager());
        userRealm.setAuthenticationTokenClass(JWTToken.class);
        return userRealm;
    }
    /**
     * redis配置
     */
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        //设置了 ObjectMapper 的可见性规则。通过该设置,所有字段(包括 private、protected 和 package-visible 等)都将被序列化和反序列化,无论它们的可见性如何。
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        //启用了默认的类型信息 NON_FINAL 参数表示只有非 final 类型的对象才包含类型信息,这可以帮助在反序列化时正确地将 JSON 字符串转换回对象。
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        //key采用String的序列化方式
        redisTemplate.setKeySerializer(stringRedisSerializer);
        //hash的key也采用String的序列化方式
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        return redisTemplate;
    }
}

执行登录请求后、正常访问接口,可以看到第二次请求进入自定义过滤器登录就会直接从缓存获取数据(源码不做介绍),如图所示:

在这里插入图片描述
查看Redis中保存了验证、授权的信息,如图所示:

在这里插入图片描述
但是还会带来一个问题,如果当用户退出后,原有的Token如果没到过期时间,依然可以使用进行接口访问,理想状态下退出后不可再继续使用的,如图所示:

在这里插入图片描述

原因:Redis缓存的校验、授权数据清空后,再次请求判断缓存中没有数据,进入自定义Realm中重新查询数据库进行保存

在这里插入图片描述
解决这个问题,可以再登录时,生成一个Key存入Redis缓存中;请求其他接口时,再自定义过滤器中判断缓存是否存在;退出时,删除该缓存。示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        // 校验用户
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        SimpleHash hash = new SimpleHash("MD5", password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        JWTToken jwtToken = new JWTToken(sign);
        // 保存用户token
        String key = "login_user_token_"+username;
        redisTemplate.opsForValue().set(key, sign);
        try {
            // 对用户信息进行身份认证
            Subject subject = SecurityUtils.getSubject();
            subject.login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            return ResponseEntity.ok("密码错误");
        }
        // 返回响应参数
        response.setHeader("Authorization", sign);
        return ResponseEntity.ok(jwtToken.getPrincipal());
    }

    @PostMapping("/logout")
    public ResponseEntity logout() {
        UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();
        // 删除用户token
        String key = "login_user_token_"+userPrincipal.getUser().getUsername();
        redisTemplate.delete(key);
        // 同时清空验证和授权缓存
        SecurityUtils.getSubject().logout();
        return ResponseEntity.ok("成功");
    }
}
@Configuration
public class ShiroConfig {

    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", new JWTFilter(redisTemplate()));
        shiroFilterFactoryBean.setFilters(filters);
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/**", "jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }
    // 省略部分代码... ...
}
public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            // 获取token
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null) { // 缓存为空,可能有效期过期,同时删除验证和授权缓存
                getSubject(request, response).logout();
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    // 省略部分代码... ...
}

下次访问当缓存中的数据不存在时(比如缓存过期导致),同时需要将Redis缓存中验证、授权时保存的数据一并清除避免占用资源,也解决了失效Token继续请求的问题(但是还有另一个问题,如果缓存过期后直接请求登录接口,导致验证、授权保存的数据未清除,所以这种情况下可能还需要监听Redis过期进行处理),执行结果如图所示:

在这里插入图片描述

如果不将验证、授权进行缓存,那么处理起来就非常的简单,直接在自定义Realm中判断RedisKey过期没有即可(如果启用缓存,在自定义Realm中判断会导致下次请求直接从缓存获取数据不进入自定义Realm中,所以再过滤器中处理),不需要考虑验证、授权的缓存清除问题(建议这样,不然你要考虑很多缓存过期数据清理的问题,比如缓存过期对应数据验证、授权需要清除)。

除此之外,你还可以给这个Key设置过期时间,每次请求当缓存未删除时需要给该缓存的过期时间进行延长,过期删除后下次请求不允许访问,需要重新登录(后续讲解)。

另外如果只允许一个用户同时登录,你还需要判断当前的用户Token与缓存中的是否一致。将另外一个用户进行下线,保证只有一个用户可以操作。

更多的处理方式,取决于你的业务深度,具体问题具体分析。

登录退出

与前后不分离项目有所区别,如果登录退出使用默认logout过滤器,示例代码如下:

filterChainDefinitionMap.put("/user/logout", "logout");

会导致退出成功后无法重定向到指定页面,导致报错,如图所示:
在这里插入图片描述

我们可以看下源码,如图所示:

在这里插入图片描述
当登录退出后,默认重定向到/根目录,因为是前后分离页面导致报错。

解决这个问题,在过滤器链中不定义/user/logout接口,然后就会先进入自定义过滤器中,然后执行登录接口逻辑,再执行具体接口业务,如果Token不存在,则在过滤器中被拦截(不建议设置为anon匿名过滤器,如果先用失效的Token再用未失效的Token会导致获取不到当前登录用户的情况,导致无法清除,一定是登录成功的用户才可以登录退出)。

示例代码如下:

@Configuration
public class ShiroConfig {

    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", new JWTFilter(redisTemplate()));
        shiroFilterFactoryBean.setFilters(filters);
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/**", "jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }
    // 省略部分代码... ...
}
public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null) { // 只有Token一致才能登录,其他全部拦截
                getSubject(request, response).logout();
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    // 省略部分代码... ...
}
@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/logout")
    public ResponseEntity logout() {
        UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();
        if (userPrincipal != null) {
            // 删除用户token
            String key = "login_user_token_" + userPrincipal.getUser().getUsername();
            redisTemplate.delete(key);
        }
        SecurityUtils.getSubject().logout();
        return ResponseEntity.ok("成功");
    }
}

因为我们使用缓存解决登录退出后Token继续使用的问题,所以登录退出时,还需要将该缓存从数据清空(如果开启了验证、授权缓存登录退出时logout()方法自动清除),为了防止过期的Token请求登录退出接口导致Null指针错误,需进行非空判断。执行结果如图:

在这里插入图片描述

单用户登录

在实际应用中,有时需要限制一个账号只能在一处登录,即实现单用户登录功能。

在前面的缓存章节中有介绍过Token失效后继续使用的问题,我们使用另一个缓存解决Token失效的问题,当然该解决方案稍作修改也解决的单用户登录的问题,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null || !redisToken.equals(headerToken)) { // 只有Token一致才能登录,其他全部拦截
                getSubject(request, response).logout();
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    // 省略部分代码... ...
}

执行结果如图所示:

在这里插入图片描述

其原理就是相同Key对应的Value被替换,当Token不一致时,不允许访问接口,从而实现了单用户登录功能,这样就确保了一个账号只能在一处登录。

如果想要实现同一账号多用户登录,就不能公用一个Key,每个用户登录生成不同Token,当作Key可以解决一账号多用户登录也可以阻止Token失效继续访问的问题,如图所示:

在这里插入图片描述
如果验证、授权使用缓存,实现单(多)用户登录,最简单的办法就是缓存的Key进行处理保持一致,实现逻辑和上面基本一致,这样三个缓存(验证、授权、解决Token失效的缓存)都要保持一致。

Token刷新

以页面的形式进行讲解,请求登录接口,遇到CORS跨域问题,因为登录使用匿名过滤器,自定义过滤器的跨域处理不会生效,如图所示:

在这里插入图片描述
所有这里定义一个全局的跨域配置(有很多种方案),示例代码如下:

@Configuration
public class MyCorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*"); // 支持域
        config.setAllowCredentials(true); // 是否发送Cookie
        config.addAllowedMethod("*"); // 支持请求方式
        config.addAllowedHeader("*"); // 允许的原始请求头部信息
        config.addExposedHeader("*"); // 暴露的头部信息

        UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**", config);

        return new CorsFilter(corsConfigurationSource);
    }
}

Web 应用程序中,Token(令牌)通常用于身份验证和授权。为了保证安全性和用户体验, Token 通常会设置一个较短的有效期。当 Token 即将过期或已经过期时,需要进行刷新操作以获取新的 Token

双Token方案

客户端在初次认证时,服务器会返回一个短期有效的访问Token和一个长期有效的刷新 Token。客户端在访问 Token过期时,可以使用刷新Token向服务器申请新的访问Token

登录时生成访问令牌accessToken和刷新令牌refreshToken,前端隔段时间通过refreshToken调用/user/refreshToken接口获取新的accessToken,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {

    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        Sha256Hash hash = new Sha256Hash(password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        // 生成token 假设AccessToken为30分钟有效期
        String generateAccessToken = JWTUtil.sign(username, hash.toHex());
        // RefreshToken7天有效期
        String generateRefreshToken = JWTUtil.sign(username, hash.toHex());
        // 保存用户token
        String accessToken = "access_token_"+username;
        redisTemplate.opsForValue().set(accessToken, generateAccessToken);
        String refreshToken = "refresh_token_"+username;
        redisTemplate.opsForValue().set(refreshToken, generateRefreshToken);
        try {
            // 对用户信息进行身份认证
            JWTToken jwtToken = new JWTToken(generateAccessToken);
            Subject subject = SecurityUtils.getSubject();
            subject.login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            return ResponseEntity.ok("密码错误");
        }
        // 返回响应参数
        response.setHeader("accessToken", generateAccessToken);
        response.setHeader("refreshToken", generateRefreshToken);
        Map<String, String> tokenResp= new HashMap<>();
        tokenResp.put("accessToken", accessToken);
        tokenResp.put("refreshToken", refreshToken);
        return ResponseEntity.ok(tokenResp);
    }

    @PostMapping("/refreshToken")
    public ResponseEntity<Map<String, String>> refreshToken(@RequestBody Map<String, String> request) {
        UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();
        User user = userPrincipal.getUser();
        String refreshToken = request.get("refreshToken");
        // 校验token是否过期
        if (JWTUtil.verify(refreshToken, user.getUsername(), user.getPassword())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        String newAccessToken = JWTUtil.sign(user.getUsername(), user.getPassword());
        Map<String, String> response = new HashMap<>();
        response.put("accessToken", newAccessToken);
        return ResponseEntity.ok(response);
    }
}

Token 方案需要额外管理刷新令牌,包括生成、存储、验证和更新等操作,增加了系统的复杂度和开发成本。

单Token方案
  1. 访问 Token本身携带所有用户认证信息。当Token过期时,客户端需要重新进行登录获取新的Token。这种方案非常的简单粗暴,一般很少使用这种方案。

登录时保存Token并设置缓存过期时间,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {

    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        Sha256Hash hash = new Sha256Hash(password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        JWTToken jwtToken = new JWTToken(sign);
        // 保存用户token,设置token有效期30分钟
        String key = "login_user_token_"+username;
        redisTemplate.opsForValue().set(key, sign, 30, TimeUnit.HOURS);
        try {
            // 对用户信息进行身份认证
            Subject subject = SecurityUtils.getSubject();
            subject.login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            return ResponseEntity.ok("密码错误");
        }
        // 返回响应参数
        response.setHeader("Authorization", sign);
        // 返回cookie
        Cookie cookie = new Cookie("Authorization", sign);
        response.addCookie(cookie);
        return ResponseEntity.ok(jwtToken.getPrincipal());
    }
}

用户登录后,访问其他接口,进入自定义过滤器,如果缓存中的Token过期被清除了,要求重新登录,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            // 获取token
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            // 判断Token是否存在
            if (redisToken == null || !redisToken.equals(headerToken)) {
                getSubject(request, response).logout();
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    /**
     * 是否允许访问
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) {
        HttpServletRequest http = WebUtils.toHttp(servletRequest);
        String requestURI = http.getRequestURI();
        System.out.println(requestURI);
        Subject subject = getSubject(servletRequest, servletResponse);
        boolean isAccessAllowed = false;
        if (getHeaderToken(servletRequest) != null) {
            isAccessAllowed = executeLogin(servletRequest, servletResponse);
        }
        System.out.println(subject.getPrincipal());
        if (isAccessAllowed && !subject.isPermitted(requestURI)) {
            return false;
        }

        return isAccessAllowed;
    }
    /**
     * 访问被拒绝
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(servletResponse);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        Object subject = getSubject(servletRequest, servletResponse).getPrincipal();
        PrintWriter writer = servletResponse.getWriter();
        if (subject == null) {
            writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有登录")));
        } else {
            writer.println(JSONObject.toJSONString(ResponseEntity.ok("没有权限")));
        }
        return false;
    }
    // 省略部分代码... ...
}

不需要额外实现复杂的 Token 刷新机制,系统只需要关注 Token 的有效期和登录验证逻辑,降低了开发和维护的难度。

用户在使用过程中,一旦 Token 过期就需要重新输入用户名和密码进行登录,尤其是在频繁操作或者长时间使用的场景下,这会给用户带来极大的不便,降低用户对系统的满意度。

  1. 访问Token有一个固定的过期时间,然而每次使用Token时,过期时间会重新刷新,延长到固定的时间窗口。这种方式通常与刷新 Token方案结合使用。

登录时设置Token存放Redis中的有效时间(Token不设置过期时间),示例代码如下:

public class JWTUtil {
    /**
     * 生成token签名
     *
     * @param username 用户名
     * @param secret 密码
     * @return token字符串
     */
    public static String sign(String username, String secret) {
        String token = JWT.create()
                .withClaim("username", username)
                .withIssuedAt(new Date())     // 发行时间
                .sign(Algorithm.HMAC256(secret));
        return token;
    }
    // 省略部分代码... ...
}
@Controller
@RequestMapping(value = "/user")
public class UserController {

    @Autowired
    private UserService userService;
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        // 校验用户
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        SimpleHash hash = new SimpleHash("MD5", password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        JWTToken jwtToken = new JWTToken(sign);
        // 保存用户token,设置token有效期30分钟
        String key = "login_user_token_"+username;
        redisTemplate.opsForValue().set(key, sign, 30, TimeUnit.MINUTES);
        try {

            // 对用户信息进行身份认证
            Subject subject = SecurityUtils.getSubject();
            subject.login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            return ResponseEntity.ok("密码错误");
        }
        // 返回响应参数
        response.setHeader("Authorization", sign);
        // 返回cookie
        Cookie cookie = new Cookie("Authorization", sign);
        response.addCookie(cookie);
        return ResponseEntity.ok(jwtToken.getPrincipal());
    }
}

访问其他接口时,进入自定义过滤器中,如果Redis中的Token不存在(过期删除)就要求重新登陆,否则每次请求就将Redis过期时间刷新,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(){}
    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            // 获取token
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null || !redisToken.equals(headerToken)) {
                getSubject(request, response).logout();
                return false;
            } else { // 重置redis过期时间
                System.out.println("redis-expire:"+redisTemplate.getExpire(key, TimeUnit.MINUTES));
                redisTemplate.expire(key, 30, TimeUnit.MINUTES);
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    // 省略部分代码... ...
}

每次请求刷新对应Redis有效期,若长时间未操作,到期自动清除,重新登陆。执行结果如图所示:

在这里插入图片描述

如果你觉得Token续期的方案,安全性太低了,当令牌已过期,服务器返回特定的错误码(如 401 Unauthorized),客户端捕获到该错误后,自动向服务器发送刷新令牌的请求。有三种方案:定时刷新、请求拦截、响应拦截,下面针对这几种方案进行详细讲解。

  • 定时刷新

在用户登录成功后,与后端确认Token过期时间(一般来说应小于服务器的过期时间),使用 JavaScriptsetInterval 函数来定时触发刷新 Token的请求。

后端需要提供Token刷新接口,通过当前Token的有效期防止频繁刷新Token,缓存Token中不存在再拦截器中处理,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/refreshToken")
    public ResponseEntity refreshToken(HttpServletResponse response) {
        UserPrincipal userPrincipal = (UserPrincipal) SecurityUtils.getSubject().getPrincipal();
        User user = userPrincipal.getUser();
        // 获取Token有效期
        Date expiry = JWTUtil.getExpiry(userPrincipal.getToken());
        // 正确将 Date 转换为 LocalDateTime
        Instant instant = expiry.toInstant();
        LocalDateTime dateAsLocalDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
        LocalDateTime now = LocalDateTime.now();
		Duration between = Duration.between(now, dateAsLocalDateTime);
        // 间隔时间大于5分钟不生成新token
        if (between.toMinutes() > 5) {
            // 返回原token
            response.setHeader("Authorization", userPrincipal.getToken());
            return ResponseEntity.ok(userPrincipal.getToken());
        }
        String sign = JWTUtil.sign(user.getUsername(), user.getPassword());
        // token重新赋值
        String key = "login_user_token_" + userPrincipal.getUser().getUsername();
        redisTemplate.opsForValue().set(key, sign);
        // 返回token
        response.setHeader("Authorization", sign);

        return ResponseEntity.ok(sign);
    }
}

登录时生成Token并设置JWT有效期(短Token)和Redis有效期(长Token)存入缓存,示例代码如下:

public class JWTUtil {
    // 过期30分钟
    private static final long EXPIRE_TIME = 30 * 60 * 1000;
    /**
     * 生成token签名
     *
     * @param username 用户名
     * @param secret 密码
     * @return token字符串
     */
    public static String sign(String username, String secret) {
        String token = JWT.create()
                .withClaim("username", username)
                .withIssuedAt(new Date())     // 发行时间
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME)) // 过期时间
                .sign(Algorithm.HMAC256(secret));
        return token;
    }
    /**
     * 获取token有效期
     * @param token
     * @return 
     */
    public static Date getExpiry(String token){
        try {
            return JWT.decode(token).getExpiresAt();
        } catch (Exception e) {
            return null;
        }
    }
}
@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        User user = userService.getOne(new QueryWrapper<User>().ge("username", username));
        if (user == null) {
            return ResponseEntity.ok("用户不存在");
        }
        Sha256Hash hash = new Sha256Hash(password, username);
        if (!hash.toHex().equals(user.getPassword())) {
            return ResponseEntity.ok("账号或密码错误");
        }
        // 生成token
        String sign = JWTUtil.sign(username, hash.toHex());
        JWTToken jwtToken = new JWTToken(sign);
        // 保存用户token,设置token有效期7天
        String key = "login_user_token_"+username;
        redisTemplate.opsForValue().set(key, sign, 7, TimeUnit.DAY);
        try {
            // 对用户信息进行身份认证
            Subject subject = SecurityUtils.getSubject();
            subject.login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            return ResponseEntity.ok("密码错误");
        }
        // 返回响应参数
        response.setHeader("Authorization", sign);
        // 返回cookie
        Cookie cookie = new Cookie("Authorization", sign);
        response.addCookie(cookie);
        return ResponseEntity.ok(jwtToken.getPrincipal());
    }
    // 模拟首页多个请求情况
    @PostMapping("/main")
    public ResponseEntity main() {
        Subject subject = SecurityUtils.getSubject();
        System.out.println("===============main==========");
        System.out.println(subject.getPrincipal());
        return ResponseEntity.ok("success");
    }

    @PostMapping("/main2")
    public ResponseEntity main2() {
        return ResponseEntity.ok("success");
    }

    @PostMapping("/main3")
    public ResponseEntity main3() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return ResponseEntity.ok("success");
    }

    @PostMapping("/main4")
    public ResponseEntity main4() {
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return ResponseEntity.ok("success");
    }
}

如果用过期的Token访问(JWT过期和Redis过期删除)接口,过滤器拦截,后端直接认定为没有登陆,JWT过期再登录时参考自定义匹配凭证章节;Redis过期删除判断主要再拦截器中处理,因为每隔段时间会更换Token所以不需要缓存延期,示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        try {
            String headerToken = getHeaderToken(request);
            JWTToken jwtToken = new JWTToken(headerToken);
            // 获取token
            String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null || !redisToken.equals(headerToken)) {
            	// 通过状态码跳转登录
                httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                getSubject(request, response).logout();
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    // 省略部分代码... ...
}

前端以JavaScript方式的 setInterval 函数来定时触发刷新 Token的请求。以登录成功后(将Token存入localStorage)进入首页为例,示例代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页页面</title>
</head>
<body>
<div class="login-container">
    <h2>首页</h2>
    <button id="fetchDataButton">发起请求</button>
    <p id="responseMessage"></p> <!-- 添加了这个元素 -->
    <p><a href="/user/logout">退出登录</a></p>
</div>
<script type="module">
    import { makeRequest } from './tokenHandler.js';
	// 触发点击事件
    document.getElementById('fetchDataButton').addEventListener('click', async () => {
        try {
            await makeRequest('http://localhost:8080/user/main');
            await makeRequest('http://localhost:8080/user/main2');
            await makeRequest('http://localhost:8080/user/main3');
            await makeRequest('http://localhost:8080/user/main4');
            // console.log('请求结果:', result);
            // document.getElementById('responseMessage').textContent = result;
        } catch (error) {
            console.error('Error:', error);
        }
    });
</script>
</body>
</html>

封装成公用js代码方便调用,定时任务每隔段时间请求刷新Token接口,如果此时用户正在请求就会放入队列中进行等待,示例代码如下:

// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];

// 模拟后端接口:刷新 Token
async function refreshTokenApi() {
    isRefreshingToken = true;
    try {
        // 这里模拟向服务器发送刷新 token 的请求
        const response = await fetch('http://localhost:8080/user/refreshToken', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `${localStorage.getItem('token')}`
            },
            // body: JSON.stringify({
            //     // 可以传递一些必要的参数
            // })
        });
        const data = await response.text();
        // 假设返回的新 token 字段名为 newToken,根据实际情况修改
        localStorage.setItem('token', data);
        console.log('Token 刷新成功'+data);
        // 执行队列中的请求
        requestQueue.forEach(request => request(localStorage.getItem('token')));
        requestQueue = [];
        return data;
    } catch (error) {
        console.error('Token 刷新失败', error);
        requestQueue = [];
        throw error;
    } finally {
        isRefreshingToken = false;
    }
}

// 定时刷新 Token
const refreshInterval = 5000; // 每 5 秒尝试刷新 Token
setInterval(() => {
    refreshTokenApi();
}, refreshInterval);

// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {
    return new Promise(async (resolve, reject) => {
        const originalRequest = async (token) => {
            try {
                const newOptions = {
                    ...options,
                    headers: {
                        ...options.headers,
                        'Authorization': `${token}`
                    },
                    method: 'GET'
                };
                const response = await fetch(url, newOptions);
                const data = await response.text();
                resolve(data);
            } catch (error) {
                reject(error);
            }
        };
        console.log(isRefreshingToken)
        if (isRefreshingToken) {
            console.log("当前正再刷新token,请求放入队列中")
            // 如果 Token 正在刷新,将请求加入队列
            requestQueue.push(originalRequest);
            console.log(requestQueue)
        } else {
            // 检查 Token 是否过期,这里简单假设响应状态码为 401 表示 Token 过期
            const response = await fetch(url, {
                ...options,
                headers: {
                    ...options.headers,
                    'Authorization': `${localStorage.getItem('token')}`
                }
            });
            if (response.status === 401) {
                // Token 过期,开始刷新
                requestQueue.push(originalRequest);
                refreshTokenApi().catch(reject);
            } else {
                const data = await response;
                resolve(data);
            }
        }
    });
};
export { makeRequest };

其实前端放入队列的请求只有一个,Token刷新后继续执行队列中的请求,后续的请求等待第一个执行完毕后才会执行,类似于同步操作。

我们需要考虑第一个问题,如果请求接口(模拟某个接口请求慢的情况)途中触发Token刷新,那么后面接口就应该等待Token刷新后使用新的Token进行请求,执行结果如图所示:

在这里插入图片描述
第二个问题,如果Token刷新的途中(模拟Token刷新慢),发起请求应该等待Token刷新完毕后再发起请求,执行结果如图所示:
在这里插入图片描述
按照固定的时间间隔向服务器发送刷新请求,会显著增加服务器的处理压力,频繁的刷新操作会消耗额外的网络带宽。另一方面,攻击者可以通过截获 Token 刷新请求,分析其中的加密算法和数据格式,从而尝试破解用户的 Token

  • 请求拦截

在获取 Token 时,服务器通常会同时返回 Token 的过期时间(也可以前端解析JWT获取)。在请求拦截时,通过比较当前时间和过期时间来判断 Token 是否过期,若已过期,则将请求挂起,先刷新Token后再继续请求。

我们需要再登录接口和刷新Token接口返回参数加上Token有效期,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/login")
    public ResponseEntity login(@RequestParam("username") String username
            , @RequestParam("password") String password, HttpServletResponse response) {
        // 省略部分代码... ...
        // 返回参数
        Map<String,Object> map = new HashMap<>();
        map.put("Authorization", sign);
        map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());
        return ResponseEntity.ok(jwtToken.getPrincipal());
    }
    @PostMapping("/refreshToken")
    public ResponseEntity refreshToken() throws InterruptedException {
        // 省略部分代码... ...
        // 返回参数
        Map<String,Object> map = new HashMap<>();
        map.put("Authorization", sign);
        map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());
        return ResponseEntity.ok(sign);
    }
}

登录成功后(将TokenExpireTime存入localStorage)进入首页为例,主要介绍js部分的处理(其他代码参考之前案例),用户发起请求前先校验Token的过期时间是否达到范围,如果没有则正常请求,否则将请求放入队列,先执行Token刷新操作,示例代码如下:

// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];

// 模拟后端接口:刷新 Token
async function refreshTokenApi() {
    isRefreshingToken = true;
    try {
        // 这里模拟向服务器发送刷新 token 的请求
        const response = await fetch('http://localhost:8080/user/refreshToken', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `${localStorage.getItem('token')}`
            },
            // body: JSON.stringify({
            //     // 可以传递一些必要的参数
            // })
        });
        const data = await response.json();
        // 假设返回的新 token 字段名为 newToken,根据实际情况修改
        localStorage.setItem('token', data.Authorization);
        localStorage.setItem('expireTime', data.ExpireTime);
        console.log('Token 刷新成功'+data);
        // 执行队列中的请求
        requestQueue.forEach(request => request(localStorage.getItem('token')));
        requestQueue = [];
        return data;
    } catch (error) {
        console.error('Token 刷新失败', error);
        requestQueue = [];
        throw error;
    } finally {
        isRefreshingToken = false;
    }
}
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {
    return new Promise(async (resolve, reject) => {
        const originalRequest = async (token) => {
            try {
                const newOptions = {
                    ...options,
                    headers: {
                        ...options.headers,
                        'Authorization': `${token}`
                    },
                    method: 'GET'
                };
                const response = await fetch(url, newOptions);
                if (!response.ok) {
                    if (response.status === 401) { // 再次检查,以防本地判断失误
                        if (!isRefreshing) {
                            requestQueue.push((newToken) => originalRequest(newToken));
                            await refreshToken();
                        } else {
                            requestQueue.push((newToken) => originalRequest(newToken));
                        }
                        return;
                    }
                    throw new Error(`Request failed with status ${response.status}`);
                }
                const data = await response.text();
                resolve(data);
            } catch (error) {
                reject(error);
            }
        };

        // 请求拦截:判断 Token 是否过期
        const currentTime = Date.now();
        const expireTime = localStorage.getItem("expireTime");
        if (expireTime && isFiveMinutesApart(expireTime, currentTime)) {
            console.log(isRefreshingToken)
            if (isRefreshingToken) {
                console.log("当前正再刷新token,请求放入队列中")
                // 如果 Token 正在刷新,将请求加入队列
                requestQueue.push(originalRequest);
                console.log(requestQueue)
            } else {
                requestQueue.push(originalRequest);
                await refreshTokenApi();
            }
        } else {
            // 正常发送请求
            await originalRequest(localStorage.getItem("token"));
        }
    });
};

function isFiveMinutesApart(timestamp1, timestamp2) {
    // 计算两个时间戳的差值(毫秒)
    const difference = timestamp1 - timestamp2;
    console.log(difference)
    // 5 分钟对应的毫秒数
    const fiveMinutesInMs = 5 * 60 * 1000;
    // 判断差值是否等于 5 分钟对应的毫秒数
    return difference < fiveMinutesInMs;
}
export { makeRequest };

Token过期时间达到范围值先触发Token刷新,再将其他请求放入队列,等Token刷新后再发起,否则正常请求接口,执行结果如图所示:

在这里插入图片描述
仔细思考还有一个问题未解决?如果用户长时间未操作,此时Token已经过期了,如果再次请求刷新Token接口,再拦截器拦下返回没有登录,该如何处理?如果直接跳转登录页面根本不需要刷新Token方案。经过思考,我们将刷新Token接口设置为anon匿名访问,示例代码如下:

@Configuration
public class ShiroConfig {

    /**
     * 核心安全过滤器对进入应用的请求进行拦截和过滤,从而实现认证、授权、会话管理等安全功能。
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 配置拦截器链,指定了哪些路径需要认证、哪些路径允许匿名访问
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", new JWTFilter(redisTemplate()));
        shiroFilterFactoryBean.setFilters(filters);
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login", "anon");
        filterChainDefinitionMap.put("/user/refreshToken", "anon");
        filterChainDefinitionMap.put("/**", "jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }
    // 省略部分代码...
}

然后修改Token刷新接口,只要RedisToken没有过期清除,就可以重新生成Token,增加一些限制判断逻辑,示例代码如下:

@Controller
@RequestMapping(value = "/user")
public class UserController {
    @PostMapping("/refreshToken")
    public ResponseEntity refreshToken(HttpServletRequest request) {
        String token = request.getHeader("authorization");
        Date expiry = JWTUtil.getExpiry(token);
        String key = "login_user_token_" + JWTUtil.getUsername(token);
        // 拦截token不存在或者不匹配
        if (!redisTemplate.hasKey(key) || !redisTemplate.opsForValue().get(key).equals(token)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("没有登陆");
        }
        // 正确将 Date 转换为 LocalDateTime
        Instant instant = expiry.toInstant();
        LocalDateTime dateAsLocalDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
        LocalDateTime now = LocalDateTime.now();
        Duration between = Duration.between(now, dateAsLocalDateTime);
        // 间隔时间大于5分钟不生成新token
        if (between.toMinutes() > 5) {
            // 返回原token
            Map<String,Object> map = new HashMap<>();
            map.put("Authorization", token);
            map.put("ExpireTime", expiry.getTime());
            return ResponseEntity.ok(map);
        }
        User user = userService.getOne(new QueryWrapper<User>().ge("username", JWTUtil.getUsername(token)));
        String sign = JWTUtil.sign(user.getUsername(), user.getPassword());
        redisTemplate.opsForValue().set(key, sign);
        // 返回token
        Map<String,Object> map = new HashMap<>();
        map.put("Authorization", sign);
        map.put("ExpireTime", JWTUtil.getExpiry(sign).getTime());
        return ResponseEntity.ok(map);
    }
    // 省略部分代码...
}

这样即使用户长时间未操作,再次请求触发刷新Token也能正常更新,不会影响用户的体验。

需要注意设置为匿名访问,会增加一定的安全风险,尤其是可能导致恶意用户滥用该接口,在实现匿名刷新时加上适当的限制,如请求频率、IP 限制等。

相对于前端定时刷新Token,请求拦截可以避免频繁的Token刷新请求,减少不必要的网络流量。但是该方法需要后端提供Token过期字段,使用本地时间判断,容易被篡改,增加后端维护成本;增加额外的计算开销,在请求非常频繁时,这种开销会导致一定的延迟。

  • 响应拦截

先发起请求,当接口返回过期后,先刷新Token再重新发送原始请求。

后端只需要把前面案例的有效期字段去除即可,代码上没什么变化,主要介绍前端js的变化,当请求第一个接口返回登录过期,先进行Token刷新并将请求放入队列中,等待刷新完毕后再次发起请求,示例代码如下:

// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];

// 模拟后端接口:刷新 Token
async function refreshTokenApi() {
    isRefreshingToken = true;
    try {
        // 这里模拟向服务器发送刷新 token 的请求
        const response = await fetch('http://localhost:8080/user/refreshToken', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `${localStorage.getItem('token')}`
            },
            // body: JSON.stringify({
            //     // 可以传递一些必要的参数
            // })
        });
        const data = await response.json();
        // 假设返回的新 token 字段名为 newToken,根据实际情况修改
        localStorage.setItem('token', data.Authorization);
        localStorage.setItem('expireTime', data.ExpireTime);
        console.log('Token 刷新成功'+data);
        // 执行队列中的请求
        requestQueue.forEach(request => request(localStorage.getItem('token')));
        requestQueue = [];
        return data;
    } catch (error) {
        console.error('Token 刷新失败', error);
        requestQueue = [];
        throw error;
    } finally {
        isRefreshingToken = false;
    }
}

// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {
    return new Promise(async (resolve, reject) => {
        const originalRequest = async (token) => {
            try {
                const newOptions = {
                    ...options,
                    headers: {
                        ...options.headers,
                        'Authorization': `${token}`
                    },
                    method: 'GET'
                };
                const response = await fetch(url, newOptions);
                // 响应拦截
                const data = await toData(response);
                if (response.ok) {
                    if (data.body === "没有登录") {
                        if (!isRefreshingToken) {
                            requestQueue.push((newToken) => originalRequest(newToken));
                            await refreshTokenApi();
                        } else {
                            requestQueue.push((newToken) => originalRequest(newToken));
                        }
                    }
                } else if (!response.ok) {
                    if (response.status === 401) { // 如果返回401则直接跳转登录页面
                        window.location.href = "./login.html"
                    }
                    throw new Error(`Request failed with status ${response.status}`);
                }
                resolve(data);
            } catch (error) {
                reject(error);
            }
        };

        // 正常发送请求
        await originalRequest(localStorage.getItem("token"));
    });
};

function toData(data) {
    const contentType = data.headers.get('Content-Type');
    if (contentType && contentType.includes('application/json')) {
        return data.json();
    } else {
        return data.text();
    }
}
export { makeRequest };

执行结果如图所示:

在这里插入图片描述
似乎还有更好的处理方案,既然接口响应Token失效了,是否可以直接将新的Token生成后返回。

因为生成Token逻辑再自定义过滤器中处理,为了保证过滤器的单一职责,我们先修改生成Token方式,使用固定密钥,示例代码如下:

public class JWTUtil {
    // 过期30分钟
    private static final long EXPIRE_TIME = 30 * 60 * 1000;

    private static final String secret = "Zt]q5V5*MZ.WfHknK)b_";
    /**
     * 生成token签名
     *
     * @param username 用户名
     * @return token字符串
     */
    public static String sign(String username) {
        String token = JWT.create()
                .withClaim("username", username)
                .withIssuedAt(new Date())     // 发行时间
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME))
                .sign(Algorithm.HMAC256(secret));
        return token;
    }

    /**
     * 校验token是否有效
     *
     * @param token    生成token
     * @param username 用户名
     * @return
     */
    public static boolean verify(String token, String username) {
        try {
            // 保证荷载参数一致
            JWTVerifier build = JWT.require(Algorithm.HMAC256(secret)).withClaim("username", username).build();
            DecodedJWT decodedJWT = build.verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    // 省略部分代码...
}

在过滤器中处理Token过期后重新生成Token,放入响应头中返回给前端(需要设置Access-Control-Expose-Headers前端才能使用),示例代码如下:

public class JWTFilter extends BasicHttpAuthenticationFilter {

    private RedisTemplate<String,Object> redisTemplate;

    public JWTFilter(){}
    public JWTFilter(RedisTemplate<String,Object> redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        String headerToken = getHeaderToken(request);
        JWTToken jwtToken = new JWTToken(headerToken);
        // 获取token
        String key = "login_user_token_"+JWTUtil.getUsername(headerToken);
        try {
            String redisToken = (String) redisTemplate.opsForValue().get(key);
            if (redisToken == null || !redisToken.equals(headerToken)) {
                getSubject(request, response).logout();
                // 通过状态码跳转登录
                httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return false;
            }
            getSubject(request, response).login(jwtToken);
        } catch (IncorrectCredentialsException e) {
            // 生成Token,并且更新Redis
            HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
            String newToken = createToken(headerToken);
            httpServletResponse.setHeader("Authorization", newToken);
            redisTemplate.opsForValue().set(key, newToken);
            e.printStackTrace();
            return false;
        }
        return true;
    }
    /**
     * 对跨域提供支持
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        try {
            httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
            httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
            // 确保前端能获取响应头中的字段
            httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
            httpServletResponse.setHeader("Access-Control-Expose-Headers","Authorization");
            // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
            if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
                httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
                return false;
            }
        }catch (Exception e) {
            e.printStackTrace();
        }

        return super.preHandle(request, response);
    }
    private String createToken(String headerToken) {
        String username = JWTUtil.getUsername(headerToken);
        return JWTUtil.sign(username);
    }
}

然后前端稍作修改,直接从第一个请求中获取新Token存入localStorage,然后重新请求,示例代码如下:

// 标记 Token 是否正在刷新
let isRefreshingToken = false;
// 请求队列
let requestQueue = [];

// 模拟后端接口:刷新 api
async function refreshApi() {
    isRefreshingToken = true;
    try {
        // 执行队列中的请求
        requestQueue.forEach(request => request(localStorage.getItem('token')));
        requestQueue = [];
    } catch (error) {
        console.error('Token 刷新失败', error);
        requestQueue = [];
        throw error;
    } finally {
        isRefreshingToken = false;
    }
}

// 发起请求的函数
// 封装通用的请求函数
const makeRequest = async (url, options = {}) => {
    return new Promise(async (resolve, reject) => {
        const originalRequest = async (token) => {
            try {
                const newOptions = {
                    ...options,
                    headers: {
                        ...options.headers,
                        'Authorization': `${token}`
                    },
                    method: 'GET'
                };
                const response = await fetch(url, newOptions);
                // 响应拦截
                const data = await toData(response);
                if (response.ok) {
                    if (data.body === "没有登录") {
                        if (!isRefreshingToken) {
                            console.log(response.headers.get("authorization"))
                            const authorization = response.headers.get("authorization");
                            localStorage.setItem("token", authorization);// 更新token
                            requestQueue.push((newToken) => originalRequest(newToken));
                            await refreshApi();
                        } else {
                            requestQueue.push((newToken) => originalRequest(newToken));
                        }
                    }
                } else if (!response.ok) {
                    if (response.status === 401) { // 如果返回401则直接跳转登录页面
                        window.location.href = "./login.html"
                    }
                    throw new Error(`Request failed with status ${response.status}`);
                }
                resolve(data);
            } catch (error) {
                reject(error);
            }
        };

        // 正常发送请求
        await originalRequest(localStorage.getItem("token"));
    });
};

function toData(data) {
    const contentType = data.headers.get('Content-Type');
    if (contentType && contentType.includes('application/json')) {
        return data.json();
    } else {
        return data.text();
    }
}
export { makeRequest };

使用第一个接口返回的新Token重新发起请求,执行结果如图所示:

在这里插入图片描述

相比较于前两种方式,响应拦截不需要复杂的处理,没有额外字段,避免不必要的判断和请求,虽然会多发送一次请求,但是没有单独提供Token接口,并且新Token的随机性给系统的安全带来了极大的保障。


http://www.kler.cn/a/564899.html

相关文章:

  • DeepSeek如何辅助学术量化研究
  • C#上位机--一元运算符
  • AI绘画软件Stable Diffusion详解教程(3):Windows系统本地化部署操作方法(通用版)
  • 【杂谈】-2025年2月五大大型语言模型(LLMs)
  • 使用TortoiseGit配合BeyondCompare实现在Git仓库中比对二进制文件
  • 2025-VNCTF-wp
  • 2025年生成式人工智能与数字媒体国际学术会议(GADM 2025)
  • 设计后端返回给前端的返回体
  • 前端项目配置初始化
  • AI 与光学的融合:开启科技变革新征程
  • React 源码揭秘 | Ref更新原理
  • [算法]——前缀和(二)
  • 事故02分析报告:慢查询+逻辑耦合导致订单无法生成
  • Lua语言入门(自用)
  • tableau之网络图和弧线图
  • 波导阵列天线 学习笔记11双极化全金属垂直公共馈电平板波导槽阵列天线
  • Lucene硬核解析专题系列(一):Lucene入门与核心概念
  • vue3+ts实现动态下拉选项为图标
  • Java高频面试之SE-23
  • Linux8-互斥锁、信号量