Skip to content

SpringMVC教程 - 22 登录功能和访问拦截(JWT)

上一章演示了 Redis Token 的方式,在登录的时候生成一个随机的 Token,并将 Token 存储到 Redis 中,后面每个请求在请求头中携带 Token,在拦截器中验证 Token 是否有效。并在每次请求的时候刷新 Token 的过期时间。

下面介绍一下使用 JWT 进行认证的实现流程,正如上一章介绍两种认证方式,JWT 认证需要两个令牌:

  • 访问令牌
  • 刷新令牌

所以在登录的时候生成这两个令牌返回给客户端,客户端在后续的请求中,在请求头中携带访问令牌请求服务器,服务器验证该令牌是否有效。如果令牌无效,则返回错误码提示客户端登录;如果令牌过期,则返回错误码提示客户端使用刷新令牌请求服务器获取新的访问令牌。客户端获取新的访问令牌时,服务器验证刷新令牌是否有效,如果有效,则生成新的访问令牌和刷新令牌返回给客户端。

这里对访问令牌,服务器是没有存储的,也就是无状态的,服务器可以验证访问是否有效,但是没办法让其失效。另外需要将刷新令牌存储到数据库或Redis,也就是说刷新令牌必须是有状态的,主要是对刷新令牌进行管理,可以将其失效,否则刷新令牌被盗,可以一直用来刷新 Token。而且会出现用户退出登录了,刷新令牌也有效的问题。

22.1 JWT简介

在演示之前,先介绍一下 JWT 令牌是什么样子的。

22.1.1 什么是JWT

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。

JWT的优势:

  1. 无状态和可扩展性:服务器不需要存储会话信息,易于水平扩展;
  2. 跨域友好:JWT 可以在不同域之间传输;
  3. 自包含:令牌本身包含所有必要信息,减少了数据库查询;
  4. 跨语言支持:JWT 是基于 JSON 的,支持所有编程语言;

22.1.2 JWT的结构

JWT 本质上是 一个字符串,由三部分组成:

  • Header(头部):包含令牌类型和签名算法;

  • Payload(载荷):包含声明(claims),即要传输的数据,可以自定义;

  • Signature(签名):用于验证消息的完整性;

格式如下:

Header.Payload.Signature
  • JWT 的三个部分用点 . 分隔。

举个栗子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoia2FuZyIsImV4cCI6MTcwMDAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

那这三部分是如何得到的呢?继续!

22.1.3 JWT结构说明

1 Header(头部)

Header 就像是身份证的 "封面",它告诉别人这是什么类型的令牌,以及使用的算法,这样才知道如何验证它。

Header 主要包含两部分信息:

  1. 签名算法alg):用于计算签名的算法(必须包含);
  2. 令牌类型typ):通常为 "JWT"(推荐但不是必须);

举个栗子:

json
{
  "alg": "HS256",  // 必须:使用HS256算法签名
  "typ": "JWT"     // 可选:标识这是JWT
}
  • 也就是说 Header 是个 JSON 格式的字符串,指定了 JWT 签名使用的算法。

  • alg 指定签名算法,常见的取值有:HS256(对称加密)、RS256(非对称加密),作用是告诉接收方使用什么算法来验证签名;

  • typ 是令牌类型,常见取值:JWT(推荐使用),作用是明确标识这是一个 JWT 令牌。

得到了 Header 的 JSON 字符串,然后使用 Base64Url 对其进行编码,得到 JWT 的第一部分。

2 Payload(载荷)

Payload 就像是身份证的 "内容页",它包含了关于用户的声明(Claims),也就是一些键值对,用于存储用户的信息。

举个栗子:

  • "sub":"1234567890" (用户ID)
  • "name":"张三" (用户名)
  • "roles":["admin", "user"] (用户角色)

这些声明信息分为三种类型:

  1. 注册声明:JWT 标准预定义的声明;
  2. 公共声明:可以自由定义的声明(需遵循规范);
  3. 私有声明:由开发者自定义的声明(用于业务需求);

推荐包含的注册声明有:

声明名称英文全称作用说明
subSubject令牌主题(通常是用户ID)
expExpiration Time令牌过期时间(Unix时间戳,单位:秒)
iatIssued At令牌发行时间(Unix时间戳,单位:秒)
issIssuer令牌发行者(比如你的网站域名)
jtiJWT ID令牌唯一标识符(防止重放攻击)

举个载荷的栗子:

json
{
  "sub": "1234567890",        // 强烈推荐:用户ID
  "name": "张三",              // 自定义:用户名
  "roles": ["admin", "user"], // 自定义:用户角色
  "iat": 1516239022,          // 推荐:发行时间
  "exp": 1516242622           // 强烈推荐:过期时间
}
  • 也就是说载荷也是 JSON 格式的;载荷中的所有声明都是可选的,但是为了实现有意义的认证,通常会包含一些关键声明,例如过期时间。
  • 我们在载荷中指定令牌的过期时间,当客户端将令牌发送给服务器后,服务器可以取出令牌的过期时间,验证令牌是否过期。

需要注意!:Payload 使用的是 Base64Url 编码,这是一种可逆的编码方式!也就是说,任何人都可以解码 Payload 并查看其中的内容。因此,绝对不要在 Payload 中存储敏感信息,比如:密码、银行卡号、身份证号、API密钥等。虽然中间人能看到载荷的信息,但是不能随意修改,因为令牌有签名部分。

3 Signature(签名)

Signature 就像是身份证上的 "防伪标记",它的作用是:

  1. 验证消息的完整性,确保信息在传输过程中没有被篡改;
  2. 验证消息的来源,确保令牌确实是由你的服务器颁发的;

签名的计算包含三个要素:

  1. Base64Url 编码后的 Header;
  2. Base64Url 编码后的 Payload;
  3. 服务器端的密钥(Secret Key)

计算公式如下:

Signature = HMACSHA256(
  base64UrlEncode(Header) + "." + base64UrlEncode(Payload),
  secret
)

举个栗子:

  • Header 编码后是:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • Payload 编码后是:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
  • 服务器密钥是:my-secret-key-12345
  • 那么签名就是通过上面的公式计算出来的字符串:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw

当然了,我们使用第三方提供的工具包计算就可以了,待会介绍。

所以总结一句话:JWT 包含三部分,也就是将 JSON 格式的 Header 和 JSON 格式的 Payload,通过 base64url 编码得到两个字符串,然后在使用加密算法,对这两部分进行计算得出签名,最后将三部分字符串使用 . 连接起来就得到整个 JWT 令牌。

22.2 JJwt库介绍

现在已经知道了 JWT 的基本概念和结构了,下面在 Java 中演示一下 JWT 的生成和校验。

在 Java 生态中,最流行的 JWT 库是 JJwt(Java JSON Web Token)。

1 引入依赖

首先在项目的 pom.xml 中引入 JJwt 的依赖:

xml
<!-- JWT依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.13.0</version>
</dependency>

2 JJwt的使用

下面就写一个 JwtUtil 工具类,提供生成令牌、对令牌进行验证的方法,演示一下 JWT 令牌的生成和校验。

JwtUtil.java

java
package com.foooor.helloweb.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;

@Slf4j
@Component
public class JwtUtil {

    /**
     * 令牌的密钥,实际应用中应从配置文件加载
     */
    // 访问令牌密钥的长度最少为32个字符,这里我直接使用一个uuid
    private static final String SECRET = "4776d9787f1c4b0a923d24a8dbb6549d";

    // 使用密钥生成key,用于加密和解密
    private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

    // jwt令牌默认过期时间,30分钟
    private static final long EXPIRE_SECONDS = 30 * 60;

    /**
     * 生成JWT令牌
     */
    private String generateToken(Long userId, Map<String, Object> payload) {
        long nowMillis = System.currentTimeMillis();

        Date now = new Date(nowMillis);
        Date exp = new Date(nowMillis + EXPIRE_SECONDS * 1000);

        return Jwts.builder()
                .claim("userId", userId)
                .claims(payload)  // 自定义负载
                .issuedAt(now)  // 签发时间
                .expiration(exp)  // 过期时间
                .signWith(KEY)  // 使用密钥签名
                .compact();  // 生成 jwt 字符串
    }


