1 Token

代替Session,就是一个加密的字符串

1.1 什么是token

Token是当用户第一次访问服务端,由服务端生成的一串加密字符串,以作后续客户端进行请求的一个通行令牌。当第一次登录后,服务器生成一个Token字符串,并将此字符串返回给客户端,以后客户端请求需要带上这个Token发送服务器,进行请求数据即可,无需再次带上用户名和密码。

1.2 使用场景

接口使用限制(聚合数据,天行数据,阿里云接口等);
登录场景(客户端登录后,服务器签发token返回客户端);
有时效的url链接控制(密保邮箱找回密码;邮箱激活账号);

2.jjwt组件

2.1 认识jjwt

生成解析token字符串的常用组件有auth0,jjwt,这里选用jjwt进行学习。

JJWT旨在成为最易于使用和理解的库,用于在jvm和Android上创建和验证JSON Web Token(JWT)。对JWT进行加密签名后,称为JWS

官网:https://github.com/jwtk/jjwt

JWT表示形式是一个字符串,该字符串包含三个部分,每个部分之间都用.进行分隔,每个部分都是Base64URL编码的。如下:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJpYXQiOjE3NjAyNTg0MDcsImV4cCI6MTc2MDI2MjAwN30.PtwFOZ9ndrnqt6IQO8DsxlSPt9G-wD11yBgHD6t-jNQ1

Header.Payload.Signature

第一部分:Header标头,说明算法、类型

{

“alg”: “HS256”

}

第二部分:Payload(body) 存放用户信息(Claims,声明),jwt中需要包含的Claims认证数据,claims分为标准cliams与自定义。

类型示例含义
标准字段(Registered Claims)sub, iss, iat, exp, aud 等JWT 规范里定义好的保留字段
自定义字段(Custom Claims)userId, role, email, nickname开发者自己添加的数据

{

“sub”: “24234242”

}

第三部分:Signature,签名,用来防篡改,它是通过将标头和正文的组合通过标头中指定的算法加密来计算的。起到鉴伪作用。

2.2 使用

  • 添加依赖
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>


完整的工具类

创建工具类,下面是工具类完整代码, 后面有拆解

@Component
public class JwtUtil {

    private final Key key;
    private final long expireTime;


    // spring创建bean的时候,会先解析依赖, 扫描带有@Component, @ConfigurationProperties 的类,
    // spring先创建了JWTConfig, 并把配置文件里面的属性读了进来
    //在创建JwtUtil的时候发现需要JWTConfig, 就把已经准备好的JWTConfig传了进来
    public JwtUtil(JWTConfig jwtConfig) {
        // Base64 解码后生成 Key
        this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtConfig.getSecretKey()));
        this.expireTime = jwtConfig.getExpireTime();
    }


    /**
     * 生成密钥 每次执行这段代码都会生成一个不同的密钥,所以它只在开发阶段执行一次,
     * 拿到密钥字符串后,就可以直接写进配置文件:
     */
    public static void generateKey() {
        Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        String secretString = Encoders.BASE64.encode(key.getEncoded());
        System.out.println("生成的密钥是:" + secretString);
    }

    /**
     * 生成token
     * @return
     */
    public String generateToken(String userId) {

        Instant now = Instant.now();   //当前时间
        return Jwts.builder()
                // 设置payload标准字段
                .setSubject(userId) // 表示哪个用户的 Token
                .setIssuedAt(Date.from(now))  //签发时间
                .setExpiration(Date.from(now.plusMillis(this.expireTime))) //过期时间
                //设置自定义的payload
                // .claim("role", "user")   // 需要就添加自定义字段
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }


    /**
     * 解析并验证 Token
     */
    public Claims parseToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)     // 设置签名的 key
                    .build()
                    .parseClaimsJws(token)  // 会自动验证签名、过期时间,如果签名错误或过期,会直接抛异常
                    .getBody();             // 返回 payload 部分 (Claims)
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("token expired");
        } catch (JwtException e) {
            throw new RuntimeException("token invalid");
        }
    }


}
拆解代码
  • 生成密钥
//JWT规范确定了12种标准签名算法:3种对称密钥算法和9种非对称密钥算法。
//HS256:使用SHA-256的HMAC
//HS384:使用SHA-384的HMAC
//HS512:使用SHA-512的HMAC
//ES256:使用P-256和SHA-256的ECDSA
//ES384:使用P-384和SHA-384的ECDSA
//ES512:使用P-521和SHA-512的ECDSA
//RS256:使用SHA-256的RSASSA-PKCS-v1_5
//RS384:使用SHA-384的RSASSA-PKCS-v1_5
//RS512:使用SHA-512的RSASSA-PKCS-v1_5
//PS256:使用SHA-256和MGF1与SHA-256的RSASSA-PSS
//PS384:使用SHA-384和MGF1与SHA-384的RSASSA-PSS
//PS512:使用SHA-512和MGF1与SHA-512的RSASSA-PSS
//前三者是对称秘钥算法,后9个是非对称算法。

//以下代码采用HS256对称秘钥算法。
/**
 *   生成密钥 每次执行这段代码都会生成一个不同的密钥,所以它只在开发阶段执行一次,
 *   拿到密钥字符串后,就可以直接写进配置文件:
 */
