Redis-短信登录04-基于Redis实现验证码登录

  1. 1. 一、session共享问题
  2. 2. 二、Redis代替session的业务流程
    1. 2.1. 1. 设计key的结构
    2. 2.2. 2. 设计key的具体细节
    3. 2.3. 3. 整体访问流程
  3. 3. 三、🍖 Redis短信登录流程描述
    1. 3.1. 🥩 短信验证码的发送
    2. 3.2. 🥩 短信验证码的验证
    3. 3.3. 🥩 是否登录的验证
  4. 4. 四、代码实现
    1. 4.1. 编程习惯:
    2. 4.2. UserServiceImpl代码
  5. 5. 五、登录验证优化
    1. 5.1. 解决状态登录刷新问题
      1. 5.1.0.1. 优化方案:
  6. 5.2. MvcConfig配置类注册拦截器
  7. 5.3. 创建两个拦截器:
    1. 5.3.1. RefreshTokenInterceptor前置拦截器
    2. 5.3.2. LoginInterceptor.java 登录拦截器

​ 与之前的对比,session登录校验是由tomcat自动地把sessionID写入cookie里,以后每次请求带着cookie就带了sessionID,根据sessionID找到session继而找到用户, 而现在是以token作为登录令牌,由于没有tomcat的帮助,我们需要手动地把token返回给前端,而后每次请求基于 token 再从 redis 中获取用户(以随机token为key来存储用户数据,而不是继续用手机号来作为key)

一、session共享问题

核心思路分析:

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

二、Redis代替session的业务流程

1. 设计key的结构

首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

2. 设计key的具体细节

所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了

在设计这个key的时候,我们之前讲过需要满足两点

1、key要具有唯一性

2、key要方便携带

如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了

3. 整体访问流程

​ 当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

三、🍖 Redis短信登录流程描述

🥩 短信验证码的发送

  用户提交手机号,系统验证手机号是否有效,毕竟无效手机号会消耗你的短信验证次数还会导致系统的性能下降。如果手机号为无效的话就让用户重新提交手机号,如果有效就生成验证码并将该验证码作为value保存到redis中对应的key是手机号,之所以这么做的原因是保证key的唯一性,如果使用固定字符串作为可以的话会被后面的数据所覆盖。然后在控制台输出验证码模拟发送验证码的过程

🥩 短信验证码的验证

  用户的手机号接收到验证码后在平台上提交验证码,系统从redis中根据手机号读取验证码并进行校验,如果验证通过的话就根据用户验证使用的手机号去数据库中进行查询用户信息。如果存在就将查询到的用户信息保存到redis中,完成登录;如果不存在的话就创建一个新用户,并将该用户的信息分别保存到sql数据库和redis中,生成随机token作为key、使用hash结构存储user数据作为value,并将这个token返回给客户端,至此完成登录注册

🥩 是否登录的验证

  用户访问系统业务逻辑的时候需要校验他是否已经登录,如果登录可以访问否则就去登录,那么该如何完成是否登录的校验呢?这就要了解session的相关知识了,每一个session都有一个sessionId信息保存在浏览器的cookie中,当用户使用浏览器发送请求的时候会携带上cookie信息,此时系统就可以使用cookie中的sessionId获取到session信息,并通过session获取到登录时存储的用户信息。如果此时用户在数据库中存在的话就将该用户的信息缓存在ThreadLocal(方便后续验证)中,并放行该访问;否则就说明发送请求的用户未登录或不合法,就要拦截到他的请求前往登录

四、代码实现

编程习惯:

对于一些常量我们都会将它单独置于一个类中,并用public static final修饰,例如:

1
2
3
4
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
}

UserServiceImpl代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);

// 4.保存验证码到 redis 2分钟 -》120秒
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, 2, TimeUnit.MINUTES);

// 5.发送验证码
log.info("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}

@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("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();

//5.判断用户是否存在
if(user == null){
//6. 不存在,则创建
user = createUserWithPhone(phone);
}
// System.out.println(user);
//7.保存用户信息到 redis 中
// 7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true); // 没有横线的
// 7.2 将 User 对象的属性拷贝到 UserDTO对象
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 将 UserDTO对象转化为 Hash 存储
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有效期 30分钟
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));
return user;
}

}

五、登录验证优化

解决状态登录刷新问题

优化方案:

​ 既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

  

​ 由上面的登录验证可知,我们对一些需要用户登录验证的功能设置了拦截器,如果验证通过会刷新token的有效期,这样的话只要用户一直访问我们拦截的功能就可以一直保持token是有效的。

​ 但是,如果用户登陆之后的操作一直是不需要验证的,那也就意味着token的有效期一直不会刷新,这样的话30分钟之后token就会失效用户验证就会失败,这样显然是不合理的。
  于是我们可以使用两个拦截器完成,最前面的负责拦截所有的请求,获取token、从redis中查询用户,将查询结果放到ThreadLocal(可能存null)、刷新token有效期,最后直接放行;后面的拦截器只负责判断有没有从redis中查询到用户,他从ThreadLocal获取查询结果,判断有则放行,无则拦截。

MvcConfig配置类注册拦截器

​ 创建完拦截器之后要将两个拦截器通过配置类配置到容器中生效,多个拦截器的优先级,默认按照添加顺序执行优先级,但是也可以使用order方法指定优先级,按参数的大小排序优先级,参数越小优先级越高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* @author 蓝顺远
* @version 2022/9/12 15:43
* @desc : 配置类注册拦截器
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private RefreshTokenInterceptor refreshTokenInterceptor;
@Autowired
private LoginInterceptor loginInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 前置拦截器
registry.addInterceptor(refreshTokenInterceptor)
.addPathPatterns("/**")
.order(0);
// 后置拦截器
registry.addInterceptor(loginInterceptor)
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
.order(1);
}

}

创建两个拦截器

RefreshTokenInterceptor前置拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* @desc : 前置拦截器,拦截所有请求,前置工作
*/
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {

@Autowired
private 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); // entries方法可以获得hash的所有的键值对
// 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();
}
}

LoginInterceptor.java 登录拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @desc : 登录拦截器,拦截需要拦截的请求,判断登录信息
*/
@Component
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;
}
}