    // 验证访问令牌
    public boolean validateToken(String token) {
        return parseToken(token) != null;
    }

    /**
     * 校验并解析JWT
     */
    private Claims parseToken(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(KEY)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            log.warn("JWT 已过期,token: {}", token);
            return null;
        } catch (io.jsonwebtoken.JwtException e) {
            return null;
        }
    }

    /**
     * 测试方法
     */
    public static void main(String[] args) {
        JwtUtil jwtUtil = new JwtUtil();
        Map<String, Object> payload = Map.of("username", "foooor");
        String token = jwtUtil.generateToken(1L, payload);

        System.out.println(token);
        System.out.println(jwtUtil.validateToken(token));
    }

}
  • 生成 JWT 主要三个步骤:

    • 使用 Jwts.builder() 创建 JWT;

    • 添加各种声明和属性、指定过期时间;

    • 使用密钥进行签名,密钥的长度最短是 32 个字符。

  • 解析和验证就简单了,直接使用 Jwts.parser() 解析和验证 JWT 就可以了。在解析和验证的时候,如果令牌非法或过期,或抛出不同的异常。


好了,原理大概也清楚了,下面就实现使用 JWT 来实现登录和访问拦截。

整个执行流程如下:

  1. 用户登录(用户名 + 密码);
  2. 服务端验证客户端的用户名密码;
  3. 生成访问令牌和刷新令牌,返回给客户端;
  4. 客户端保存访问令牌和刷新令牌;
  5. 客户端每次请求,在请求头中添加 Authorization: Bearer <访问令牌>
  6. 服务器收到请求,在拦截器中获取客户端传递的访问令牌,校验访问令牌是否有效并过期;
  7. 访问令牌有效,则放行;访问令牌无效,返回需要登录的错误码;访问令牌过期,返回访问令牌过期的错误码;
  8. 客户端收到令牌过期错误码,可以调用刷新令牌的接口来刷新令牌,重新获取新的访问令牌和刷新令牌;

22.3 JWT实现方式登录拦截

1 依赖配置

pom.xml文件中添加 JWT 依赖:

xml
<!-- JWT依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.13.0</version>
</dependency>
  • 在上面可能已经添加了。

2 编写Token工具类

上面的 JwtUtil 只是演示,不够完善,下面重新编写一个 JwtTokenUtil.java ,主要涉及几个方面:

  • 生成访问令牌;
  • 生成新令牌,并将刷新令牌保存到 Redis 中;
  • 校验访问令牌和刷新令牌;
  • 提供从缓存中获取刷新令牌和删除刷新令牌的方法。

代码如下:

java
package com.foooor.helloweb.util;

