[JWT]在项目中使用JWT,不依赖安全框架

669 阅读8分钟

ChatGPT Image 2026年1月15日 11_35_22.png

什么是JWT?

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

尽管可以对 JWT 进行加密以在各方之间提供保密性,但我们将重点关注已签名的令牌。签名令牌可以验证其中包含的声明的完整性,而加密令牌可以向其他方隐藏这些声明。 当使用公钥/私钥对对令牌进行签名时,签名还证明只有持有私钥的一方才是签名者。

什么时候使用JWT

以下是 JSON Web Tokens 有用的一些场景:

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

JWT的组成结构

在其紧凑的形式中,JSON Web Tokens 由三部分组成,由点 ( .) 分隔,它们是:

  • header:头部。
  • payload:有效载荷,也就是存储的数据。
  • signature:签名。

因此,JWT 通常如下所示。

xxxxx.yyyyy.zzzzz

让我们分解不同的部分。

标头

标头通常由两部分组成:令牌的类型,即 JWT,以及所使用的签名算法,例如 HMAC SHA256 或 RSA。

例如:

{ "alg": "HS256", "typ": "JWT" }

然后,这个 JSON 被Base64Url编码以形成 JWT 的第一部分。

有效载荷

令牌的第二部分是有效负载,其中包含声明。声明是关于实体(通常是用户)和附加数据的陈述。声明分为三种类型:已注册声明、公共声明和私人声明。

  • 已注册声明:这些是一组预定义的声明,它们不是强制性的,但建议使用,以提供一组有用的、可互操作的声明。其中一些是: iss(发行者)、 exp(到期时间)、 sub(主题)、 aud(受众)和其他

  • 公共声明:这些可以由使用 JWT 的人随意定义。但是为了避免冲突,它们应该在IANA JSON Web 令牌注册表中定义,或者定义为包含抗冲突名称空间的 URI。

  • 私人声明:这些是为在同意使用它们的各方之间共享信息而创建的自定义声明,既不是注册声明也不是公共声明。

一个示例有效载荷可以是:

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

然后,有效负载经过Base64Url编码,形成 JSON Web Token 的第二部分。

请注意,对于签名的令牌,此信息虽然受到防止篡改的保护,但任何人都可以读取。不要将秘密信息放在 JWT 的有效负载或标头元素中,除非它是加密的。

签名

要创建签名部分,您必须采用编码标头、编码有效负载、密钥、标头中指定的算法,然后对其进行签名。

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

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

签名用于验证消息在此过程中没有被更改,并且在使用私钥签名的令牌的情况下,它还可以验证 JWT 的发送者是否如其所说。

把所有放在一起

输出是由点分隔的三个 Base64-URL 字符串,可以在 HTML 和 HTTP 环境中轻松传递,同时与基于 XML 的标准(如 SAML)相比更加紧凑。

下面显示了一个 JWT,它对前面的标头和有效负载进行了编码,并使用秘密进行了签名。

image.png

如果您想使用 JWT 并将这些概念付诸实践,您可以使用jwt.io Debugger来解码、验证和生成 JWT。

JSON Web 令牌如何工作?

在身份验证中,当用户使用其凭据成功登录时,将返回一个 JSON Web Token。由于令牌是凭据,因此必须非常小心以防止出现安全问题。通常,您不应将令牌保留的时间超过要求的时间。

由于缺乏安全性,您也不应该将敏感的会话数据存储在浏览器存储中。

每当用户想要访问受保护的路由或资源时,用户代理都应发送 JWT,通常在使用Bearer模式的 Authorization标头中。标头的内容应如下所示:

Authorization: Bearer <token>

在某些情况下,这可以是无状态授权机制。服务器的受保护路由将检查标头中是否存在有效的 JWT Authorization,如果存在,则允许用户访问受保护的资源。如果 JWT 包含必要的数据,则可能会减少为某些操作查询数据库的需要,尽管情况可能并非总是如此。

请注意,如果您通过 HTTP 标头发送 JWT 令牌,您应该尽量防止它们变得太大。有些服务器不接受超过 8 KB 的标头。如果您试图在 JWT 令牌中嵌入过多信息,例如通过包含所有用户的权限,您可能需要替代解决方案,例如Auth0 Fine-Grained Authorization

如果令牌在Authorization标头中发送,跨源资源共享 (CORS) 将不会成为问题,因为它不使用 cookie。

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

JSON Web Token 是如何工作的

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

请注意,对于已签名的令牌,令牌中包含的所有信息都会暴露给用户或其他方,即使他们无法更改。这意味着您不应将秘密信息放入令牌中。

