Skip to content

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...
└── 头部 ──┘ └───── 载荷(声明) ─────┘ └──── 签名 ────┘
  • 头部:加密算法
  • 载荷:业务声明(userIdexp 过期时间)
  • 签名:服务端用密钥对前两段签名,保证内容不可篡改

优点:无状态(服务端不用存 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 的完整链路。

基于 MIT 协议发布,可自由学习与修改