import com.foooor.helloweb.common.BizException;
import com.foooor.helloweb.common.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class JwtTokenUtil {

    @Autowired
    private RedisUtil redisUtil;

    /**
     * 令牌的密钥,实际应用中应从配置文件加载
     */
    // 访问令牌密钥的长度最少为32个字符,这里我直接使用一个uuid
    private static final String ACCESS_SECRET = "4776d9787f1c4b0a923d24a8dbb6549d";
    // 刷新令牌密钥的长度最少为32个字符
    private static final String REFRESH_SECRET = "77927f31b1ff4920ae21f2233334c50e";

    // 使用密钥生成key,用于加密和解密
    private static final SecretKey ACCESS_KEY = Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8));
    private static final SecretKey REFRESH_KEY = Keys.hmacShaKeyFor(REFRESH_SECRET.getBytes(StandardCharsets.UTF_8));

    /**
     * 令牌的类型
     */
    // jwt令牌,也就是访问令牌
    private static final String ACCESS_SECRET_TYPE = "access";
    // 刷新令牌
    private static final String REFRESH_SECRET_TYPE = "refresh";

    /**
     * 令牌的过期时间(秒)
     */
    // jwt令牌默认过期时间,30分钟
    private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 30 * 60;
    // 刷新令牌过期时间,7天
    private static final long REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60;

    // 生成访问令牌
    public String generateAccessToken(Long userId, Map<String, Object> claims) {
        return generateToken(userId, claims, ACCESS_SECRET_TYPE, ACCESS_TOKEN_EXPIRE_SECONDS);
    }

    // 生成刷新令牌
    public String generateRefreshToken(Long userId) {
        String refreshToken = generateToken(userId, null, REFRESH_SECRET_TYPE, REFRESH_TOKEN_EXPIRE_SECONDS);

        // 刷新令牌也需要缓存起来,用于后续验证
        redisUtil.set("REFRESH_TOKEN:" + userId, refreshToken, REFRESH_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);

        return refreshToken;
    }

    /**
     * 生成JWT令牌
     */
    private String generateToken(Long userId, Map<String, Object> payload, String tokenType, long expiration) {
        long nowMillis = System.currentTimeMillis();

        Date now = new Date(nowMillis);
        Date exp = new Date(nowMillis + expiration * 1000);

        return Jwts.builder()
                .claim("userId", userId)
                .claim("type", tokenType)  // 不同的类型
                .claims(payload)  // 自定义负载
                .issuedAt(now)  // 签发时间
                .expiration(exp)  // 过期时间
                .signWith(tokenType.equals(ACCESS_SECRET_TYPE) ? ACCESS_KEY : REFRESH_KEY)  // 使用不同的key
                .compact();  // 生成 jwt 字符串
    }

    /**
     * 从缓存中获取刷新令牌
     */
    public String getRefreshToken(Long userId) {
        return (String) redisUtil.get("REFRESH_TOKEN:" + userId);
    }

    /**
     * 删除刷新令牌
     */
    public void deleteRefreshToken(Long userId) {
        // 删除Redis中的刷新令牌
        redisUtil.delete("REFRESH_TOKEN:" + userId);
    }

    // 验证访问令牌
    public void validateAccessToken(String token) {
        parseToken(token, ACCESS_SECRET_TYPE);
    }

    // 验证刷新令牌
    public void validateRefreshToken(String token) {
        parseToken(token, REFRESH_SECRET_TYPE);
    }

    /**
     * 校验并解析访问令牌
     */
    public Claims parseAccessToken(String token) {
        return parseToken(token, ACCESS_SECRET_TYPE);
    }

    /**
     * 校验并解析刷新令牌
     */
    public Claims parseRefreshToken(String token) {
        return parseToken(token, REFRESH_SECRET_TYPE);
    }

    /**
     * 校验并解析JWT
     */
    private Claims parseToken(String token, String tokenType) {
        try {
            return Jwts.parser()
                    .verifyWith(tokenType.equals(ACCESS_SECRET_TYPE) ? ACCESS_KEY : REFRESH_KEY)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            log.warn("JWT 已过期,token: {}", token);

            if (tokenType.equals(ACCESS_SECRET_TYPE)) {
                throw new BizException(ErrorCode.JWT_EXPIRED);  // 客户端收到过期令牌,需要刷新令牌
            }
            else {
                // 刷新令牌过期,需要重新登录
                throw new BizException(ErrorCode.REFRESH_TOKEN_INVALID);
            }
        } catch (io.jsonwebtoken.JwtException e) {
            log.warn("JWT 非法,token: {}", token);

            // 令牌非法,需要重新登录
            throw new BizException(ErrorCode.JWT_INVALID);
        }
    }
}
  • 校验令牌,这里直接抛出异常,然后走项目的统一异常管理;
  • 另外令牌区分类型,避免使用访问令牌来当刷新令牌使用;

这里补充几个错误码,用于抛出异常,你自己按照需要定义就好了:

java
package com.foooor.helloweb.common;

public enum ErrorCode {
    SUCCESS(0, "成功"),
    SYSTEM_ERROR(1000, "系统内部错误"),
    PARAM_ERROR(1001, "参数错误"),
    NO_PERMISSION(1002, "无权限访问"),
    USER_NOT_FOUND(1003, "用户不存在"),
    USER_PASSWORD_ERROR(1004, "用户名或密码错误"),
    USER_REGISTERED(1005, "用户名已存在"),
    PASSWORD_NOT_MATCH(1006, "两次输入的密码不一致"),
    JWT_EXPIRED(1007, "JWT 已过期"),
    JWT_INVALID(1008, "JWT 非法"),
    REFRESH_TOKEN_INVALID(1009, "刷新令牌非法");