在项目中使用

下面介绍在 SpringBoot 项目中使用 JWT,不使用 Spring Security、Shiro 等安全框架。

首先需要导入依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.14.0</version>
</dependency>

然后编写 JWT 工具类:

public class JwtUtils {
 
    // 盐值
    private static final String SALT = "13770389674";
    
    private static final String JWT_KEY_PHONE = "phone";

    /**
     * 根据手机号生成token,一天过期
     * 如何生成,这里可以自己定义。
     * 使用JWT自身的过期时间设置那就不需要Redis了
     */
    public static String generateToken(String phone) {
        Map<String, String> map = new HashMap<String, String>() {{
            put(JWT_KEY_PHONE, phone);
            // 这里还可以再加东西
        }};
        
        // 过期时间自己定义,可以在全局定义一个常量,然后加上现在的时间得到过期时间
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.DATE, 1);
        Date date = calendar.getTime();

        // 填充数据负载
        JWTCreator.Builder builder = JWT.create();
        map.forEach(builder::withClaim);

        return builder.withExpiresAt(date).sign(Algorithm.HMAC256(SALT));
    }

    /**
     * 解析token,通过token拿到里面的数据比如说用户名、手机号之类的数据
     */
    public static String parseToken(String token) {
        DecodedJWT jwt = JWT.require(Algorithm.HMAC256(SALT)).build().verify(token);
        String phone = jwt.getClaim(JWT_KEY_PHONE).asString();
        return phone;
    }

    public static void main(String[] args) {
        String phone = "13770366963";
        String s = generateToken(phone);
        System.out.println(s);
        String phone = parseToken(s);
        System.out.println("手机号:" + phone);
    }
}

这一步完成之后,编写一个拦截器,过滤请求,从请求头中拿到 token 并解析判断是否有效:

public class JwtInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        boolean flag = true;
        String result = "";
        String token = request.getHeader("Authorization");
        try {
            JwtUtils.parseToken(token);
        } catch (SignatureVerificationException e) {
            result = "token sign error";
            flag = false;
        } catch (TokenExpiredException e) {
            result = "token expired";
            flag = false;
        } catch (AlgorithmMismatchException e) {
            result = "token algorithm error";
            flag = false;
        } catch (Exception e) {
            result = "token invalid";
            flag = false;
        }
        
        return flag;
    }
}

编写一个配置类,注册拦截器:

public class InterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JwtInterceptor())
                .addPathPatterns("/**")  // 拦截的路径
                .excludePathPatterns("/verification-code", "/verify-code-check");  // 排除的路径
    }
}

token 是放在请求头的 Authorization 字段中。这里就是全靠 JwtUtils 判断是否有效,没有使用 Redis 存储 token 判断有效期。下一节介绍使用 Redis 存储 token,并判断有效期,这样做更安全一点。

使用Redis存储token

需要使用到 StringRedisTemplate,然后还要使用 @Component 注解修饰拦截器:

@Component
public class JwtInterceptor implements HandlerInterceptor {
    
    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        boolean flag = true;
        String result = "";
        String token = request.getHeader("Authorization");
        TokenResult tokenResult = null;
        try {
            tokenResult = JwtUtils.parseToken(token);
        } catch (SignatureVerificationException e) {
            result = "token sign error";
            flag = false;
        } catch (TokenExpiredException e) {
            result = "token expired";
            flag = false;
        } catch (AlgorithmMismatchException e) {
            result = "token algorithm error";
            flag = false;
        } catch (Exception e) {
            result = "token invalid";
            flag = false;
        }
        
        if (tokenResult == null) {
            result = "token invalid";
            flag = false;
        } else {
            // 从redis取出token
            String phone = tokenResult.getPhone();
            String identity = tokenResult.getIdentity();
            // 生成token的key,前缀自己定义
            String tokenKey = RedisPrefixUtil.generateTokenKey(phone, identity);
            String redisToken = redisTemplate.opsForValue().get(tokenKey);
            if (StrUtil.isEmpty(redisToken)) {
                result = "token invalid";
                flag = false;
            } else {
                if (!token.trim().equals(redisToken.trim())) {
                    result = "token invalid";
                    flag = false;
                }
            }
        }
        
        if (!flag) {
            PrintWriter out = response.getWriter();
            out.print(JSONUtil.toJsonStr(R.fail(result)));
        }
        return flag;
    }
}

在配置类里面注入拦截器 Bean:

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    
    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/verification-code", "/verify-code-check");
    }
}

这样就是实现了使用 Redis 存储并校验 token 时效性功能。