Appearance
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等类型,先这么简单理解。
整个执行流程如下:
- 用户登录(用户名 + 密码)
- 服务端验证客户端的用户名密码
- 生成 token 保存到 Redis,并返回给客户端
- 客户端保存 token
- 客户端每次请求,在请求头中添加
Authorization: Bearer <token> - 服务器收到请求,在拦截器中获取客户端传递的Token,并查询 Redis 中是否有该 Token
- 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 测试接口。
- 首先直接访问学生列表接口,查看是否能获取到数据
可以看到直接访问 http://localhost:8080/apis/students 接口,返回错误:

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

拿到 token 以后,再次请求学生列表接口,并在请求头中添加登录获取到的 token,可以发现请求成功了:
请求头中添加 token 的格式:
Authorization: Bearer 随机生成的Token字符串

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