    public final int code;
    public final String msg;

    ErrorCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

3 创建登录Controller层

这里直接创建一个新的 Controller,就叫 JwtLoginController ,在登录的时候生成 JWT 访问令牌和刷新令牌返回给客户端。并提供一个刷新令牌的接口,在退出接口中,删除 Redis 中的刷新令牌。

RestLoginController.java

java
package com.foooor.helloweb.controller;

import com.foooor.helloweb.common.ErrorCode;
import com.foooor.helloweb.common.Result;
import com.foooor.helloweb.pojo.User;
import com.foooor.helloweb.service.IUserService;
import com.foooor.helloweb.util.JwtTokenUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/jwt")
public class JwtLoginController {

    @Autowired
    private IUserService userService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    /**
     * 处理登录请求
     */
    @PostMapping("/login")
    public Result login(@RequestBody Map<String, String> loginData) {
        String username = loginData.get("username");
        String password = loginData.get("password");

        // 调用Service层的登录方法进行验证
        User user = userService.login(username, password);
        if (user != null) {

            /**
             * JWT token 实现方式
             */
            // 可以在访问令牌中包含用户角色等信息,用于权限验证
            Map<String, Object> claims = new HashMap<>();
            claims.put("roles", new String[] { "user" }); // 这里只是演示,实际应用中根据用户角色设置

            // 生成访问令牌
            String accessToken = jwtTokenUtil.generateAccessToken(user.getId(), claims);
            // 刷新令牌,并保存到redis
            String refreshToken = jwtTokenUtil.generateRefreshToken(user.getId());

            // 返回令牌
            Map<String, String> resultData = new HashMap<>();
            resultData.put("accessToken", accessToken);
            resultData.put("refreshToken", refreshToken);
            return Result.success(resultData);
        } else {
            return Result.error(ErrorCode.USER_PASSWORD_ERROR);
        }
    }

    /**
     * 处理注册请求
     */
    @PostMapping("/register")
    public Result register(@RequestBody Map<String, String> loginData) {
        String username = loginData.get("username");
        String password = loginData.get("password");
        String confirmPassword = loginData.get("confirmPassword");

        // 检查参数是否为空
        if (username == null || password == null || confirmPassword == null) {
            log.error("注册失败,参数为空:username={}, password={}, confirmPassword={}", username, password, confirmPassword);
            return Result.error(ErrorCode.PARAM_ERROR);
        }

        // 去掉前后空格
        username = username.trim();
        password = password.trim();
        confirmPassword = confirmPassword.trim();
        // 检查长度是否符合要求
        if (username.length() < 6 || username.length() > 20) {
            log.error("注册失败,用户名长度不符合要求:{}", username);
            return Result.error(ErrorCode.PARAM_ERROR);
        }
        if (password.length() < 6 || password.length() > 20) {
            log.error("注册失败,密码长度不符合要求:{}", password);
            return Result.error(ErrorCode.PARAM_ERROR);
        }

        // 验证密码是否一致
        if (!password.equals(confirmPassword)) {
            log.error("注册失败,密码不一致:password={}, confirmPassword={}", password, confirmPassword);
            return Result.error(ErrorCode.PASSWORD_NOT_MATCH);
        }

        // 创建用户对象
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);

        // 注册用户
        boolean success = userService.register(user);
        if (success) {
            // 注册成功
            return Result.success();
        } else {
            // 注册失败,返回错误信息
            return Result.error(ErrorCode.SYSTEM_ERROR);
        }
    }

