一、JWT 密钥配置

Article detail

后端

2026/4/18 · 71 分钟阅读

一、JWT 密钥配置

在项目配置文件 src/main/resources/application.properties 中新增 JWT 签名密钥配置项。该密钥用于对生成的 JWT 令牌进行签名和验证,生产环境必须使用足够强度的自定义密钥。

# JWT 签名密钥(生产环境请务必更换为至少 32 字节的强密码)
jwt.secret=testjwttetc

安全提示:此处配置的示例密钥 testjwttetc 仅用于本地开发调试,长度不足且强度极弱。正式部署时,请通过环境变量或外部配置中心注入长度不低于 32 字符的强随机字符串。


二、创建 Security 核心包及组件

com.netflix.clone.demo 包下新建 security 子包,用于存放所有与安全认证相关的核心类。

2.1 JwtUtil - JWT 工具类

该类负责 JWT 令牌的生成、解析、校验等核心操作,被 Spring 容器管理,可在任意需要的地方注入使用。

文件路径com.netflix.clone.demo.security.JwtUtil

package com.netflix.clone.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;  // ✅ 正确的 Spring @Value 注解
import org.apache.el.lang.FunctionMapperImpl;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;

/**
 * JWT 工具类
 * <p>
 *     负责 JWT(JSON Web Token)的创建、解析与校验。
 *     该类被 Spring 管理(@Component),可以在任何需要的地方注入使用。
 * </p>
 */
@Component
public class JwtUtil {

    /**
     * Token 有效期:30 天(单位:毫秒)
     * 计算公式:30L * 24小时 * 60分钟 * 60秒 * 1000毫秒
     */
    private static final long JWT_TOKEN_VALIDITY = 30L * 24 * 60 * 60 * 1000;

    /**
     * JWT 签名密钥
     * 从配置文件 `application.properties` 中读取 `jwt.secret` 的值。
     * 如果未配置,则使用默认值 "defaultSercetKeyForNetflixClone"。
     *
     * 【安全提示】:该默认值长度不足 32 字节,不符合 JWT 安全规范,启动时会抛出 WeakKeyException。
     * 建议在配置文件中显式指定一个至少 32 字符的强密钥。
     */
    @Value("${jwt.secret:defaultSercetKeyForNetflixClone}")
    private String secret;

    /**
     * 获取用于签名的密钥对象(SecretKey)
     * <p>
     *     将配置的字符串密钥转换为 JJWT 库所需的 SecretKey 类型。
     * </p>
     *
     * @return SecretKey 签名密钥
     */
    private SecretKey getSigningKey(){
        // 注意:secret.getBytes() 未指定编码,在不同操作系统下可能表现不一致。
        // 且默认密钥长度不足时,Keys.hmacShaKeyFor() 会抛出异常。
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    // ==================== Token 解析相关方法 ====================
    /**
     * 从 Token 中提取用户角色
     *
     * @param token JWT 字符串
     * @return 角色字符串(如 "USER", "ADMIN")
     */
    public String getRoleFromToken(String token){
        // 调用通用解析方法,从 Claims 中取出 key 为 "role" 的值
        return getClaimFromToken(token, claims -> claims.get("role", String.class));
    }

    /**
     * 从 Token 中提取过期时间
     *
     * @param token JWT 字符串
     * @return 过期时间的 Date 对象
     */
    public Date getExpirationDateFromToken(String token){
        return getClaimFromToken(token, Claims::getExpiration);
    }

    /**
     * 从 Token 中提取用户名(Subject)
     * 本项目中使用邮箱作为 Subject,因此方法名虽为 Username,实际返回的是邮箱。
     *
     * @param token JWT 字符串
     * @return 邮箱地址
     */
    public String getUsernameFromToken(String token){
        return getClaimFromToken(token, Claims::getSubject);
    }

    /**
     * 通用方法:从 Token 中提取指定的 Claim 信息
     *
     * @param token          JWT 字符串
     * @param claimsResolver 一个函数式接口,定义如何从 Claims 中取值
     * @param <T>            返回值的泛型
     * @return 提取出的具体值
     */
    private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver){
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 解析 Token,获取其中存储的所有 Claims(负载数据)
     * <p>
     *     这是 JJWT 0.12.x 版本的标准解析写法。
     *     解析过程中会自动校验 Token 的签名是否正确。
     * </p>
     *
     * @param token JWT 字符串
     * @return Claims 对象,包含 Token 中所有的键值对
     */
    private Claims getAllClaimsFromToken(String token){
        return Jwts.parser()
                .verifyWith(getSigningKey())   // 设置验证签名所需的密钥
                .build()                       // 构建解析器
                .parseSignedClaims(token)      // 解析已签名的 Token
                .getPayload();                 // 获取负载内容
    }

    /**
     * 判断 Token 是否已过期
     *
     * @param token JWT 字符串
     * @return true-已过期,false-未过期
     */
    private Boolean isTokenExpired(String token){
        final Date expiration = getExpirationDateFromToken(token);
        // 判断过期时间是否在当前时间之前
        return expiration.before(new Date());
    }

    // ==================== Token 生成相关方法 ====================
    /**
     * 生成 JWT Token(公开调用入口)
     *
     * @param username 用户名(本项目为邮箱)
     * @param role     用户角色
     * @return 生成的 JWT 字符串
     */
    public String generateToken(String username, String role){
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);           // 将角色存入私有声明(Private Claims)
        return doGenerateToken(claims, username);
    }

