开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情
1.内容概述
最近在学习redis的实战应用,本篇的内容主要介绍redis如何保存短信的验证码,以及如何保存用户信息并以此来进行用户登录的认证。至于发短信的过程,采用的是模拟的方式,将短信打印出来。
2.实现思路
2.1生成验证码,并将验证码保存到redis
服务器生成验证码,将验证码保存到redis之后将验证码发送到用户手机。
代码实现思路
这个接口的任务是生成验证码,保存验证码到redis,最后将验证码发送给用户。为此对这部分代码做一些说明:
- 因为需要发验证码,所以接口需要一个接收用户手机号的参数,接收到手机号后,需要对手机号进行验证验证,相关正则表达式的工具类可以在网上查询。有些同学可能有疑问,为什么需要有HttpSession类型的参数?其实这个参数现在用不着了,如果有看过我上一篇关于session进行手机验证码登录的文章的话,就可以发现,这部分代码是直接在上一篇文章里修改的,因此大家可以忽略这个参数。
- 生成验证码使用的工具类是hutool依赖包提供的,有需要的同学可以自行导入这个依赖。
- 操作redis数据库的对象是StringRedisTemplate。它提供了一系列操作redis的方法。注意:我们这里使用redis的String数据结构,将手机号做key,验证码做value。这里面有些小细节,第一个是我们不是直接用手机号码做key,而是在手机号码加了个前缀,比如LOGIN_CODE_KEY+phone,LOGIN_CODE_KEY是前缀常量,原因是避免后面有别的键需要使用到手机号;第二个是设置验证码的有效期。大家看到例子的LOGIN_CODE_TTL就是一个我们自己定义的Long类型的常量。
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
@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分钟有效时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.发送验证码
log.debug("发送短信验证码成功,验证码:{}",code);
// 返回ok
return Result.ok();
}
2.2短信登录或注册
这个接口的功能是:根据用户提交的验证码和redis中保存的验证码进行比对,如果两者一样,说明用户是手机的拥有者。然后根据手机号查询用户是否注册,如果用户已经注册,那么便生成token,用这个token作为key,用户信息作为value保存到redis中,原因是可以在拦截器中做用户登录认证;如果用户为注册,那么我们需要为用户注册,注册之后的操作和上面用户已注册的操作一样。
伪代码
- 因为是通过手机号登录,所以校验手机号码是必须的。
- 用户提交的验证码想要验证,需要从redis中拿出生成验证码时保存存进redis中的验证码,用着两者进行比对。从redis中获取验证码采用的方式是用tringRedisTemplate对象去调用对应的方法实现的。
- 为什么要生成token? 因为我们需要保存用户信息到redis中。其中token是key,用户信息是value。最后我们会将token返回给用户,以后用户再次访问应用的时候,可以携带这个token,我们根据这个token在去查询redis是否有对应的用户,如果有,说明用户已登录,否则用户未登录。为什么不用手机号作为key,还要自己生成token来作为key? 因为我们验证用户登录的凭证是根据用户提空的令牌是否能在redis中查询到对应的用户来进行判断的,而这个令牌是保存在浏览器,如果用手机号的话,那么便有信息泄露的风险。
- 将用户信息保存到redis中应该选什么数据结构? 最简单的数据结构是String数据结构,token为key,接着将用户对象序列化为JSON,将这JSON字符串作为value存储起来,好处是value值一目了然,缺点是如果数据信息多,占用的内存就多;例子采用的是hash数据结构,因为hash数据结构能把用户对象的属性名和属性值一起存进来。
- 设置token在redis中的有效期是必须的,如果用户长时间没有操作,那么就应该清除掉。
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm,session);
}
//登录
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//TODO 3.从redis拿出验证码,校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null | !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户 select * from tb_user where phone = ?
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);
//因为我们采用Hash的数据结构保存用户信息到redis,所以这里要将UserDto对象转成map集合,最后将map集合对象放到对应的方法即可
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);
//8.返回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;
}
2.3拦截器
为什么要定义拦截器呢?因为我们需要验证用户是否登录。如果我们不设置拦截器的话,我们需要在很多接口里面写通过token获取用户的代码,通过判断是否获取到用户信息来判断用户是否已经登录。将这部分代码写在拦截器里面可以减少代码冗余。
在这里可能有同学会有疑问,为什么需要两个拦截器?一个拦截器不就行了吗?只要将请求拦截下来,看看请求携带的token是否在redis中有用户存在即可判断用户是否已经登录。大家回忆下,我们在登录的时候设置了token在redis的有效期,如果只有一个拦截器,那么便意味着登录验证是在这一个拦截器里面完成的,而这个拦截器是不能拦截所有请求路径的,如登录请求的路径你就不能拦截,不然怎么登录?既然不是所有请求路径都需要拦截,比如首页的请求,而用户登录后一直在访问首页,时间一长,token过期,那么会导致用户需要重新登录才能进行其他需要登录的操作。为此,我们创建两个拦截器,第一个拦截器拦截所有请求,在第一个拦截器里完成用户登录认证,刷新token的有效时间,并且在这个拦截器里从redis获取用户信息保存到ThreadLocal里面;第二个拦截器因为设置了哪些操作需要拦截的规则,那么在第二个拦截器里面便可以验证ThreadLocal里面是否有用户信息,如果有则证明用户已登录,否则用户未登录。
代码
这里有个小细节需要说明一下。
- 我们有两个拦截器,确定哪一个拦截器先执行的方式是在拦截器的配置类里调用order方法,order方法的参数越小,优先级越高。
- 大家可以发现第一个拦截器需要拦截所有请求获取redis里保存的用户信息,并刷新里面token的有效期,所以需要获取StringRedisTemplate对象。但是我们并不是在拦截器里面用主动注入的方式,而是采用了创建构造方法来为StringRedisTemplate赋值,具体的StringRedisTemplate对象在拦截器的配置类里面自动注入后再赋值给第一个拦截器。原因是:拦截器是我们自己new出来的,自己new出来的是没法使用spring的自动注入的。为什么说是我们自己new出来的拦截器呢?大家可以看看拦截器的配置类。
- UserHolder封装了ThreadLocal类。
@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)).addPathPatterns("/**").order(0);
}
}
//刷新token有效期拦截器
@Slf4j
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(key);
//3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// TODO 5.将查询到的Hash数据转为UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//TODO 7.刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
//验证登录拦截器
@Slf4j
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;
}
}