2023.12.6
短信登陆如果基于session来实现,会存在session共享问题:多台Tomcat不能共享session存储空间,这会导致当请求切换到不同服务器时出现数据丢失的问题。
早期的解决办法是让session提供一个数据拷贝的功能,即让各个Tomcat的数据实现一个互相的拷贝,但是这种方式容易造成内存空间的浪费,并且拷贝是需要时间的,这段时间内依然存在数据不一致的问题。
于是我们可以基于Redis来完成,由于Redis数据本身就是共享的,就可以避免session共享的问题了,并且redis是基于内存的,读写性能高。
下面基于redis实现一下代码:
发送短信验证码
之前是将验证码保存到session中,现在是将验证码保存到redis中,此时需要考虑redis采用什么数据结构来保存验证码:key需要保证唯一性,所以使用用户的手机号作为key;由于验证码就是一个六位数的字符串,所以value直接采用String保存就好。
该模块的代码如下:
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.不符合则返回错误信息
return Result.fail("手机号格式错误!");
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到session
//session.setAttribute("code",code);
//4.保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);//设置过期时间定期清理redis中的验证码
//5.发送验证码
log.debug("发送验证码成功,验证码:{}",code);
return Result.ok();
}
这里不真的发验证码,就模拟一下就好了,验证码直接输入到控制台上。
短信验证码登陆和注册模块
这里的redis要存的是用户对象信息,所以value值我们采用HashMap结构去存,该结构可以将对象的每个字段独立存储,方便针对单个字段进行CRUD。
redis的key的选择这里不选择手机号,而是采用一个随机token作为key,此处的token最终是要返回给前端页面的,类似于之前的session id(登陆凭证),所以key填手机号的话不安全;并且为了后续能够通过这个token得到用户信息,需要将此token返回给客户端。
该模块代码如下:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
//2.不符合则返回错误信息
return Result.fail("手机号格式错误!");
}
//3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
//3.验证码不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到redis中
//7.1 随机生成token,作为登陆令牌
String token = UUID.randomUUID().toString(true);
//7.2 将User对象转为HashMap存储
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.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
//7.4 设置token有效期
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
// 返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
//1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}
此处有个细节,虽然我们设置了token有效期为30分钟,但是不管我们登陆后做什么操作,token始终都是30分钟后被消除。 而正常情况应该是我们登陆后如果有操作行为,token的有效期应该被重置为30分钟。
于是我们可以使用一个拦截器对请求进行拦截,每当有请求发生,就将token的有效期重置。
拦截器的代码为:
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 = RedisConstants.LOGIN_USER_KEY+token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//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,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
写完拦截器的代码之后,还需要在MvcConfig中进行配置:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登陆拦截器
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
//token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
这里虽然是在第二行配置的刷新有效期拦截器,但是它会先进行拦截,因为我们order里填的数字是0,数字越小优先级越高。
第一行的拦截器是用来进行登陆校验的,即如果你访问了除代码中路径以外的路径的话,需要判断你有没有登陆,那如何判断你有没有登陆呢?只需要查看当前ThreadLocal中有无用户信息就知道了,该拦截器代码如下:
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;
}
}
拦截器的整体架构图如下: