Skip to content

SpringMVC教程 - 21 登录功能和访问拦截(Redis)

在上一章,讲解的是传统项目下的登录功能和访问拦截,下面介绍一下在前后端分离项目中的实现。

21.1 前后端分离认证

在前后端分离架构中,由于不再依赖传统的 Cookie-Session 机制,常用的认证方案有一下几种:

1 Redis Token方案

原理:用户登录时,服务端生成随机Token并存储在Redis中,同时将Token发送给客户端。客户端后续请求时在请求头中携带Token,服务端接收到Token去Redis中查询,验证Token的有效性。

特点

  • 有状态,需要Redis存储Token信息;
  • 支持主动失效,Token 存储在 Redis 中便于监控和管理,只需要将 Redis 中存储的登录信息删除就可以,安全性较高;

在使用 Redis Token 方案的时候,每次请求服务器,服务器需要刷新一下 Redis 中存储的 Token 的时间,确保 Token 不失效;如果一段时间没有请求服务器并刷新 Token,再次请求,则判断登录失效,需要重新登录。

2 JWT(JSON Web Token)方案

原理:用户登录时,服务端使用算法,生成包含用户信息的 JWT 加密令牌,并将 JWT 令牌返回给客户端,后续请求时,客户端在请求头中携带 JWT 令牌,服务端通过验证令牌的签名和有效期来验证用户身份。服务器能验证令牌就说明令牌有效,不能验证说明令牌无效,而服务器是不需要存储令牌的。

特点

  • 无状态,服务端不需要存储令牌信息;
  • 便于服务器水平扩展,适合大规模分布式系统;
  • 生成令牌的时候,令牌中存储了有效期,后面拿令牌请求服务器,服务器没办法主动让这个令牌失效,因为颁发令牌的时候,服务器是没有存储的,也就不知道哪个令牌给谁的;

JWT 中存储了令牌的有效期,例如30分钟,那么30分钟内拿这个令牌来请求都是没有问题的,但是过了30分钟的时候,用户再次使用这个令牌来请求服务器,令牌过期了。假设用户正在浏览网页,上一秒请求还正常,下一秒请求突然告诉登录无效,多少有点离谱。所以 JWT 方案一般使用双令牌模式,也就是一个访问令牌,一个刷新令牌。也就是说,在登录的时候,给客户端返回两个令牌,一个是访问令牌,一个是刷新令牌,访问令牌的过期时间短,刷新令牌的过期时间长,每次请求服务器的时候,携带访问令牌。在访问令牌过期的时候,使用刷新令牌请求服务器获取新的访问令牌,同时刷新一下刷新令牌,避免使刷新令牌过期。

下面就简单实现一下上面两种方案,还是在前两章的基础上继续实现。

21.2 Redis存储Token实现方式

首先需要安装Redis,也可以使用 Docker 部署 Redis,可以参考 Docker 教程中 常用容器部署-Redis

如果你现在不熟悉 Redis,可以简单理解为 Redis 是一个键值存储(key-value)的 Map ,key 是 String 类型,而 value 可以是字符串、集合、Map等类型,先这么简单理解。

整个执行流程如下:

  1. 用户登录(用户名 + 密码)
  2. 服务端验证客户端的用户名密码
  3. 生成 token 保存到 Redis,并返回给客户端
  4. 客户端保存 token
  5. 客户端每次请求,在请求头中添加 Authorization: Bearer <token>
  6. 服务器收到请求,在拦截器中获取客户端传递的Token,并查询 Redis 中是否有该 Token
  7. token 有效放行,token 无效,返回需要登录的错误码;

1 依赖配置

pom.xml 文件中添加 Redis 依赖,用于操作 Redis:

xml
<!-- Spring Data Redis依赖,定义了一套操作 Redis 的接口 -->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>3.5.7</version>
</dependency>

<!-- Redis客户端依赖,具体操作Redis的实现 -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>6.2.0</version>
</dependency>

2 Redis配置

首先创建一个 redis 的配置文件,配置一些连接 Redis 的参数,和连接数据库类似。

这里就叫 redis.properties,放在 resources 目录下。

