Spring Boot 实战:基于 Redis + Token 实现分布式登录系统

1. 设计思路

在分布式系统或前后端分离的架构中,我们需要一种无状态的登录方案。核心设计思路如下:

  1. 凭证机制:使用 Token(随机字符串)作为用户身份的唯一标识,替代传统的 Cookie。
  2. 数据存储:将用户的登录状态(Token 与用户信息的映射)存储在 Redis 中,利用其高性能和自动过期机制。
  3. 状态管理Redis:作为服务端共享的“会话存储中心”。 ThreadLocal:作为单次请求内的“上下文容器”,方便 Controller 和 Service 层获取用户信息。
  4. 请求拦截:采用 双拦截器模式,分离“Token 刷新”与“登录鉴权”的职责。

2. 代码实现

2.1 步骤一:发送短信验证码

验证码需要有短期的有效期(如 2 分钟),适合使用 Redis 的 String 结构存储。

  • Key: login:code:{手机号}
  • Value: 验证码数字
typescript @Override public Result sendCode(String phone) { // 1. 校验手机号格式 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误"); } // 2. 生成 6 位随机验证码 String code = RandomUtil.randomNumbers(6); // 3. 保存验证码到 Redis // 设置 2 分钟过期 stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, 2, TimeUnit.MINUTES); // 4. 模拟发送短信(实际项目中调用短信服务商 API) log.debug("发送验证码成功,验证码:{}", code); return Result.ok(); }

2.2 步骤二:登录并生成 Token

登录成功后,我们需要生成 Token,并将用户对象存储为 Redis 的 Hash 结构。Hash 结构适合存储对象,且内存占用更优。

  • Key: login:token:{随机Token}
  • Value: User 对象的字段映射 (Hash)
ini @Override public Result login(LoginFormDTO loginForm) { String phone = loginForm.getPhone(); // 1. 从 Redis 获取验证码并校验 String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { return Result.fail("验证码错误"); } // 2. 根据手机号查询用户(若不存在则注册,代码略) User user = query().eq("phone", phone).one(); if (user == null) { user = createUserWithPhone(phone); } // 3. 生成随机 Token (作为登录令牌) String token = UUID.randomUUID().toString(true); // 4. 将 User 对象转为 HashMap 存储 // 核心细节:StringRedisTemplate 要求 Value 必须是 String, // 所以必须将 Long 类型的 ID 强转为 String,否则会报 ClassCastException UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); // 5. 存储到 Redis String tokenKey = RedisConstants.LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); // 6. 设置有效期(例如 30 分钟) stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); // 7. 返回 Token 给前端 return Result.ok(token); }

rust Redis 数据库 │ ├── Key: "login:token:xyz-123" <-- 这是你设置的 Redis Key (Token) │ │ │ └── Value (Hash结构) <-- 这是一个大容器 │ ├── Field: "id" --> Value: "101" (必须是 String) │ ├── Field: "nickName" --> Value: "小明" (必须是 String) │ └── Field: "icon" --> Value: "/a.jpg" (必须是 String)

2.3 步骤三:构建“双拦截器”防御体系

为了实现 “用户只要在使用 App,登录状态就一直有效” 的无感刷新体验,我们将拦截器拆分为两层。

第一层:Token 刷新拦截器 (RefreshTokenInterceptor)

  • 拦截路径:所有路径 (/**)
  • 职责:只要携带了 Token,就刷新 Redis 有效期,并保存用户信息到 ThreadLocal。不负责拦截请求。
java public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate 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; // 没 Token 也放行,交给下一道拦截器 } // 2. 基于 token 获取 Redis 中的用户 String key = RedisConstants.LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); // 3. 判断用户是否存在 if (userMap.isEmpty()) { return true; // Token 无效或过期,也放行 } // 4. 将查询到的 Map 转为 UserDTO 对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); // 5. 保存到 ThreadLocal (供后续业务使用) UserHolder.saveUser(userDTO); // 6. 【核心动作】刷新 Token 有效期 stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); // 7. 放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求结束,清理 ThreadLocal,防止内存泄漏 UserHolder.removeUser(); } }

第二层:登录拦截器 (LoginInterceptor)

  • 拦截路径:敏感业务路径 (排除首页、登录接口等)
  • 职责:只检查 ThreadLocal 是否有用户。如果没有,则拦截并返回 401。
java public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 判断 ThreadLocal 中是否有用户 if (UserHolder.getUser() == null) { // 2. 没有用户,拦截并返回 401 (未授权) response.setStatus(401); return false; } // 3. 有用户,放行 return true; } }

2.4 步骤四:配置拦截器 (MvcConfig)

在配置类中注册这两个拦截器,并通过 order 控制执行顺序:先刷新,后拦截。

typescript @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { // 1. 注册刷新拦截器 (Order = 0, 最先执行) // 拦截所有请求,确保用户访问任何页面都能刷新 Token registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns("/**") .order(0); // 2. 注册登录拦截器 (Order = 1, 后执行) // 只拦截需要保护的接口 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/user/login", "/user/code", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**" ).order(1); } }

3. 业务层使用

在 Controller 或 Service 层,无需再操作 Request 或 Redis,直接从 ThreadLocal 获取当前登录用户即可。

java @GetMapping("/me") public Result me(){ // 直接获取当前用户 UserDTO user = UserHolder.getUser(); return Result.ok(user); }

4. 总结

这套 Redis + Token + 双拦截器 的方案具有以下优势:

  1. 高性能:Redis 的读写速度极快,不会拖慢请求响应。
  2. 无感刷新:通过 RefreshTokenInterceptor,解决了用户在使用过程4中 Token 突然过期的问题。
  3. 职责单一:登录逻辑只负责生成 Token,拦截器只负责鉴权,业务层只负责业务,代码结构清晰,耦合度低。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值