    /**
     * 实际执行 Token 生成的内部方法
     *
     * @param claims  要存入 Token 的自定义数据
     * @param subject Token 主题(通常是用户唯一标识,如邮箱)
     * @return JWT 字符串
     */
    private String doGenerateToken(Map<String, Object> claims, String subject){
        return Jwts.builder()
                .claims(claims)                                          // 设置自定义数据
                .subject(subject)                                        // 设置主题(Subject)
                .issuedAt(new Date(System.currentTimeMillis()))          // 设置签发时间
                .expiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY)) // 设置过期时间
                .signWith(getSigningKey())                               // 使用密钥签名
                .compact();                                              // 压缩并生成最终的字符串
    }

    // ==================== Token 校验相关方法 ====================
    /**
     * 验证 Token 是否有效
     * <p>
     *     验证逻辑包括:
     *     1. Token 签名是否正确(在 getAllClaimsFromToken 中校验)
     *     2. Token 是否过期
     * </p>
     *
     * @param token JWT 字符串
     * @return true-有效,false-无效
     */
    public Boolean validateToken(String token){
        try {
            // 如果能成功解析出 Claims,说明签名正确且格式合法
            getAllClaimsFromToken(token);
            // 额外校验是否过期
            return !isTokenExpired(token);
        }catch (Exception e){
            // 任何异常(签名错误、格式错误、过期异常等)都视为 Token 无效
            return false;
        }
    }
}

2.2 JwtAuthenticationFilter - JWT 认证过滤器

该过滤器继承自 OncePerRequestFilter,确保每个请求仅被过滤一次。其核心职责是从请求头或 URL 参数中提取 JWT 并完成用户身份认证。

文件路径com.netflix.clone.demo.security.JwtAuthenticationFilter

package com.netflix.clone.demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.logging.log4j.message.StringFormattedMessage;  // ⚠️ 未使用的导入,可删除但依要求保留
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.web.config.SpringDataWebSettings;    // ⚠️ 错误注入,与 JWT 无关,会导致启动失败
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;