properties
# Redis主机地址
redis.host=localhost
# Redis端口号
redis.port=6379
# Redis密码
redis.password=123456
# Redis数据库索引,默认0
redis.database=0
# 连接超时时间,单位秒
redis.connectTimeout=2
# 读取超时时间,单位秒
redis.readTimeout=2
# 连接池最大连接数
redis.pool.MaxTotal=50
# 连接池最大空闲连接数
redis.pool.MaxIdle=20
# 连接池最小空闲连接数
redis.pool.MinIdle=5
# 连接池获取连接最大等待时间,单位秒
redis.pool.MaxWait=3

创建一个 Redis 配置类 RedisConfig.java ,对 Redis 的连接器和操作类交给 Spring 容器来管理:

java
package com.foooor.helloweb.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisClientConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.clients.jedis.JedisPoolConfig;

import java.time.Duration;

/**
 * Redis配置类
 * 负责配置Redis连接工厂、连接池和RedisTemplate
 */
@Configuration
public class RedisConfig {

    @Value("${redis.host}")
    private String hostName;
    @Value("${redis.port}")
    private int port;
    @Value("${redis.password}")
    private String password;
    @Value("${redis.database}")
    private int database;
    @Value("${redis.connectTimeout}")
    private int connectTimeout;
    @Value("${redis.readTimeout}")
    private int readTimeout;

    @Value("${redis.pool.MaxTotal}")
    private int poolMaxTotal;
    @Value("${redis.pool.MaxIdle}")
    private int poolMaxIdle;
    @Value("${redis.pool.MinIdle}")
    private int poolMinIdle;
    @Value("${redis.pool.MaxWait}")
    private int poolMaxWait;

    /**
     * 配置Redis连接工厂Bean
     * 使用Jedis作为Redis客户端,配置连接参数和连接池
     *
     * @return JedisConnectionFactory Redis连接工厂实例
     */
    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        // 1. 配置Redis服务器连接信息(主机、端口、密码、数据库索引)
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName(hostName);   // Redis服务器地址
        config.setPort(port);              // Redis服务器端口,默认6379
        config.setPassword(password);            // Redis访问密码,空字符串表示无密码
        config.setDatabase(database);             // Redis数据库索引,默认0号数据库

        // 2. 配置Jedis客户端参数和连接池
        JedisClientConfiguration clientConfig = JedisClientConfiguration.builder()
                .connectTimeout(Duration.ofSeconds(connectTimeout))  // 设置连接超时
                .readTimeout(Duration.ofSeconds(readTimeout))     // 设置读取超时
                .usePooling()                   // 启用连接池
                .poolConfig(jedisPoolConfig())   // 设置连接池配置
                .build();

        // 3. 创建并返回连接工厂实例
        return new JedisConnectionFactory(config, clientConfig);
    }

    /**
     * 配置Redis连接池Bean
     */
    @Bean
    public JedisPoolConfig jedisPoolConfig() {
        JedisPoolConfig poolConfig = new JedisPoolConfig();

        // 连接池核心参数配置
        poolConfig.setMaxTotal(poolMaxTotal);     // 最大连接数:同时支持的最大连接数量
        poolConfig.setMaxIdle(poolMaxIdle);       // 最大空闲连接数:连接池中保持的空闲连接最大数量
        poolConfig.setMinIdle(poolMinIdle);        // 最小空闲连接数:连接池中保持的空闲连接最小数量
        poolConfig.setMaxWait(Duration.ofSeconds(poolMaxWait)); // 获取连接最大等待时间:3秒

        // 连接有效性验证配置
        poolConfig.setTestOnBorrow(true);  // 借用连接时是否验证有效性(推荐true)
        poolConfig.setTestWhileIdle(true); // 定期检查空闲连接的有效性(推荐true)

        return poolConfig;
    }

    /**
     * 配置RedisTemplate
     * RedisTemplate是Spring Data Redis提供的核心操作类,用于操作Redis数据库
     * 序列化说明:
     * 1. Key序列化:使用StringRedisSerializer,将key转换为字符串存储
     * 2. Value序列化:使用GenericJackson2JsonRedisSerializer,将对象转换为JSON存储
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        // 设置序列化器
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }

    /**
     * 配置StringRedisTemplate
     * 用于操作字符串类型的数据,键值都是字符串,使用StringRedisSerializer序列化器
     */
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

