主题
JWT 认证与安全
让商城从"裸奔接口"变成"有身份的服务"。登录签发 JWT,请求带 token,后端校验身份。
一、认证流程
① 客户端 POST /api/auth/login { username, password }
② 服务端验证密码 → 生成 JWT → 返回给客户端
③ 客户端把 token 存在 localStorage,之后每个请求带在
Authorization: Bearer <token>
④ 服务端拦截器校验 token → 解析出 userId → 放行 / 拒绝(401)二、什么是 JWT
JWT(JSON Web Token)是三段式的令牌:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImV4cCI6fQ.Xy_Zw...
└── 头部 ──┘ └───── 载荷(声明) ─────┘ └──── 签名 ────┘- 头部:加密算法
- 载荷:业务声明(
userId、exp过期时间) - 签名:服务端用密钥对前两段签名,保证内容不可篡改
优点:无状态(服务端不用存 session)、跨域友好、易于多端共用。 权衡:token 无法主动失效(只能等过期),需要更短的过期时间配合刷新。
三、引入依赖与 JWT 工具类
xml
<!-- pjwt 依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>java
// util/JwtUtil.java
package com.mall.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
public class JwtUtil {
// 生产环境务必从配置读取,不要写死在代码里!
private static final SecretKey KEY =
Keys.hmacShaKeyFor("mall-secret-key-change-me-0123456789".getBytes(StandardCharsets.UTF_8));
private static final long EXPIRE = 24 * 60 * 60 * 1000L; // 24 小时
public static String generateToken(Long userId) {
return Jwts.builder()
.claim("userId", userId)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.signWith(KEY)
.compact();
}
// 解析 token,失败抛异常返回 null
public static Long parseUserId(String token) {
try {
Claims claims = Jwts.parser().verifyWith(KEY).build()
.parseSignedClaims(token).getPayload();
return claims.get("userId", Long.class);
} catch (Exception e) {
return null;
}
}
}四、登录时签发 token(补全 UserService)
java
public LoginVO login(LoginDTO dto) {
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername()));
if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
throw new BizException("用户名或密码错误");
}
LoginVO vo = new LoginVO();
vo.setToken(JwtUtil.generateToken(user.getId())); // 签发 token
vo.setNickname(user.getNickname());
return vo;
}五、全局拦截器:校验 token
java
// config/AuthInterceptor.java
package com.mall.config;
import com.mall.common.BizException;
import com.mall.util.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class AuthInterceptor implements HandlerInterceptor {
// ThreadLocal 保存当前用户(当前线程内共享,请求结束清除)
public static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 放行预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
throw new BizException("未登录");
}
Long userId = JwtUtil.parseUserId(token.substring(7));
if (userId == null) {
throw new BizException("登录已过期,请重新登录");
}
USER_ID.set(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
USER_ID.remove(); // 防止 ThreadLocal 内存泄漏
}
}注册拦截器并配置放行路径:
java
// config/WebConfig.java
package com.mall.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**") // 全部拦截
.excludePathPatterns("/api/auth/**") // 登录注册放行
.excludePathPatterns("/api/product/**", "/error", "/doc.html");
}
// 跨域配置:本地开发 Vue(5173)/React(5174) 访问 8080
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*");
}
}六、Controller 获取当前用户
拦截器把 userId 放入 ThreadLocal,Controller 取用:
java
// common/CurrentUser.java —— 工具方法
public static Long userId() {
return AuthInterceptor.USER_ID.get();
}java
@RestController
@RequestMapping("/api/cart")
public class CartController {
@GetMapping
public Result<List<CartItemVO>> list() {
// 不再从参数传 userId,从 token 取
return Result.ok(cartService.list(AuthInterceptor.USER_ID.get()));
}
@DeleteMapping("/{id}")
public Result<Void> remove(@PathVariable Long id) {
cartService.remove(AuthInterceptor.USER_ID.get(), id); // 校验归属
return Result.ok(null);
}
}归属校验(重要安全点)
操作别人的数据前必须校验所有权,防止"越权":
java
public void remove(Long userId, Long cartItemId) {
CartItem item = cartItemMapper.selectById(cartItemId);
if (item == null || !item.getUserId().equals(userId)) {
throw new BizException("无权操作该购物车");
}
cartItemMapper.deleteById(cartItemId);
}七、密码安全:明文 → BCrypt
xml
<!-- Spring Security Crypto 只用来做密码加密,不接完整 Security -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>java
// config/WebConfig 里注册 Bean
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 自带盐值,同密码密文不同
}注册时 encoder.encode(明文),登录时 encoder.matches(明文, 密文)(前面已用)。
八、安全清单
- [ ] 密码全部 BCrypt 加密,任何地方不打印明文
- [ ] JWT 密钥在
application.yml用环境变量配置,不硬编码 - [ ] 所有资源操作校验"属于当前用户"
- [ ] 生产环境 CORS 不配置
*,只允许自己的前端域名 - [ ] token 过期时间 24h,敏感项目缩短
本教程已覆盖的安全基线
能防住:未登录访问、越权读取他人购物车/订单、密码明文泄露。防不住:XSS/CORS 配置不当、token 本地存储遭 XSS 窃取。进阶再学:Redis 做 token 黑名单、httpOnly Cookie、Spring Security 的完整链路。