/**
 * JWT 认证过滤器
 * <p>
 *     继承 OncePerRequestFilter,确保每个请求只被过滤一次。
 *     负责从请求中提取 JWT,验证有效性,并将用户认证信息存入 SecurityContext。
 * </p>
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    /**
     * JWT 工具类,用于解析和校验 Token
     */
    @Autowired
    private JwtUtil jwtUtil;

    /**
     * ⚠️ 语法/逻辑错误点:
     * SpringDataWebSettings 是一个与 Spring Data Web 支持相关的配置类,
     * 此处注入完全无关,且很可能在 Spring 容器中并不存在该 Bean,会导致应用启动失败。
     */
    @Autowired
    private SpringDataWebSettings springDataWebSettings;

    /**
     * 过滤器的核心方法
     *
     * @param request      HTTP 请求
     * @param response     HTTP 响应
     * @param filterChain  过滤器链,用于继续传递请求
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 从请求中提取 JWT 字符串
        String jwt = extractJwtToken(request);

        // 2. 从 JWT 中解析出用户名(邮箱)
        // ⚠️ 潜在空指针风险:如果 jwt 为 null,jwtUtil.getUsernameFromToken(null) 会抛出异常
        String username = jwtUtil.getUsernameFromToken(jwt);

        // 3. 判断是否需要处理认证(用户名有效且当前未认证)
        if (shouldProcessAuthentication(username)) {
            processAuthentication(request, jwt, username);
        }

        // 4. 放行请求,继续执行后续过滤器
        filterChain.doFilter(request, response);
    }

    /**
     * 从 HTTP 请求中提取 JWT
     * <p>
     *     支持两种提取方式:
     *     1. 标准 Authorization 头:Bearer <token>
     *     2. URL 查询参数:用于视频/图片资源访问,参数名为 token
     * </p>
     *
     * @param request HTTP 请求
     * @return JWT 字符串,若未找到则返回 null
     */
    private String extractJwtToken(HttpServletRequest request) {
        final String authorizationHeader = request.getHeader("Authorization");
        final String requestURI = request.getRequestURI();

        // 优先从 Authorization 头获取
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            return authorizationHeader.substring(7); // 去除 "Bearer " 前缀
        }
        // 特殊处理:视频/图片文件请求允许通过 URL 参数传递 Token
        else if ((requestURI.contains("/api/files/video") || requestURI.contains("/api/files/image/"))
                && request.getParameter("token") != null) {
            return request.getParameter("token");
        }
        return null;
    }

    /**
     * 判断是否需要进行认证处理
     * <p>
     *     条件:
     *     1. 用户名(邮箱)非空
     *     2. 当前 SecurityContext 中尚未存储认证信息
     * </p>
     *
     * @param username 从 Token 中解析出的用户名
     * @return true-需要处理认证,false-无需处理
     */
    private boolean shouldProcessAuthentication(String username) {
        return username != null && SecurityContextHolder.getContext().getAuthentication() == null;
    }

    /**
     * 执行认证处理逻辑
     * <p>
     *     验证 Token 有效性,构建 UserDetails 对象,并存入 SecurityContext。
     * </p>
     *
     * @param request  HTTP 请求,用于构建认证详情
     * @param jwt      JWT 字符串
     * @param username 用户名
     */
    private void processAuthentication(HttpServletRequest request, String jwt, String username) {
        // 验证 Token 是否合法(签名正确且未过期)
        if (jwtUtil.validateToken(jwt)) {
            // 根据 Token 中的信息创建 UserDetails
            UserDetails userDetails = createUserDetailsFromToken(jwt, username);
            // 将认证信息设置到 SecurityContext 中
            setAuthenticationInContext(request, userDetails);
        }
    }

    /**
     * 从 JWT 中提取角色信息,构建 Spring Security 的 UserDetails 对象
     *
     * @param jwt      JWT 字符串
     * @param username 用户名(邮箱)
     * @return UserDetails 实例
     */
    private UserDetails createUserDetailsFromToken(String jwt, String username) {
        // 从 Token 中获取角色
        String role = jwtUtil.getRoleFromToken(jwt);

        // 使用 Spring Security 提供的 User 构建器创建 UserDetails
        // 密码字段设为空字符串,因为认证已由 JWT 完成,无需密码校验
        // 注意:角色前会自动拼接 "ROLE_" 前缀,符合 Spring Security 规范
        return User.builder()
                .username(username)
                .password("")
                .authorities(Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)))
                .build();
    }

    /**
     * 将 UserDetails 封装为 Authentication 对象,并存入 SecurityContext
     *
     * @param request     HTTP 请求,用于设置认证详情(如 IP、SessionId)
     * @param userDetails 用户详情
     */
    private void setAuthenticationInContext(HttpServletRequest request, UserDetails userDetails) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,                           // 凭证(密码)已无需传递
                        userDetails.getAuthorities()    // 权限列表
                );

        // 设置认证详情(包含请求的 IP、SessionId 等信息)
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        // 将认证对象存入 SecurityContextHolder
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

