SpringSecurity入门篇(3):JWT的生成、存储与验证

0 阅读12分钟

大家好,我是加洛斯,是一名全栈工程师👨‍💻,这里是我的知识笔记与分享,旨在把复杂的东西讲明白。如果发现有误🔍,万分欢迎你帮我指出来!废话不多说,正文开始 👇

一、基础概念

1.1 什么是JWT?

JWT,全称是JSON Web Token是一种开放标准(RFC 7519),它用于在各方之间作为JSON对象安全地传输信息。也通常用于身份验证和授权。

1.2 为什么要用JWT?

  • 服务器不需要存储会话信息:传统的session需要在服务器端存储会话数据,而JWT将所有必要信息包含在token中
  • 易于水平扩展:不需要会话复制或共享存储,适合分布式系统和微服务架构
特性JWT传统Session
状态无状态有状态
存储Token自包含服务器存储
扩展性容易扩展需要会话共享
跨域原生支持需要额外配置
性能减少数据库查询每次验证需查库

1.3 JWT的结构

JWT 由三部分组成,用点号分隔:

xxxxx.yyyyy.zzzzz
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

1.3.1 Header

Header(头部)包含令牌类型和签名算法,JWT的头部通常由两部分组成:

  • typ:令牌类型,固定为"JWT"
  • alg:签名算法,如HS256、RS256等

示例:Base64编码前:

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

Base64编码后:  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

1.3.2 Payload

Payload(负载):包含声明,即要传输的数据,它有三种类型的声明:

  1. 标准声明(建议但不强制):
  • iss (issuer):签发者
  • sub (subject):主题
  • aud (audience):接收方
  • exp (expiration time):过期时间
  • nbf (not before):生效时间
  • iat (issued at):签发时间
  • jti (JWT ID):唯一标识
  1. 公共声明

可以定义公开的声明,但应避免冲突

  1. 私有声明

自定义的业务数据,我们可以不使用官方定义的任何字段,我们可以使用任何字段来传输我们想要的数据。

示例:Base64编码前:

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

Base64编码后:  eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0

1.3.3 Signature

Signature:验证令牌完整性的签名,用于验证消息在传输过程中没有被篡改。

首先,需要指定一个密钥,这个密钥只有服务器才知道,不能泄露给用户,然后,使用Header里面指定的签名算法(默认是HMACSHA256),按照下面的公式产生签名:

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

示例签名:  SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

二、生成JWT

我们有很多的工具可以帮我们生成JWT,我们只需要简单配置一下就能使用,本章讲述第一个工具java-jwt

2.1 生成jwt

首先我们先引入依赖:

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

然后我们创建一个JWTUtil工具类,并创建一个生成JWT的方法creatToken,这个代码很简单也很固定,一般情况下我们只需要配置SECRET与参数,也就是负载中的信息就可以。

public class JWTUtil {

    private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
    public static void main(String[] args) {
        LoginUser user = new LoginUser();
        user.setName("zhangsan");
        String token = creatToken(user);
        System.out.println(token);
    }

    /**
     * 生成JWT
     */
    private static String creatToken(LoginUser user){
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256");
        header.put("typ", "JWT");
        return JWT.create()
                .withHeader(header)  // 设置头部信息
                .withClaim("name", user.getName()) // 添加负载
                .sign(Algorithm.HMAC256(SECRET)); // 设置签名
    }
}

我们还写了个main方法打印一下jwt:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4ifQ.AmjB3ghyybghULaEqkkNJiaMtZ-Zlqb3U8i0ZLrW2v4

2.2 验证jwt

我们使用JWTVerifier对象中的.verify方法来验证jwt是否正确且没被篡改过。

public class JWTUtil {

    private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
    public static void main(String[] args) {
        LoginUser user = new LoginUser();
        user.setName("zhangsan");
        String token = creatToken(user);
        boolean verify = verify(token);
        System.out.println("无改变验证:"+verify);
        boolean verify2 = verify(token+123);
        System.out.println("有改变验证:"+verify2);
    }

    /**
     * 生成JWT
     */
    private static String creatToken(LoginUser user){
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256");
        header.put("typ", "JWT");
        return JWT.create()
                .withHeader(header)  // 设置头部信息
                .withClaim("name", user.getName()) // 添加负载
                .sign(Algorithm.HMAC256(SECRET)); // 设置签名
    }

    /**
     * 验证JWT
     */
    @SuppressWarnings("CallToPrintStackTrace")
    public static boolean verify(String token){
        try {
            // 使用秘钥创建验证器
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            // 验证JWT对象,如果没有抛出异常,则验证成功
            jwtVerifier.verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
}

结果如下,我们看到如果改变token它会抛异常出来

无改变验证:true
有改变验证:false
com.auth0.jwt.exceptions.SignatureVerificationException: The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256
	at com.auth0.jwt.algorithms.HMACAlgorithm.verify(HMACAlgorithm.java:57)
	at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:463)
	at com.auth0.jwt.JWTVerifier.verify(JWTVerifier.java:445)
	at com.example.util.JWTUtil.verify(JWTUtil.java:46)
	at com.example.util.JWTUtil.main(JWTUtil.java:20)

2.3 解析jwt

public class JWTUtil {

