一、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 操作步骤
- 打开 MySQL 8.0 Command Line Client(或任意 MySQL 客户端工具)。
- 输入 MySQL 的 root 用户密码(示例密码为
chen)。 - 依次执行以下 SQL 命令:
-- 创建数据库,指定字符集为 utf8mb4,排序规则为 utf8mb4_unicode_ci
CREATE DATABASE pulsescreen CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 退出 MySQL 客户端
EXIT;
4.2 验证数据库连接
在 IntelliJ IDEA 或任意数据库管理工具中,使用 application.properties 中配置的数据源信息测试连接:
- Host:
localhost - Port:
3306 - Database:
pulsescreen - Username:
root - Password:
chen
连接测试通过后,即可启动 Spring Boot 应用。由于配置了 spring.jpa.hibernate.ddl-auto=update,Hibernate 会在应用启动时自动扫描实体类并创建/更新对应的数据表结构。
五、启动项目
完成以上配置与数据库初始化后,运行 DemoApplication 主类。若控制台输出类似以下信息,则表明项目启动成功:
Started DemoApplication in X.XXX seconds
随后即可通过 Postman 或前端应用进行接口测试。
评论