三、创建配置包(config)

com.netflix.clone.demo 包下新建 config 子包,用于存放所有 Spring 配置类。

3.1 SecurityConfig - Spring Security 核心配置

该类启用 Web 安全及方法级授权,定义公开接口白名单、会话管理策略,并注册自定义 JWT 过滤器。

文件路径com.netflix.clone.demo.config.SecurityConfig

package com.netflix.clone.demo.config;

import com.netflix.clone.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * Spring Security 配置类
 * <p>
 *     负责定义应用的安全策略:
 *     - 哪些 URL 无需认证即可访问(白名单)
 *     - 密码加密方式
 *     - 关闭 CSRF、启用 CORS
 *     - 设置为无状态会话(JWT 方式)
 *     - 注册自定义的 JWT 认证过滤器
 * </p>
 */
@Configuration
@EnableWebSecurity              // 启用 Spring Security 的 Web 安全支持
@EnableMethodSecurity           // 启用方法级安全注解(如 @PreAuthorize)
public class SecurityConfig {

    /**
     * 注入自定义的 JWT 认证过滤器
     */
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    /**
     * 白名单路径数组
     * <p>
     *     这些 URL 不需要携带 JWT Token 即可访问,通常包括:
     *     登录、注册、邮箱验证、忘记密码等公开接口。
     * </p>
     */
    private static final String[] PUBLIC_ENDPOINTS = {
            "/api/auth/login",
            "/api/auth/signup",
            "/api/auth/validate-email",
            "/api/auth/verify-email",
            "/api/auth/resend-verification",
            "/api/auth/forget-password",   // ⚠️ 拼写问题:正确应为 forgot-password
            "/api/auth/reset-password",
    };