然后在项目的根配置类中,引入上面的配置文件和配置类:

java
package com.foooor.helloweb.config;

import org.springframework.context.annotation.*;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.stereotype.Controller;  // 别引错了

import javax.sql.DataSource;

@Configuration
@ComponentScan(
        basePackages = "com.foooor.helloweb",
        excludeFilters = @ComponentScan.Filter(
                type = FilterType.ANNOTATION,
                classes = Controller.class
        )
)
@PropertySource({"classpath:jdbc.properties", "classpath:redis.properties"})  // 加载数据库和Redis配置文件
@Import({MyBatisConfig.class, RedisConfig.class})  // 导入MyBatis配置类和Redis配置类
@EnableTransactionManagement // 开启事务管理
public class SpringConfig {
    /**
     * 其他,略...
     */
}

3 Redis工具类

为了方便操作 Redis,这里创建一个 Redis 的工具类,提供一些操作 Redis 的方法,然后添加 @Component 注解,交给 Spring 来管理,这样可以在其他 Service 中引入并使用。

创建 Redis 工具类 RedisUtil.java

java
package com.foooor.helloweb.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * Redis工具类
 * 提供对Redis数据库的操作方法,包括设置键值对、获取值、删除键、检查键是否存在、刷新过期时间等
 */
@Component
public class RedisUtil {

    /**
     * 自动注入RedisTemplate Bean
     * 用于执行Redis操作,如设置键值对、获取值、删除键等
     * 在配置类中已经配置了RedisTemplate Bean,这里直接注入即可
     */
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 设置键值对,带过期时间
     */
    public void set(String key, Object value, long timeout, TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 获取键对应的值
     */
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 删除键
     */
    public Boolean delete(String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 检查键是否存在
     */
    public Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }

    /**
     * 刷新键的过期时间
     */
    public Boolean expire(String key, long timeout, TimeUnit timeUnit) {
        return redisTemplate.expire(key, timeout, timeUnit);
    }

    /**
     * 获取键的剩余过期时间
     */
    public Long getExpire(String key, TimeUnit timeUnit) {
        return redisTemplate.getExpire(key, timeUnit);
    }
}
  • 主要是对 Redis 中数据进行存、取、删除、修改过期时间的操作。
  • 将 token 存入到 Redis 中,是指定一个有效期的,有效期到了,Redis 会将 Token 删除。

4 基于Redis的Token工具类

上面的工具类是对 Redis 进行操作,下面基于上面的 RedisUtil 工具类,再创建一个操作 Token 的工具类,方便对操作 Redis 中的 Token。

RedisTokenUtil.java

java
package com.foooor.helloweb.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class RedisTokenUtil {

    @Autowired
    private RedisUtil redisUtil;

    // Token过期时间(30分钟)
    private static final long TOKEN_EXPIRATION = 30 * 60;

    /**
     * 生成Token并将Token缓存到Redis
     */
    public String generateToken(String username) {
        // 生成一个32个字符的UUID作为Token
        String token = UUID.randomUUID().toString().replace("-", "");
        // 存储到Redis,设置过期时间
        redisUtil.set("token:" + token, username, TOKEN_EXPIRATION, TimeUnit.SECONDS);
        return token;
    }

    /**
     * 验证Redis中是否存在该Token
     */
    public boolean validateToken(String token) {
        return redisUtil.hasKey("token:" + token);
    }

    /**
     * 每次请求刷新Token的过期时间
     */
    public void refreshToken(String token) {
        redisUtil.expire("token:" + token, TOKEN_EXPIRATION, TimeUnit.SECONDS);
    }

    /**
     * 移除Token
     */
    public void removeToken(String token) {
        redisUtil.delete("token:" + token);
    }

    /**
     * 根据Token获取用户名
     */
    public String getUsernameFromToken(String token) {
        return (String) redisUtil.get("token:" + token);
    }

}
  • 主要是对 Redis 中的 Token 进行操作,例如存储 Token 到 Redis、刷新token、删除token等操作。

5 创建Restful学生Controller

因为这一章是在前一章节的基础上进行的,所以这里为了统一,所以 Service 和 Mapper 就不用修改了。

但是上一个章节的 StudentController 是传统项目的 Controller,使用了视图,所以这里再添加一个 Restful API 的 RestStudentController,提供 Restful API。

RestStudentController.java

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("/apis/students")
public class RestStudentController {

