1. 基于Redis的登录校验实现方案
在当今互联网应用中,用户认证是系统安全的第一道防线。传统的Session认证方式在分布式环境下存在诸多限制,而基于Token的无状态认证方案正逐渐成为主流选择。本文将详细介绍如何利用Redis实现一套完整的手机验证码登录校验系统,涵盖从验证码发送到接口权限控制的完整流程。
这套方案的核心优势在于:
- 利用Redis的高性能特性,实现快速验证
- 通过Token机制实现无状态认证,天然支持分布式部署
- 结合ThreadLocal实现用户信息线程级共享
- 采用双拦截器设计优化性能与用户体验
2. 系统架构设计
2.1 整体流程设计
登录校验系统的核心流程可分为三个关键阶段:
-
验证码阶段:
- 用户提交手机号获取验证码
- 服务端生成随机验证码并存入Redis
- 验证码通过短信服务发送给用户(演示中使用日志替代)
-
登录认证阶段:
- 用户提交手机号和验证码
- 服务端校验验证码有效性
- 查询/创建用户信息
- 生成Token并保存用户数据到Redis
- 返回Token给客户端
-
请求拦截阶段:
- 客户端携带Token访问受保护接口
- 拦截器校验Token有效性
- 刷新Token有效期
- 通过ThreadLocal共享用户信息
2.2 技术选型解析
Redis存储设计:
- 验证码存储:String结构,key为"login:code:{phone}",value为6位数字验证码
- 用户信息存储:Hash结构,key为"login:token:{token}",field-value对应用户属性
Token机制:
- 采用UUID生成唯一Token
- 设置合理有效期(通常30分钟)
- 每次访问自动续期
ThreadLocal应用:
- 在拦截器中解析用户信息
- 存入ThreadLocal供业务层使用
- 请求结束后自动清理
3. 核心实现细节
3.1 验证码发送实现
验证码发送接口是登录流程的起点,需要特别注意安全性和性能:
java复制@Override
public Result sendCode(String phone) {
// 1. 严格校验手机号格式
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 生成6位随机数字验证码
String code = RandomUtil.randomNumbers(6);
// 3. Redis存储验证码,设置5分钟有效期
String key = LOGIN_CODE_KEY + phone;
stringRedisTemplate.opsForValue().set(
key,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);
// 4. 模拟发送短信(生产环境应接入短信服务)
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
关键点说明:
- 手机号校验使用正则表达式,确保格式正确
- 验证码生成使用Hutool的RandomUtil,保证随机性
- Redis存储设置合理有效期,防止资源浪费
- 生产环境应替换为真实短信服务
注意事项:验证码有效期不宜过长(建议5分钟),且同一手机号获取频率应做限制,防止短信轰炸攻击。
3.2 用户登录实现
登录接口需要处理多种业务逻辑,包括验证码校验、用户查询/创建、Token生成等:
java复制@Override
public Result login(LoginFormDTO loginForm) {
// 1. 二次校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 验证码校验(从Redis获取)
String cacheCode = stringRedisTemplate.opsForValue()
.get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误!");
}
// 3. 查询用户信息
User user = query().eq("phone", phone).one();
// 4. 新用户自动注册
if (user == null) {
user = createUserWithPhone(phone);
}
// 5. 生成Token并保存用户信息
String token = UUID.randomUUID().toString(true);
String tokenKey = LOGIN_USER_KEY + token;
// 6. 用户信息转换处理
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())
);
// 7. Redis存储用户信息
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8. 删除已使用的验证码
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
return Result.ok(token);
}
关键设计考量:
- 采用DTO模式返回用户信息,避免敏感数据泄露
- 使用Hash结构存储用户信息,便于部分更新
- 登录成功后立即删除验证码,防止重复使用
- 新用户自动注册提升用户体验
实操技巧:BeanUtil.beanToMap转换时注意处理null值和类型转换,特别是使用StringRedisTemplate时所有值必须为String类型。
4. 拦截器设计与优化
4.1 基础拦截器实现
初始版本的拦截器同时处理认证和Token刷新,虽然简单但存在优化空间:
java复制public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(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 (token == null) {
response.setStatus(401);
return false;
}
// 2. 查询用户信息
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
// 3. 保存用户信息到ThreadLocal
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 4. 刷新Token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
UserHolder.removeUser();
}
}
问题分析:
- 所有请求都需要经过完整校验,包括不需要登录的接口
- Token刷新只对需要登录的接口有效
- 拦截逻辑不够灵活
4.2 优化后的双拦截器方案
将功能拆分为两个独立拦截器,各司其职:
刷新Token拦截器:
java复制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. 查询用户信息
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) {
return true;
}
// 3. 保存用户并刷新Token
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
UserHolder.removeUser();
}
}
登录校验拦截器:
java复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 只需检查ThreadLocal中是否存在用户
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}
}
配置类调整:
java复制@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器(order=1)
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/voucher/**",
"/upload/**"
).order(1);
// Token刷新拦截器(order=0,优先执行)
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.order(0);
}
}
优化效果:
- 所有请求都经过Refresh拦截器,有机会刷新Token
- 只有需要登录的接口会触发Login拦截器
- 职责分离,代码更清晰
- 性能更优,不需要登录的接口省去了Redis查询
5. 安全增强与性能优化
5.1 安全防护措施
-
验证码安全:
- 限制同一IP/手机号的获取频率
- 设置合理的验证码有效期(建议5分钟)
- 验证码使用后立即删除
-
Token安全:
- 使用足够随机的Token(UUID)
- 设置合理的有效期(通常30分钟)
- 采用HTTPS传输防止窃听
- 考虑加入JWT签名机制增强安全性
-
接口防护:
- 敏感接口强制登录
- 关键操作需二次验证
- 记录操作日志
5.2 性能优化建议
-
Redis优化:
- 使用Pipeline批量操作
- 合理设置数据结构
- 适当使用本地缓存减少Redis访问
-
并发处理:
- 使用分布式锁处理并发登录
- 避免在拦截器中执行耗时操作
-
存储优化:
- 控制Redis中存储的数据量
- 对用户信息进行压缩
- 定期清理过期数据
6. 常见问题排查
6.1 验证码相关问题
问题1:验证码发送成功但收不到短信
- 检查短信服务配置
- 确认手机号不在黑名单
- 验证短信模板是否审核通过
问题2:验证码校验不通过
- 检查Redis中存储的验证码
- 确认客户端提交的验证码是否正确
- 检查验证码是否已过期
6.2 Token相关问题
问题1:Token频繁失效
- 检查Token有效期设置
- 确认拦截器是否正确刷新了有效期
- 检查系统时间是否准确
问题2:用户信息获取失败
- 检查Redis中对应Key是否存在
- 确认Token传递是否正确
- 检查数据结构是否匹配
6.3 拦截器相关问题
问题1:拦截器不生效
- 检查拦截器注册顺序
- 确认路径匹配规则
- 检查拦截器是否被Spring管理
问题2:ThreadLocal数据污染
- 确保afterCompletion中清理数据
- 避免使用线程池复用线程
- 考虑使用RequestContextHolder替代
7. 扩展思考
7.1 多端登录支持
实际业务中常需要支持多端(Web、APP、小程序)同时登录,可以考虑:
- 为每个设备生成独立Token
- 在用户信息中记录登录设备
- 提供登录设备管理功能
7.2 分布式会话管理
在大型分布式系统中,可以:
- 引入Redis集群提高可用性
- 考虑多级缓存架构
- 实现会话同步机制
7.3 无感刷新方案
为了提升用户体验,可以实现:
- 双Token机制(accessToken + refreshToken)
- 静默刷新接口
- 前端自动处理Token更新
这套基于Redis的登录校验方案经过多个线上项目验证,在保证安全性的同时提供了良好的性能表现。开发者可以根据实际业务需求进行调整和扩展,比如加入风控策略、多因素认证等增强措施。