JWT 认证¶
实现注册 / 登录,以及登录后请求的 token 校验。这是前端人最熟悉的后端话题——你天天在 Vue 里存 token、塞 header、做拦截器,这章讲后端那半。
JWT 是什么¶
JWT(JSON Web Token)是一段签名过的字符串,服务端签发、客户端保存、每次请求带上。服务端靠它认人,不用在服务器存 session。
flowchart LR
A[登录成功] --> B[服务端签发 JWT]
B --> C[前端存 localStorage]
C --> D[每次请求带在 Authorization 头]
D --> E[服务端校验签名 + 取出 userId]
对标前端:就是你 axios 请求拦截器里 config.headers.Authorization = 'Bearer ' + token 那套,这里讲后端怎么签发和校验。
注册 / 登录:AuthService¶
注册:用户名查重 + BCrypt 哈希存密码(绝不存明文)。登录:校验密码 + 签发 token。
package com.javaglm.task.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.javaglm.task.common.BizException;
import com.javaglm.task.dto.LoginRequest;
import com.javaglm.task.dto.RegisterRequest;
import com.javaglm.task.entity.User;
import com.javaglm.task.mapper.UserMapper;
import com.javaglm.task.security.JwtUtil;
import com.javaglm.task.service.AuthService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 认证服务实现(第 29 章)。
* 注册:用户名查重 + BCrypt 哈希存密码。
* 登录:校验密码 + 签发 JWT。
*/
@Service
public class AuthServiceImpl implements AuthService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/** 构造器注入(推荐):依赖一目了然,对标前端的依赖注入思想。 */
public AuthServiceImpl(UserMapper userMapper, JwtUtil jwtUtil) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
}
@Override
public void register(RegisterRequest req) {
Long count = userMapper.selectCount(
new LambdaQueryWrapper<User>().eq(User::getUsername, req.getUsername()));
if (count > 0) {
throw new BizException("用户名已被占用");
}
User user = new User();
user.setUsername(req.getUsername());
user.setPassword(passwordEncoder.encode(req.getPassword())); // 只存哈希
user.setCreateTime(LocalDateTime.now());
userMapper.insert(user);
}
@Override
public Map<String, Object> login(LoginRequest req) {
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>().eq(User::getUsername, req.getUsername()));
if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPassword())) {
throw new BizException(401, "用户名或密码错误");
}
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("id", user.getId());
userInfo.put("username", user.getUsername());
result.put("user", userInfo);
return result;
}
}
为什么用 BCrypt
明文存密码是灾难。BCrypt 是专门为密码设计的哈希:每次哈希结果不同(带随机盐)、故意很慢(防暴力破解)、不可逆。encode 哈希、matches 校验。这里只引了 spring-security-crypto(轻量),没引完整 Spring Security(避免它自动锁死所有接口)。
签发与解析:JwtUtil¶
package com.javaglm.task.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具类(第 29 章)。
* 对标前端:后端签发 token,前端存在 localStorage,每次请求带在 Authorization 头里。
* 这里负责"签发"和"解析校验"。
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire-millis:86400000}")
private long expireMillis; // 默认 24 小时
private SecretKey key() {
// HS256 要求密钥至少 32 字节
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/** 签发 token:把 userId 放进 subject,username 放进自定义 claim。 */
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expireMillis);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key(), SignatureAlgorithm.HS256)
.compact();
}
/** 解析 token,签名错误或过期会抛异常(由拦截器捕获转 401)。 */
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key())
.build()
.parseClaimsJws(token)
.getBody();
}
public Long getUserId(String token) {
return Long.valueOf(parseToken(token).getSubject());
}
}
generateToken:把userId放进 subject,设过期时间,用密钥 HS256 签名。parseToken:解析验证,签名错/过期会抛异常。
拦截校验:JwtInterceptor¶
每个需要登录的请求,进 Controller 前先校验 token、取出 userId:
package com.javaglm.task.security;
import com.javaglm.task.common.BizException;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* JWT 拦截器(第 29 章)。
* 对标前端 axios 的请求拦截器:前端在发请求前给 header 塞 token;
* 这里是后端在进 Controller 前,校验 token、提取 userId。
*/
@Component
public class JwtInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
public JwtInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 浏览器跨域预检请求(OPTIONS)不带 Authorization,必须放行,
// 否则第 30 章配置的 CORS 在真实跨域下会被这里 401 拦死。
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
throw new BizException(401, "未登录或 token 缺失");
}
String token = header.substring(7);
try {
Long userId = jwtUtil.getUserId(token);
// 解析出的 userId 放进请求上下文,Controller 用 request.getAttribute("userId") 取
request.setAttribute("userId", userId);
} catch (Exception e) {
throw new BizException(401, "token 无效或已过期");
}
return true; // 放行
}
}
对标前端的路由守卫:进页面前先检查有没有 token,没有就跳登录。这里是后端版本——没 token 直接 401。
为什么第一行先放行 OPTIONS
跨域时浏览器会先发一个 OPTIONS 预检请求"试探",它不带 Authorization。如果不放行,预检会被这里 401 拦死,浏览器收不到 CORS 响应头,跨域直接失败(见下一章 CORS)。所以拦截器开头先判断:是预检就放行,再校验真正的业务请求。
注册拦截器 + 放行登录¶
package com.javaglm.task.config;
import com.javaglm.task.security.JwtInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置(第 29 章):注册 JWT 拦截器,并放行登录/注册/健康检查。
* 对标前端:类似全局路由守卫——进路由前先校验。
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
public WebMvcConfig(JwtInterceptor jwtInterceptor) {
this.jwtInterceptor = jwtInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/auth/**", "/health", "/error"); // 这些路径不用登录
}
}
excludePathPatterns("/auth/**", "/health", "/error"):登录、注册、健康检查不用 token(否则还没登录就被拦住了)。
认证接口¶
package com.javaglm.task.controller;
import com.javaglm.task.common.Result;
import com.javaglm.task.dto.LoginRequest;
import com.javaglm.task.dto.RegisterRequest;
import com.javaglm.task.service.AuthService;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.Map;
/**
* 认证接口(第 29 章):注册 / 登录。
* 这两个路径在 WebMvcConfig 里被 JWT 拦截器放行(不用登录就能访问)。
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public Result<?> register(@RequestBody @Valid RegisterRequest req) {
authService.register(req);
return Result.success();
}
@PostMapping("/login")
public Result<Map<String, Object>> login(@RequestBody @Valid LoginRequest req) {
return Result.success(authService.login(req));
}
}
完整流程串联¶
- 用户调
POST /auth/register→ 注册成功; - 调
POST /auth/login→ 返回{ token, user }; - 前端把 token 存起来;
- 之后每个请求带
Authorization: Bearer <token>; JwtInterceptor校验、取出 userId 存入 request;- Controller 用
request.getAttribute("userId")拿到当前用户,查"我的任务"。
第四篇会用 Vue 把这套前端对接做出来。
:octicons-arrow-left-16: 上一章:任务 CRUD 业务实现 | 下一章:跨域 CORS