📚博客主页:代码探秘者
✨专栏:文章正在持续更新ing…
✅C语言/C++:C++(详细版) 数据结构) 十大排序算法
✅Java基础:JavaSE基础 面向对象大合集 JavaSE进阶 Java版数据结构JDK新特性
✅后端经典框架: 后端基础 SpringBoot Tlias项目(含SSM)
✅数据库:Mysql
✅常用中间件:redis入门+实战 Elasticsearch RabbitMQ
✅Linux: 部署篇
✅微服务:微服务
❤️感谢大家点赞👍🏻收藏⭐评论✍🏻,您的三连就是我持续更新的动力❤️
🙏作者水平有限,欢迎各位大佬指点,相互学习进步!
文章目录
Spring 登录拦截器 + Redis + ThreadLocal 实现用户认证与退出
本文注意:
章节一、二可以只做了解,不详细探讨
章节三开始才是重点
一、基于Session实现共享(了解)
1.登录
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行
2.共享
核心思路分析:
具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
在单体应用中,使用 HttpSession
管理用户登录状态非常方便。但一旦系统扩展为分布式架构(多台服务器、多个节点),传统的 session
就暴露了明显的缺陷。
二、❌ 为什么不能直接用 Session
?(了解)
1. Session 不共享
HttpSession
是服务器内存中的一个对象,只存在于当前服务器节点上。- 用户如果第一次请求被路由到 A 服务器,登录状态保存在 A 的内存中;
- 下次请求被路由到 B 服务器,B 没有这个
session
,用户就被认为未登录。
结论:在分布式环境中,session 是不共享的。
2. Session 粘性问题(Session Stickiness)不可扩展
- 可以使用“粘性会话(sticky session)”策略,让某用户的请求固定落到同一台服务器。
- 但这带来两个问题:
- 一旦该节点宕机,用户 session 丢失;
- 负载不均衡,影响性能。
3. 不能横向扩展
- 使用 session 意味着必须将用户状态保存在单一机器内存中。
- 当业务量上升、服务器需要扩容时,session 难以迁移或同步,不利于扩展和容灾。
4. 不适用于前后端分离 / 移动端接口
- 移动端、前端应用(如 React/Vue)通常使用 Token 来进行身份认证,不能依赖 cookie 自动传递 sessionId。
HttpSession
是基于 cookie 的机制,不适用于 token 驱动的接口体系。
✅ 分布式登录正确姿势:Token + Redis
为了实现分布式下的统一登录状态管理,通常会采用:
技术 | 作用 |
---|---|
Token | 登录后生成的唯一标识,客户端存储,服务端根据 token 查 Redis 获取用户信息 |
Redis | 高性能、集中式缓存服务器,用于保存用户登录信息,可被所有节点共享 |
ThreadLocal | 拦截器中将当前用户信息存入 ThreadLocal,在业务代码中随时获取 |
🧠 总结一句话:
HttpSession
是单机状态,不能跨节点共享,在分布式系统中不可用。需要使用 Redis + Token 实现用户登录状态的统一管理。
三、基于Redis发送短信验证码
✅ 1.页面流程
✅ 2.核心逻辑总结(共5点)
- 校验手机号格式(防止非法输入)。
- 生成 6 位随机数字验证码(使用 Hutool 工具)。
- 将验证码存入 Redis(key = 手机号拼接常量)。
- 设置验证码有效期(如2分钟,防止长期有效)。
- 记录日志并返回成功结果(便于调试)。
✅ 3.Service 实现类部分
UserServiceImpl里发送验证码
📘 核心代码
@Override
public Result sendCode(String phone) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
//2.符合,生成验证码,保存到redis
String code= RandomUtil.randomNumbers(6); //hutool工具 2L 按分钟计算
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.info("发送验证码成功,验证码为:{}",code);
//返回ok
return Result.ok();
}
- 测试成功
四、基于Redis实现登录
✅ 1.页面流程
✅ 2.重要逻辑总结(核心9点)
- 校验手机号是否合法(用正则工具类
RegexUtils
检查格式)。 - 验证验证码是否正确(从 Redis 获取验证码并与用户输入对比)。
- 根据手机号查询用户(若不存在则创建新用户)。
- 生成登录 token(UUID 唯一字符串)。
- 将用户信息封装为 DTO 对象(避免泄露敏感数据)。
- 使用
BeanUtil.beanToMap
将 DTO 转为 Map(为存 Redis 哈希结构做准备)。 - 将用户信息写入 Redis(以 token 为 key,Map 为 value)。
- 设置 Redis 过期时间(表示登录状态有效期)。
- 返回 token 给前端(供前端后续请求时携带身份信息)。
✅ 3.隐藏用户敏感信息
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
在登录方法处修改
//5.2 保存token到HashMap存储(但是不要存私密信息)
UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
//6.添加用户
UserHolder.saveUser(userDTO);
// 难理解 userDTO转换为HashMap
Map<String,Object> userMap=BeanUtil.beanToMap(userDTO,new HashMap<>(), // 创建一个空map
CopyOptions.create().setIgnoreNullValue(true) //忽略空值
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//key(属性) :value(属性值,转为字符串)
//7.将token和userMap存储到redis中
String tokenKey= LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
在拦截器处:
//存在,保存用户信息到Threadlocal
UserHolder.saveUser(userDTO);
✅ 4.ThreadLocal实现用户上下文共享
在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离。
在UserHolder处:将user对象换成UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
✅ 5.key的结构设计
redis:
✅ 6.代码
UserServiceImpl里
@Override
public Result login(LoginFormDTO loginFormDTO, HttpSession session) {
//1.先判断手机号
String phone = loginFormDTO.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
//2.校验验证码(不为空/需要与redis的正确)
//获取校验码
String code = loginFormDTO.getCode();
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY +phone);
if(code == null || !cacheCode.equals(code)){
return Result.fail("验证码错误");
}
//3.验证码正确,查询用户
User user = query().eq("phone", phone).one();
//4.用户不存在,创建新用户
if(user == null){
// user =createUserWithPhone(phone);//使用手机号创建一个用户
user = new User();
user.setPhone(phone);
user.setNickName(USER_SIGN_KEY+RandomUtil.randomString(10));
//保存用户
save(user);
}
//5.保存用户信息到redis中
//5.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString();
//5.2 保存token到HashMap存储(但是不要存私密信息)
UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
//6.添加用户
UserHolder.saveUser(userDTO);
// 难理解 userDTO转换为HashMap
Map<String,Object> userMap=BeanUtil.beanToMap(userDTO,new HashMap<>(), // 创建一个空map
CopyOptions.create().setIgnoreNullValue(true) //忽略空值
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//key(属性) :value(属性值,转为字符串)
//7.将token和userMap存储到redis中
String tokenKey= LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//8.设置token有效期 //设置的分钟数 按分钟计算
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);//注意:这里的key是tokenKey,不是userMap
//9.返回token
return Result.ok(token);
}
五、登录拦截器以及优化
✅1.初始方案思路
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。
✅2.优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
✅ 3.登录状态刷新拦截器
刷新token拦截器-RefreshTokenInterceptor(优先级最高)
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
✅ 4.登录拦截器
登录拦截器-LoginInterceptor(优先级第二)
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户,则放行
return true;
}
}
注册拦截器-MvcConfig
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录的拦截器
registry.addInterceptor(new LoginInterceptor()) //.addPathPatterns("/**")
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1); //越小的数字,优先级越高
//token刷新(以及保存登录用户)
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
}
}
六、获取用户信息和退出登录
✅ 1.获取用户信息
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
//我的获取用户信息
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
✅2.退出登录
@PostMapping("/logout")
public Result logout(HttpServletRequest request){
// TODO 实现登出功能
//获取token
String token = request.getHeader("authorization");
if (token == null || token.isEmpty()) {
return Result.fail("缺少有效的登录凭证");
}
String key = LOGIN_USER_KEY + token;
// 删除Redis中对应的用户信息
stringRedisTemplate.delete(key);
// 清除线程本地存储的用户信息
UserHolder.removeUser();
return Result.ok("退出登录成功");
}