    @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);
        }
    }
}
  • 为了区分接口,接口前面添加了 /apis

6 创建Restful登录Controller

在上一个章节中,是将用户登录信息存储在 Session 中,下面再编写一个用于登录和注册的 Controller,并在登录成功后,将 Token 存储到 Redis 中,退出时从 Redis 中删除 Token:

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.RedisTokenUtil;
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("/apis")
public class RestLoginController {

    @Autowired
    private IUserService userService;
    @Autowired
    private RedisTokenUtil redisTokenUtil;

    /**
     * 处理登录请求
     */
    @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) {
            // 生成Token并将其存入Redis
            String token = redisTokenUtil.generateToken(username);

            // 返回令牌
            Map<String, String> resultData = new HashMap<>();
            resultData.put("token", token);
            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);
        }
    }

    /**
     * 处理退出登录
     */
    @GetMapping("/logout")
    public Result logout(@RequestHeader("Authorization") String token) {
        // 从 Header 获取 Token,前端发起请求的时候,是将Token放在Authorization头中,格式为:Bearer <token>
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            // 从 Redis 中移除 Token
            redisTokenUtil.removeToken(token);
        }

        return Result.success();
    }
}
  • 客户端在请求的时候,在请求头中需要携带 Authorization 参数,值的格式是 Bearer <token> ,以 Bearer 作为前缀,这是 OAuth 2.0 规范中定义的认证方案,虽然这里没有这么高的要求,但是还是建议使用,统一一下。

7 创建基于Redis的拦截器

在上一章在拦截器中是从 session 获取登录信息,在这里创建基于Redis的登录拦截器 RedisLoginInterceptor.java,首先从请求头中取出 token,然后验证 Redis 中是否存在该 token,如果不存在,那么则没登录,抛出未登录的异常,由统一异常处理返回错误码。如果在 Redis 中查询到 token,说明登录了,则刷新一下 token ,继续延长 token 的过期时间。

java
package com.foooor.helloweb.interceptor;

import com.foooor.helloweb.common.BizException;
import com.foooor.helloweb.common.ErrorCode;
import com.foooor.helloweb.util.RedisTokenUtil;
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 RedisLoginInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTokenUtil redisTokenUtil;

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

        // 检查Redis中是否存在该token,不存在则抛出异常,进入异常处理流程,返回响应错误码给客户端
        if (!redisTokenUtil.validateToken(token)) {
            throw new BizException(ErrorCode.NO_PERMISSION);
        }

        // 走到这里,说明token有效,用户登录了,则刷新一下token的过期时间,继续延长token的过期时间
        redisTokenUtil.refreshToken(token);

        return true;
    }
}

8 配置拦截器配置

现在 Controller 创建好了,拦截器也创建好了,现在需要配置拦截器。

因为前一章 Controller 和 拦截器的存在,所以这里需要兼顾一下上一章的配置,这里涉及的两个拦截器,不要相互影响。

不要让上一章的拦截器拦截这一章的接口,也不要让这一章的拦截器拦截上一章的接口。

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

java
/**
 * 配置拦截器
 */
@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 其他,略...

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

    registry.addInterceptor(redisLoginInterceptor)
            .addPathPatterns("/**")  // 拦截所有请求
            // 添加例外,不拦截登录和注册页面
            .excludePathPatterns(
                    "/apis/login",
                    "/apis/register",
                    "/views/**" // 不拦截/apis/下的所有请求,交给loginInterceptor拦截器拦截
            );
}

9 测试Redis Token

使用 Apifox 或 Postman 测试接口。

  1. 首先直接访问学生列表接口,查看是否能获取到数据

可以看到直接访问 http://localhost:8080/apis/students 接口,返回错误:


然后使用 Post 方式请求登录接口,获取 Token:


拿到 token 以后,再次请求学生列表接口,并在请求头中添加登录获取到的 token,可以发现请求成功了:

请求头中添加 token 的格式:Authorization: Bearer 随机生成的Token字符串

如果调用退出接口后(记得请求头中携带token),再次请求学生列表接口,就无法获取数据了。

内容未完......