    private static final String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
    public static void main(String[] args) {
        LoginUser user = new LoginUser();
        user.setName("zhangsan");
        String token = creatToken(user);
        // 解析
        System.out.println(getPayload(token));
    }

    /**
     * 生成JWT
     */
    private static String creatToken(LoginUser user){
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256");
        header.put("typ", "JWT");
        return JWT.create()
                .withHeader(header)  // 设置头部信息
                .withClaim("name", user.getName()) // 添加负载
                .sign(Algorithm.HMAC256(SECRET)); // 设置签名
    }
    /**
     * 获取JWT中的负载(解析jwt)
     */
    public static String getPayload(String token){
        try {
            // 使用秘钥创建验证器
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            Claim name = decodedJWT.getClaim("name");
            return name.asString();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    /**
     * 验证JWT
     */
    @SuppressWarnings("CallToPrintStackTrace")
    public static boolean verify(String token){
        try {
            // 使用秘钥创建验证器
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            // 验证JWT对象,如果没有抛出异常,则验证成功
            jwtVerifier.verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
}

看输出,一点问题没有,至此一个简单的jwt生成案例就算是成功了。

image.png

三、存储JTW

我们先看下面图片,它简单的展示了我们jwt的生成与存储过程,我们将基于此来完成我们的代码编写。

image.png

3.1 配置redis

首先我们需要先准备一下redis的环境,这块是一个大知识点所以肯定并不能详细的讲,具体如何下载如何配置请移步到其他详细讲redis的文章

  1. 准备依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 修改配置
data:
  redis:
    host: 127.0.0.1(改成你自己的)
    port: 6379
    database: 1
    timeout: 2000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: -1ms
  1. 准备RedisConfig

这是一个固定代码,用于给redis存储序列化用的,否则存到库里面是乱码。

@Configuration
public class RedisConfig {

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

        // 使用Jackson序列化Value
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL);

        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(om);

        // Key使用String序列化
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());

        // Value使用JSON序列化
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

3.2 Constant常量接口

public interface Constant {
    // JWT秘钥
    String SECRET = "6a5w5*;d5656z/54aw1d2*z55cc";
    // redis的key的命名规范:项目名:模块名:功能名[:唯一业务参数]
    String REDIS_TOKEN_KEY = "security:user:login";
    // 登录接口
    String LOGIN_URL = "/login";
    // 登出接口
    String LOGOUT_URL = "/logout";
    // 后端请求头中token的名称
    String HEADER_TOKEN_NAME = "token";
}

3.3 JWTUtil

/**
 * JWT工具类,用于生成、解析和验证JWT令牌
 * 
 * JWT (JSON Web Token) 是一种开放标准(RFC 7519),用于在网络应用环境间安全地传递声明。
 * JWT通常由三部分组成:Header(头部)、Payload(载荷)、Signature(签名)
 */
@Component
public class JWTUtil {
    
    /**
     * 生成JWT令牌
     * @param user 用户信息对象,包含用户身份相关数据
     * @return 加密后的JWT令牌字符串
     */
    public String creatToken(LoginUser user) {
        // 创建JWT头部信息
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256"); // 指定签名算法
        header.put("typ", "JWT");   // 指定令牌类型
        
        // 生成令牌
        return JWT.create()
                .withHeader(header)                                    // 设置头部信息
                .withClaim("loginUser", JSONUtil.toJsonStr(user))      // 设置用户信息载荷
                .withIssuedAt(new Date())                             // 设置签发时间
                .withExpiresAt(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) // 设置过期时间为24小时
                .sign(Algorithm.HMAC256(Constant.SECRET));             // 使用指定密钥进行签名
    }
    
    /**
     * 从JWT令牌中获取载荷信息
     * 
     * @param token JWT令牌字符串
     * @return 存储在令牌中的用户信息JSON字符串
     * @throws RuntimeException 当令牌无效或解析失败时抛出异常
     */
    public String getPayload(String token) {
        try {
            // 使用密钥创建验证器
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(Constant.SECRET)).build();
            // 验证并解码JWT令牌
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            // 获取用户信息载荷
            Claim loginUser = decodedJWT.getClaim("loginUser");
            return loginUser.asString();
        } catch (JWTVerificationException e) {
            throw new RuntimeException("JWT解析失败", e);
        }
    }
    
    /**
     * 验证JWT令牌的有效性
     * 
     * @param token JWT令牌字符串
     * @return 如果令牌有效返回true,否则返回false
     */
    public boolean verify(String token) {
        try {
            // 使用密钥创建验证器
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(Constant.SECRET)).build();
            // 验证JWT令牌,如果验证失败会抛出异常
            jwtVerifier.verify(token);
            // 如果没有抛出异常,说明验证成功
            return true;
        } catch (JWTVerificationException | IllegalArgumentException e) {
            return false;
        }
    }
}

3.4 登录写入流程

我们之前学习过登录成功后的handle,那么生成jwt令牌和写入redis的逻辑写在这个handle里面正好,下面看代码,这个代码是认证成功后调用的方法,此方法会在用户成功登录后被调用,执行以下操作:

  1. 生成JWT令牌
  2. 将令牌存储到Redis中(用于后续的令牌验证)
  3. 构建并返回成功响应给客户端
/**
 * 自定义登录成功处理器
 * 当用户认证成功后,此处理器会生成JWT令牌,将其保存到Redis中,
 * 并向客户端返回包含令牌的成功响应。
 * 实现了Spring Security的AuthenticationSuccessHandler接口。
 * 请引入hutool-all这个依赖
 */
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Resource
    private JWTUtil jwtUtil;
    
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 认证成功后调用的方法
     * @param request HTTP请求对象,提供客户端请求的请求信息
     * @param response HTTP响应对象,用于向客户端发送响应
     * @param authentication 包含认证信息的对象,包含已认证的用户信息
     * @throws IOException 当写入响应时发生IO错误
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, 
                                      HttpServletResponse response, 
                                      Authentication authentication) throws IOException {
        // 设置响应的内容类型为JSON格式,字符编码为UTF-8
        response.setContentType("application/json;charset=UTF-8");
        
        // 从认证对象中获取已认证的用户信息
        LoginUser principal = (LoginUser) authentication.getPrincipal();
        
        // 1. 根据用户信息生成JWT令牌
        String token = jwtUtil.creatToken(principal);

        // 2. 将生成的令牌存储到Redis中,用于后续验证
        // Redis的键格式为: security:user:login (定义在Constant中)
        // Redis的字段为: 用户ID
        // Redis的值为: JWT令牌
        redisTemplate.opsForHash().put(Constant.REDIS_TOKEN_KEY,String.valueOf(principal.getId()), token);

        // 3. 构建成功响应结果
        R result = R.builder()
                    .code(200)           // 成功状态码
                    .msg("登录成功!")     // 成功提示信息
                    .data(token)         // 返回JWT令牌
                    .build();
        
        // 4. 将响应对象转换为JSON字符串并写入响应体
        String jsonStr = JSONUtil.toJsonStr(result);
        response.getWriter().write(jsonStr);
    }
}

3.5 验证

打开登录页面,点击登录,我们查看一下数据库,可以看到我们成功存储了这个id为1的用户的登录token image.png

四、请求验证

除了我们配置的无需验证的URL以外的其他请求,springsecurity都会为我们验证该请求的发起者是否登录,因此每次请求中我们都需要在请求头携带我们的token,然后对比token,当所有步骤都通过后,我们在security上下文存入一个登录对象即可验证通过完成请求。

image.png

至于在哪验证,自然是手写一个过滤器来验证,来看下面环节。

4.1 过滤器

该过滤器继承自OncePerRequestFilter,确保每次请求只被执行一次。 过滤器的主要职责包括:

  1. 检查请求是否为登录请求,如果是则直接放行
  2. 验证请求中的JWT令牌是否合法
  3. 检查Redis中存储的令牌与请求令牌是否一致
  4. 将已验证的用户信息设置到Spring Security上下文中
/**
 * JWT令牌过滤器,负责验证请求中的JWT令牌
 */
@Component
public class TokenFilter extends OncePerRequestFilter {

    @Resource
    private JWTUtil jwtUtil;
    
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 执行过滤器的核心方法
     * 
     * @param request  HttpServletRequest对象
     * @param response HttpServletResponse对象
     * @param filterChain FilterChain对象,用于执行后续过滤器
     * @throws ServletException Servlet异常
     * @throws IOException IO异常
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        // 设置响应内容类型为JSON格式
        response.setContentType("application/json;charset=UTF-8");
        
        // 1. 检查是否为登录请求,如果是则直接放行,让后续过滤器链处理
        if (request.getRequestURI().equals(Constant.LOGIN_URL)) {
            filterChain.doFilter(request, response);
            return; // 确保在匹配登录URL时直接返回
        }
        
        // 2. 从请求头中获取JWT令牌
        String token = request.getHeader(Constant.HEADER_TOKEN_NAME);
        
        // 3. 检查令牌是否存在
        if (!StringUtils.hasText(token)) {
            // 令牌为空,返回错误响应
            sendErrorResponse(response, 901, "请求token为空");
            return;
        }
        