    /**
     * 刷新令牌接口
     * 客户端使用刷新令牌获取新的访问令牌和刷新令牌
     */
    @PostMapping("/refresh-token")
    public Result refreshToken(@RequestBody Map<String, String> refreshData) {
        String refreshToken = refreshData.get("refreshToken");
        if (refreshToken == null || refreshToken.isEmpty()) {
            return Result.error(ErrorCode.REFRESH_TOKEN_INVALID);
        }

        try {
            // 验证刷新令牌是否有效,如果校验失败会抛异常
            jwtTokenUtil.validateRefreshToken(refreshToken);

            // 解析刷新令牌获取用户ID
            Claims claims = jwtTokenUtil.parseRefreshToken(refreshToken);
            Long userId = claims.get("userId", Long.class);

            // 生成新的访问令牌和刷新令牌
            Map<String, Object> newClaims = new HashMap<>();
            newClaims.put("roles", new String[] { "user" });
            String newAccessToken = jwtTokenUtil.generateAccessToken(userId, newClaims);
            String newRefreshToken = jwtTokenUtil.generateRefreshToken(userId);  // 这里会覆盖旧的刷新令牌

            Map<String, String> resultData = new HashMap<>();
            resultData.put("accessToken", newAccessToken);
            resultData.put("refreshToken", newRefreshToken);
            return Result.success(resultData);
        } catch (Exception e) {
            log.error("刷新令牌失败: {}", e.getMessage());
            return Result.error(ErrorCode.REFRESH_TOKEN_INVALID);
        }
    }

    /**
     * 用户退出登录接口
     * 从Redis中删除刷新令牌,使令牌失效
     */
    @PostMapping("/logout")
    public Result logout(@RequestHeader("Authorization") String authorization) {
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            return Result.error(ErrorCode.JWT_INVALID);
        }

        // 提取访问令牌
        String accessToken = authorization.substring("Bearer ".length());

        try {
            // 解析访问令牌获取用户ID
            Claims claims = jwtTokenUtil.parseAccessToken(accessToken);
            Long userId = claims.get("userId", Long.class);

            // 从Redis中删除刷新令牌
            jwtTokenUtil.deleteRefreshToken(userId);

            return Result.success();
        } catch (Exception e) {
            log.error("退出登录报错: {}", e.getMessage());
            return Result.success();
        }
    }
}
  • 注册方法和上一个章节的一样,没有修改。

4 创建学生Controller

因为之前的学生 RestStudentController是 /apis/students 的访问路径,会被 RedisLoginInterceptor 拦截。

所以这里重新创建一个 JwtStudentController,访问路径是 /jwt/students,主要是避免与之前两个拦截器相互干扰。

代码和之前的 Restful API 的学生 Controller 是一样的,只是修改一下访问路径为 /jwt/students

java
package com.foooor.helloweb.controller;

import com.foooor.helloweb.common.ErrorCode;
import com.foooor.helloweb.common.Result;
import com.foooor.helloweb.pojo.Student;
import com.foooor.helloweb.service.IStudentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/jwt/students")
public class JwtStudentController {

    @Autowired
    private IStudentService studentService;

    // 跳转到学生列表页
    @GetMapping
    public Result list(Model model) {
        List<Student> studentList = studentService.findAll();
        return Result.success(studentList);
    }

    // 获取学生
    @GetMapping("/{id}")
    public Result getStudent(@PathVariable("id") Long id) {
        Student student = studentService.findById(id);
        return Result.success(student);
    }

    // 新增学生
    @PostMapping
    public Result create(Student student) {
        boolean success = studentService.addStudent(student); // 新增
        if (success) {
            return Result.success();
        } else {
            return Result.error(ErrorCode.SYSTEM_ERROR);
        }
    }

    // 修改学生
    @PutMapping
    public Result update(Student student) {
        boolean success = studentService.updateStudent(student); // 编辑
        if (success) {
            return Result.success();
        } else {
            return Result.error(ErrorCode.SYSTEM_ERROR);
        }
    }

    // 删除学生
    @DeleteMapping("/{id}")
    public Result delete(@PathVariable("id") Long id) {
        boolean isDeleted = studentService.deleteStudent(id);
        if (isDeleted) {
            return Result.success();
        } else {
            return Result.error(ErrorCode.SYSTEM_ERROR);
        }
    }
}

5 修改拦截器

创建 JwtLoginInterceptor.java 拦截器类,用于拦截用户请求,从请求头中取出 JWT 访问令牌,并检查访问令牌的有效性。

java
package com.foooor.helloweb.interceptor;

