Redis实现短信登录
业务流程
Session方式实现的问题
为了支持系统整体的性能,会使用负载均衡的相关技术。此时,由于多台Tomcat不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失。
- 每个Tomcat服务器有自己的session空间。
- 第一次请求,Nginx分配给Tomcat A;第二次请求Nginx分配给Tomcat B,B无法获取到A中的Session数据。
需要一种方案替代Session,这种方案必须满足:
- 数据共享:不同Tomcat能够读取到相同的数据。(改进)
- 内存存储:Session存在服务器内存,速度快。(保持)
- Key-Value结构:Session本身就是存储键值对,效率高。(保持)
Redis替代Session
- Redis是Tomcat服务器方案之外的存储方案,任何一台Tomcat都可以访问Redis。
- Redis存在内存中,性能强劲。
- Redis是键值对存储,Value支持多种数据结构。
Redis实现短信验证登录
思考
- 使用Redis保存数据,Value应该选择何种数据结构?
验证码用String即可。 - Key应该存什么?
使用Session时,由于Tomcat独占各自内存,保存相同Key值互不干扰。当用户请求到来,自动生成SessionID写入用户浏览器,用户下次访问携带SessionID可以查到服务器之前保存的KEY-VALUE,即不同的请求可以使用相同的KEY,但由于SessionID的存在,仍然能够区分(SessionID-KV)。而Redis是共享空间,对于不同Tomcat处理的请求,由于SessionID不存在,无法区分,需要通过KEY来区分(K-V)。需要为每个不同的手机号,需要保存不同的Key。因此直接将手机号作为Key值。 - 通过验证后,从MySQL查到的用户对象(根据查询结果封装)用什么数据结构存到Redis?
首先,此处将用户对象存入Redis是为了访问资源时携带用户信息,便于进行对应资源的展示(使用Session方式,登陆成功时,也会存入用户对象)。此处Redis应该使用Hash来存储用户对象信息,相较于JSON字符串,占用空间更少(Json会存入一些结构化的字符,例如“{}”)。Key值使用随机Token,并在前端手动写入用户浏览器(替代SessionID)。以后用户请求携带token,从而查找到用户对应的用户对象。
实现
验证码注册/登录
UserServiceImpl实现类中定义sedCode方法,其中phone是前端的传值。
@Override
public Result sedCode(String phone, HttpSession session) {
//1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3. 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4. 保存验证码到session
//设置一个验证码的有效期,验证码到期即从Redis删除。
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5. 发送验证码(此处模拟,应该使用阿里云等平台提供的服务接口实现)
log.debug("发送短信验证码成功,验证码:{}", code);
//返回ok
return Result.ok();
}
UserServiceImpl实现类中定义login方法,其中loginForm是前端的表单,包含手机号。
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//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("验证码错误");
//4.通过验证码校验,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5. 判断用户是否存在
if (user == null) {
//6. 不存在,创建新用户到MySQL数据库
user = createUserWithPhone(phone);
}
//7.保存用户信息到redis中
//7.1生成随机token作为登录令牌
String token = UUID.randomUUID().toString(true);
//7.2将User转为UserDTO(保留基础信息,防止隐私泄露)Hash存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //简化用户描述信息,避免信息泄露
//7.3将UserDTO对象转为Map类型。
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions
.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((filedName, filedValue) -> filedValue.toString())); //将对象转为Map(包含多个KV),
//7.3 存储用户对象信息到Redis
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4 设置有效期,防止大量数据占内存
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
访问鉴权:登录/注册完毕后跳转到资源页,资源页面的访问可能需要鉴权!
对于共有数据,例如首页,广告,无需鉴权。但访问这些页面仍然需要刷新token,刷新redis中用户信息的有效期。否则,用户访问公共页面太久会被推出私密数据的访问权限。
RefreshTokenInterceptor全局拦截器中刷新token。在需要登录的路径查询ThreadLocal中是否存在用户信息。
@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;
}
为什么要存到ThreadLocal?既然已经存到Redis,那每次用户请求,直接去Redis中鉴权不就好了?Redis是共用数据库,用户量过大、访问量上升时导致服务器负担较重。使用ThreadLocal可以减少io操作,也可以保证访问的稳定性,如果访问过程中redis故障了,ThreadLocal仍然正常使用。更多参考:百度贴吧、CSDN
LoginInterceptor登录拦截器中只做鉴权,不做刷新。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}