    /**
     * 密码编码器 Bean
     * <p>
     *     ⚠️ 严重安全隐患:
     *     此处使用了 NoOpPasswordEncoder,它不对密码进行任何加密处理。
     *     这意味着数据库中存储的密码是明文,属于严重的安全漏洞。
     *     生产环境必须替换为 BCryptPasswordEncoder 等强哈希算法。
     * </p>
     *
     * @return PasswordEncoder 实例
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // NoOpPasswordEncoder 已过时且不安全,仅适用于极简演示或测试
        return org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance();
    }

    /**
     * 安全过滤器链配置
     * <p>
     *     定义了整个应用的请求拦截规则和过滤器顺序。
     * </p>
     *
     * @param http HttpSecurity 对象,用于配置安全策略
     * @return 构建好的 SecurityFilterChain
     * @throws Exception 配置过程中的异常
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 CSRF 保护:前后端分离且使用 JWT,无需依赖 Session 的 CSRF 防护
                .csrf(csrf -> csrf.disable())

                // 启用 CORS 跨域支持
                // ⚠️ 潜在问题:此处使用空 Lambda,未提供自定义 CorsConfigurationSource Bean,
                // 会导致 Spring Security 使用默认的 CORS 配置,可能无法匹配前端实际请求。
                .cors(cors -> {})

                // 配置 URL 授权规则
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(PUBLIC_ENDPOINTS).permitAll()   // 白名单路径无需认证
                        .anyRequest().authenticated()                    // 其他所有请求均需认证
                )

                // 会话管理策略:设置为无状态,不创建 HttpSession,完全依赖 JWT
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                // 将自定义的 JWT 过滤器添加到 UsernamePasswordAuthenticationFilter 之前
                // 确保请求到达 Controller 前已完成 Token 校验和用户信息设置
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

3.2 CorsConfig - 跨域配置

该类实现 WebMvcConfigurer 接口,为后端 API 提供全局 CORS 规则支持。

文件路径com.netflix.clone.demo.config.CorsConfig

package com.netflix.clone.demo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 跨域配置类
 * <p>
 *     实现 WebMvcConfigurer 接口,重写 addCorsMappings 方法,
 *     为后端 API 设置全局 CORS 规则,解决前后端分离部署时的跨域请求限制。
 * </p>
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    /**
     * 允许跨域访问的前端源地址
     * <p>
     *     从配置文件 `application.properties` 中读取 `app.cors.allowed-origins` 的值。
     *     如果未配置,则使用默认值 "http://localhost:4200"(Angular 默认开发服务器地址)。
     *     支持配置多个源,用逗号分隔,例如:http://localhost:4200,https://example.com
     * </p>
     */
    @Value("${app.cors.allowed-origins:http://localhost:4200}")
    private String[] allowedOrigins;

    /**
     * 配置 CORS 映射规则
     *
     * @param registry CORS 注册器,用于添加映射规则
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                // 指定哪些路径应用此 CORS 规则(此处匹配所有 /api/ 开头的接口)
                .addMapping("/api/**")

                // 允许哪些源发起跨域请求
                .allowedOrigins(allowedOrigins)

                // 允许哪些 HTTP 方法
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")

                // 允许携带哪些请求头(* 表示全部允许)
                .allowedHeaders("*")

                // 暴露哪些响应头给前端 JavaScript 访问
                // Location:常用于重定向后获取新资源地址
                // Content-Disposition:用于文件下载时获取文件名
                .exposedHeaders("Location", "Content-Disposition")

                // 是否允许携带凭证(Cookie、HTTP 认证信息等)
                // 设置为 false 表示不允许,符合 JWT 无状态认证的设计
                .allowCredentials(false)

                // 预检请求(OPTIONS)的缓存时间,单位:秒
                // 3600 秒(1 小时)内浏览器不会对同一 URL 重复发送预检请求
                .maxAge(3600);
    }
}

四、本地数据库初始化

在启动 Spring Boot 应用之前,需要先在 MySQL 中创建项目所需的数据库 pulsescreen

4.1 操作步骤

  1. 打开 MySQL 8.0 Command Line Client(或任意 MySQL 客户端工具)。
  2. 输入 MySQL 的 root 用户密码(示例密码为 chen)。
  3. 依次执行以下 SQL 命令
-- 创建数据库,指定字符集为 utf8mb4,排序规则为 utf8mb4_unicode_ci
CREATE DATABASE pulsescreen CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 退出 MySQL 客户端
EXIT;

4.2 验证数据库连接

在 IntelliJ IDEA 或任意数据库管理工具中,使用 application.properties 中配置的数据源信息测试连接:

  • Hostlocalhost
  • Port3306
  • Databasepulsescreen
  • Usernameroot
  • Passwordchen

连接测试通过后,即可启动 Spring Boot 应用。由于配置了 spring.jpa.hibernate.ddl-auto=update,Hibernate 会在应用启动时自动扫描实体类并创建/更新对应的数据表结构。


五、启动项目

完成以上配置与数据库初始化后,运行 DemoApplication 主类。若控制台输出类似以下信息,则表明项目启动成功:

Started DemoApplication in X.XXX seconds

随后即可通过 Postman 或前端应用进行接口测试。

评论

动作测试