public static void generateKey() {
    Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    String secretString = Encoders.BASE64.encode(key.getEncoded());
    System.out.println("生成的密钥是:"+secretString);
}


image-20251012094725792

  • 复制生成的密钥写入配置文件 application.yml中

    jwt:
      secret-key: B8GMT15rEkshJBGlGu3W7ClQGg9yavvU/VHjTOUOpVg=
      expire-time: 3600000  # 1小时(可选)
    
  • 创建配置属性类

    /**
    *  配置属性类
    */
    @Data
    @Component
    @ConfigurationProperties(prefix = "jwt")
    public class JWTConfig {
        private String secretKey;
        private Long expireTime;
    }
    

    Spring Boot 会在加载配置文件时自动识别多种常见命名风格,并互相兼容。

    比如下面这些写法在绑定时被认为是等价的

YAML 中的写法Java 属性对应
secret-keysecretKey
secret_keysecretKey
SECRET_KEYsecretKey
SecretKeysecretKey
secretKeysecretKey

Spring Boot 允许你在配置文件中自由使用中划线、小写、下划线或驼峰格式。最终都会被正确映射到 Java Bean 属性中。

  • 生成token
/**
 * 生成token
 * @return
 */
public String generateToken(String userId) {

    Instant now = Instant.now();   //当前时间
    return Jwts.builder()
            // 设置payload标准字段
            .setSubject(userId) // 表示哪个用户的 Token
            .setIssuedAt(Date.from(now))  //签发时间
            .setExpiration(Date.from(now.plusMillis(this.expireTime))) //过期时间
            // .claim("role", "user")   // 需要就添加自定义字段
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
}
  • 解析token
/**
 * 解析并验证 Token
 */
public Claims parseToken(String token) {
    try {
        return Jwts.parserBuilder()
                .setSigningKey(key)     // 设置签名的 key
                .build()
                .parseClaimsJws(token)  // 会自动验证签名、过期时间,如果签名错误或过期,会直接抛异常
                .getBody();             // 返回 payload 部分 
    } catch (ExpiredJwtException e) {
        throw new RuntimeException("token expired");
    } catch (JwtException e) {
        throw new RuntimeException("token invalid");
    }
}

3.登录场景使用token

后端部分代码

  • 服务端向客户端发送token
 //写在controller里面
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("Authorization", "Bearer " + token); //这里存储的数据在header里面
httpHeaders.add("Access-Control-Expose-Headers", "Authorization");   //这里要暴露出去
Result<Object> result = Result.success("这里的数据返回到body里面");
ResponseEntity<Result<Object>> responseEntity = new ResponseEntity<>(result, httpHeaders, HttpStatus.OK);


注意

若要让前端能正确读取到响应头中的自定义字段(如 Authorization),

后端需显式暴露该字段,例如在 Spring Boot 中:

image-20251013110856343

image-20251013110912784

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final JwtInterceptor jwtInterceptor;

    public WebConfig(JwtInterceptor jwtInterceptor) {
        this.jwtInterceptor = jwtInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/auth/login",
                        "/error",
                        "/public/**",
                        "/static/**",
                        "/swagger-ui/**",
                        "/v3/api-docs/**",
                        "/actuator/**"
                )
                .order(Ordered.HIGHEST_PRECEDENCE);
    }

    @Override
    public void addCorsMappings(CorsRegistry r) {
        r.addMapping("/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET","POST","PUT","DELETE","OPTIONS")
                .allowedHeaders("Authorization","Content-Type","X-Requested-With")
                .exposedHeaders("Authorization","Content-Disposition")
                // 和 Access-Control-Expose-Headers: Authorization 是同一个目的
                .allowCredentials(true)
                .maxAge(3600);
    }
}
@Component
public class JwtInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtUtil jwtUtil;

//    @Autowired
//    private RedisTemplate<String, String> redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        //放行预检请求
        if("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        String header = request.getHeader("Authorization");

        if (header == null || !header.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }

        String token = header.substring(7);
        try {
            Claims claims = jwtUtil.parseToken(token);
            String userId = claims.getSubject();
//
//            // 可选:在 Redis 中校验 token
//            String redisToken = redisTemplate.opsForValue().get("login:token:" + userId);
//            if (redisToken == null || !redisToken.equals(token)) {
//                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
//                return false;
//            }

            // 保存到请求作用域,Controller 可以直接拿 userId
            request.setAttribute("userId", userId);
            return true;

        } catch (JwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }
}
  • 登录接口省略了, 就是判断有没有token, 用户登录成功就下发token, 后续访问接口只需要验证是否有token

    对此接口进行测试

image-20251012171701755

前端部分代码

  • 前端接收token, 并存储起来, 下次发请求携带即可. 用请求过滤器和pinia进行实现,这里不展示
    console.log(res.headers.authorization) 

image-20251013102341452

  • token 在响应头(Authorization)中返回;

  • 前端可通过 res.headers.authorization 获取;

  • 存入 Pinia 或 localStorage 后,在 Axios 请求拦截器中自动携带;

  • 这样后续接口请求即可实现免登录访问。

更多推荐