        // 4. 验证JWT令牌是否合法
        if (!jwtUtil.verify(token)) {
            // 令牌非法,返回错误响应
            sendErrorResponse(response, 902, "请求token非法");
            return;
        }
        
        // 5. 从JWT中提取用户信息
        LoginUser loginUser;
        try {
            String payload = jwtUtil.getPayload(token);
            loginUser = JSONUtil.toBean(payload, LoginUser.class);
        } catch (Exception e) {
            // 解析用户信息失败,返回错误响应
            sendErrorResponse(response, 904, "用户信息解析失败");
            return;
        }
        
        // 6. 验证Redis中的令牌是否与当前令牌一致(双重验证,防止令牌盗用)
        String redisToken = (String) redisTemplate.opsForHash().get(
                Constant.REDIS_TOKEN_KEY, String.valueOf(loginUser.getId()));
        
        if (redisToken == null || !token.equals(redisToken)) {
            // Redis中不存在对应的令牌或令牌不匹配,返回错误响应
            sendErrorResponse(response, 903, "请求token错误");
            return;
        }
        
        // 7. 令牌验证通过,将用户信息设置到Spring Security上下文中
        // 创建认证令牌对象,其中包含用户信息和权限信息
        UsernamePasswordAuthenticationToken authenticationToken = 
            new UsernamePasswordAuthenticationToken(loginUser, null, 
                                                  AuthorityUtils.NO_AUTHORITIES);
        
        // 将认证对象设置到Security上下文中,以便后续组件使用
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        
        // 8. 放行请求,继续执行后续过滤器链
        filterChain.doFilter(request, response);
    }

    /**
     * 发送错误响应
     *
     * @param response HttpServletResponse对象
     * @param code 错误代码
     * @param message 错误消息
     * @throws IOException IO异常
     */
    private void sendErrorResponse(HttpServletResponse response, int code, String message) throws IOException {
        R result = R.builder().code(code).msg(message).build();
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 设置HTTP状态码为401
        response.getWriter().write(JSONUtil.toJsonStr(result));
    }
}

4.2 SecurityConfig

我们之前写过这个config,现在我们需要将我们刚刚写的filter添加到security过滤链里面去

/**
 * 创建一个 SecurityFilterChain 对象,用于配置 Spring Security 的安全过滤链
 */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,CorsConfigurationSource configurationSource) throws Exception {
    httpSecurity
            .formLogin((formLogin) -> formLogin.loginProcessingUrl(Constant.LOGIN_URL)
                    .successHandler(myAuthenticationSuccessHandler) // 登录成功处理器 如果登录成功,则会执行里面的代码
                    .failureHandler(failAuthenticationFailureHandler) // 登录失败处理器 如果登录失败,则会执行里面的代码
            )
            .logout(logout->
                    logout.logoutUrl(Constant.LOGOUT_URL).logoutSuccessHandler(myLogoutSuccessHandler)
            )
            .authorizeHttpRequests((authorize) ->
                    authorize.requestMatchers(Constant.LOGIN_URL).permitAll().anyRequest().authenticated())
            .csrf(AbstractHttpConfigurer::disable)
            //支持跨域请求
            .cors( (cors) -> cors.configurationSource(configurationSource))
            .sessionManagement(
                    // SESSION创建策略
                    session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            // 在登录filter后添加token过滤器
            .addFilterAfter(tokenFilter, UsernamePasswordAuthenticationFilter.class);
    return httpSecurity.build();
}

4.3 前端

我们获取到的token需要存储在浏览器当中的localStorage中,他是浏览器持久化数据的一个空间,即使关闭浏览器也不会清除数据。

我们先看登录函数

const handleSubmit = () => {
  const formData = new FormData()
  formData.append('username', loginForm.value.username)
  formData.append('password', loginForm.value.password)
  let token = window.localStorage.getItem('token')
  axios({
    url: 'http://localhost:8080/login',
    method: 'post',
    data: formData,
    responseType: 'json',
  })
    .then((res) => {
      if (res.data.code === 200) {
        console.log(res.data.data)
        window.localStorage.setItem('token', res.data.data)
        // 跳转到首页
        router.push('/home')
      } else {
        console.log(res.data.msg)
      }
    })
    .catch((err) => {
      console.log(err)
    })
}

home页当中写一个函数请求一些固定数据,携带token

onMounted(() => {
  // 获取用户信息
  axios
    .get('http://localhost:8080/user', {
      headers: {
        token: window.localStorage.getItem('token'),
      },
    })
    .then((res) => {
      console.log(res.data)
    })
})

4.4 验证

其实请求只会获得一个固定的string image.png

而前端登录成功后确实自动跳转到home页面并且成功获取到了数据 image.png

我们可以看一下删除请求头中的token会返回什么 image.png