import com.foooor.helloweb.common.BizException;
import com.foooor.helloweb.common.ErrorCode;
import com.foooor.helloweb.util.JwtTokenUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
@Component
public class JwtLoginInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 浏览器在进行跨域请求时,会先发送一个OPTIONS请求,判断是否支持该跨域请求,直接返回200状态码,表示支持,不进行后续处理
        if ("OPTIONS".equals(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return false;
        }

        // 获取请求头中的Authorization头
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            throw new BizException(ErrorCode.NO_PERMISSION);
        }

        // 去掉Bearer前缀
        token = token.substring(7);
        log.info("请求头中的token:{}", token);

        // 验证访问令牌是否有效,如果无效,抛出异常,进入异常处理流程,返回响应错误码给客户端
        jwtTokenUtil.validateAccessToken(token);

        return true;
    }
}
  • 检查请求头中的 Authorization 字段、验证 JWT 令牌的有效性,如果无效会抛异常,走统一异常管理流程。

6 配置拦截器

因为前两章 Controller 和 拦截器的存在,所以这里需要兼顾一下配置,这里已经涉及到三个拦截器和三种接口了,不要相互影响。如果为了简单,完全可以将前两章的 Controller 和 拦截器删掉。

SpringMvcConfig 配置类中注入并配置 JwtLoginInterceptor 拦截器:

java
@Configuration
@ComponentScan(basePackages = "com.foooor.helloweb.controller")
@EnableWebMvc
public class SpringMvcConfig implements WebMvcConfigurer {

    // 其他,略...
  
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Autowired
    private RedisLoginInterceptor redisLoginInterceptor;

    @Autowired
    private JwtLoginInterceptor jwtLoginInterceptor;

    /**
     * 3.配置拦截器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册拦截器,拦截所有请求,除了 /login 和 /register.html
        registry.addInterceptor(logInterceptor)
                .addPathPatterns("/**");  // 添加例外

        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")  // 拦截所有请求
                // 添加例外,不拦截登录和注册页面
                .excludePathPatterns(
                        "/views/to-login",
                        "/views/login",
                        "/views/to-register",
                        "/views/register",
                        "/apis/**",
                        "/jwt/**"
                );

        registry.addInterceptor(redisLoginInterceptor)
                .addPathPatterns("/**")  // 拦截所有请求
                // 添加例外,不拦截登录和注册页面
                .excludePathPatterns(
                        "/apis/login",
                        "/apis/register",
                        "/views/**",
                        "/jwt/**",
                        "/jwt/refresh-token" // 刷新令牌接口,不拦截
                );

        registry.addInterceptor(jwtLoginInterceptor)
                .addPathPatterns("/**")  // 拦截所有请求
                // 添加例外,不拦截登录和注册页面
                .excludePathPatterns(
                        "/jwt/login",
                        "/jwt/register",
                        "/jwt/refresh-token", // 刷新令牌接口,不拦截
                        "/views/**",
                        "/apis/**"
                );
    }

    // 其他,略...

}

22.4 API测试

1 测试访问被拦截

直接访问学生列表接口 http://localhost:8080/jwt/students,会返回错误:

2 测试登录

此时登录,获取访问令牌和刷新令牌:

3 使用令牌访问

重新访问 http://localhost:8080/jwt/students 接口,获取学生信息,在 Header 中添加访问令牌信息:

添加访问令牌,就可以访问被拦截的接口了。

4 测试刷新令牌

如果访问令牌过期,则需要使用刷新令牌,请求刷新令牌接口,重新获取访问令牌:


JWT 在实际的项目中使用是很灵活的,也有人将 JWT 访问令牌直接存储到 Redis 中,为了方便管理。还因为可以存在多个客户端同时登录的情况,所以在 JWT 令牌存储的时候,也存在比较灵活的处理。

而且上面的方案也不是完美的,例如在退出的时候,访问令牌依然是有效的,依然可以使用访问令牌访问接口。你说这是一个问题吗,可能是,主要看系统的应用场景和安全要求,有的人针对这个问题,在 Redis 添加 JWT 访问令牌黑名单,在检查访问令牌是否合法的时候,同时检查是否在黑名单中。所以具体的实现要看具体的安全要求和场景,我这里只是给出一种方案的演示。


SpringMVC 教程到这里的结束了,感谢支持!

内容未完......