跳转至

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));
    }
}

完整流程串联

  1. 用户调 POST /auth/register → 注册成功;
  2. POST /auth/login → 返回 { token, user }
  3. 前端把 token 存起来;
  4. 之后每个请求带 Authorization: Bearer <token>
  5. JwtInterceptor 校验、取出 userId 存入 request;
  6. Controller 用 request.getAttribute("userId") 拿到当前用户,查"我的任务"。

第四篇会用 Vue 把这套前端对接做出来。


:octicons-arrow-left-16: 上一章:任务 CRUD 业务实现 | 下一章:跨域 CORS