我们可以对比之间学过的Session实现短信登录学习
目录
至于为什么在这里注入 StringRedisTemplate ,在2.2.5我们会进行讲解
2.2.5自定义拦截器更新token有效期(注入对象有点巧妙)
一、 基于Redis实现用户短信登录
那么接下来我们就有这么一个疑问:
以后我们的每个请求都要携带Token,它是怎么做到这一点的呢?
我们可以看一下下面这段代码,其中{data}就是后端传输过来的token信息,并将其存储到浏览器中的本地存储当中(关闭再打开就没有了)
那我们接下来就看看到底在请求的时候怎么携带Token: 给axios设置了一个拦截器,将Token作为请求头放进去了,名字叫“authorization”,这样就能确保每次axios发起请求都会携带“authorization”这个头(也就是token),这样一来在服务端我们就能获取到这个请求到拿到token从而实现登录的严重
这也是为什么我们token的值不选择手机号当做key,如果是选择手机号当做key很可能会发生信息泄露(因为如果token是手机号,最终token是要保存在前端浏览器的本地存储当中的)
如果有学过Vue并且感兴趣的小伙伴可以看一下下面这个登录表单验证,这里面从标题六及其以后都会有涉及到Token的应用,可以直接看
二、代码编写
我们之前是将短信验证码保存到了session,现在做出改变,放入到Redis之中
2.1 发送短信验证码
2.1.1 业务流程图:
2.1.2 Controller层代码
相比于session方式,这个地方并没有修改
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone,HttpSession session) {
// TODO 发送短信验证码并保存验证码、
// return Result.fail("功能未完成");
return userService.sendCode(phone,session);
}
2.1.3 Service层代码
相对于session方式,在存储code的时候有所不同,以前是存储到session,现在是存储到Redis
首先我们注入StringRedisTemplate对象,至于为什么用@Resource注解的,可以看上面这个博主的文章,在此提出感谢
@Resource
private StringRedisTemplate stringRedisTemplate;
如果有朋友对StringRedisTemplate对象不熟悉的话,可以看下面这个博客
Redis的Java客户端——SpringDataRedis、RedisTemplate、StringRedisTemplate_我爱布朗熊的博客-CSDN博客
/**
* 发送手机验证码
* @param phone 电话号码
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
// 说明:RegexUtils使我们封装的一个类 isCodeInvalid是里面的静态方法,在这个静态方法里面又调用了另外一个静态方法得以实现
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
// 3.符合,生成验证码 6代表生成的验证码的长度 RandomUtil使用这个工具类生成
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到Redis 在采用Redis中采用String的数据结构 "login:code:"+phone这样更加有层次感
// 2, TimeUnit.MINUTES有效期是两分钟 类似set key value ex(有效期,单位是秒)
stringRedisTemplate.opsForValue().set("login:code:"+phone,code,2, TimeUnit.MINUTES);
//5.发送验证码
// 实现起来比较麻烦 我们使用日志假装发送
log.debug("发送短信验证码成功,验证码:"+code);
return Result.ok();
}
如果大家有对Redis命令有所忘记的话,可以看下面这一篇文章
Redis命令——通用命令、String类型、Key层级结构、Hash类型、List类型、Set类型、SortedSet类型_我爱布朗熊的博客-CSDN博客
2.1.4 发送验证码演示
2.2 短信验证码登录、注册
2.2.1 业务流程图
2.2.2 Controller层
与session并没有区别
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return userService.login(loginForm,session);
}
2.2.3 Service层代码
这个地方就比较复杂,改动的地方也比较多
/**
* 实现用户登录
* @param loginForm 登录的参数
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
// 说明:RegexUtils使我们封装的一个类 isCodeInvalid是里面的静态方法,在这个静态方法里面又调用了另外一个静态方法得以实现
// 1.2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
// 2.校验验证码
// 2.1 从Redis中获取真正正确的验证码
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:"+loginForm.getPhone());
// 2.2 获取用户输入的code
String code = loginForm.getCode();
if(cacheCode ==null || !cacheCode.equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户 .one()代表查询一个 list()代表着查询多个
User user =query().eq("phone",loginForm.getPhone()).one();
//5.判断用户是否存在
if(user ==null){
//6.不存在,创建新用户并保存
user = createUserWithPhone(loginForm.getPhone());
}
//7.保存用户信息到Redis中
// 7.1 随机生成Token,作为登录令牌 我们使用import cn.hutool.core.lang.UUID;
// true代表着不加中划线
String token = UUID.randomUUID().toString(true);
// 7.2 将User对象转为HashMap存储
// BeanUtil.copyProperties(user, UserDTO.class)) 会自动的将user中的属性拷贝到UserDTO当中而且也创建出一个UserDTO对象
// 将User转化为UserDTO是为了提高数据的保密性,User中有各种信息都会返回到前端,这样很不友好
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 转化为Map集合的时候,确保每一个都是String,否则就会出现异常!!!!!!!
// new HashMap<>() 是一个空的map集合
// setIgnoreNullValue(true) 忽略空值 setFieldValueEditor这个就是我们所需要的设置字段值,把每一个字段值都设置成Value属性
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));
// 7.3 存储并设置有效期(session有效期是30分钟,超过30分钟不访问便被剔除,这里我们也可以设置30分钟)
// put是一个一个的加不是很适合,这里使用putAll,一次性存入
stringRedisTemplate.opsForHash().putAll("login:token:"+token,userMap);
// 这个地方存储的时候是不能设置有效期的,我们只能先存储再设置有效期
// 7.4 设置有效期(但是这个地方是到了30分钟就剔除,但是session不是这个样子的,剩下的操作我们需要在拦截器中设置)
stringRedisTemplate.expire("login:token:"+token,30,TimeUnit.MINUTES);
// 8. 返回token
return Result.ok(token);
}
2.2.4 拦截器的注册器
至于为什么在这里注入 StringRedisTemplate ,在2.2.5我们会进行讲解
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 拦截器的注册器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor( stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login",
"/shop/**",
"/blog/hot",
"/shop-type/**",
"upload/**",
"voucher/**"
);
}
}
2.2.5自定义拦截器更新token有效期(注入对象有点巧妙)
当我们将数据放入token的时候是30分钟的有效期,也就是说当我们把数据放入Redis就开始30分钟的倒计时,这样是非常不合理的。加入用户正在操作着,突然没有访问权限了
所有我们要设置一下拦截器,当用户还在操作的时候,就会更新token的有效期
当顾客三十分钟没有操作之后才将token剔除
// HandlerInterceptor 这是拦截器
public class LoginInterceptor implements HandlerInterceptor {
// 这个类LoginInterceptor 我们并没有给他添加注解,所以这个类并没有交给Spring管理,所以这个地方也没有自动装配的注解
// 这个类的对象使我们自己new出来的(不是Spring帮我们创建的,所以我们不能使用那些注解了)
private StringRedisTemplate stringRedisTemplate;
// 所以我们使用构造函数注入 很重要哦
// 那我们利用构造函数注入,那谁帮我们注入呢?谁用了它,谁就帮我们注入
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 前置拦截 在进入controller之前我们进行登录校验
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token authorization使我们前端定义的一个请求头
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
// 运行到这里说明token是空
//不存在,拦截
response.setStatus(401); //返回401状态码
return false;
}
// 2.基于Token获取Redis中的用户 这里不用get,get只能获取一个字段的值
// entries返回值是一个map
Map<Object,Object> userMap= stringRedisTemplate.opsForHash()
.entries("login:token:"+token);
// 3.判断用户是否存在
// 这个地方不用担心空指针异常,因为如果是空的话entries返回的就是一个什么也没有的map集合而已
if(userMap.isEmpty()){
// 4.不存在,拦截
response.setStatus(401); //返回401状态码
return false;
}
// 5.将查询到的hash数据转化为UserDTO对象
// false 表示不忽略转换过程中的错误
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
// 6.存在,保存用户信息到ThreadLocal 保存在当前线程里面的
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire("login:token:"+token,30, TimeUnit.MINUTES);
// 8.放行
return true;
}
// 在controller执行之后拦截 这个我们在这里不需要
// @Override
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
// }
// 渲染之后,返回给用户之前 用户业务执行完毕我们要销毁维护信息,避免泄露
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
2.2.6 最终结果演示
验证码:
token令牌:存储的是User对象的部分内容(存储部分主要内容省内存空间)
我们也可以看一下前端的请求Header
携带了authorization,这样后台才能验证token,很完美!
三、解决状态登录刷新的问题
这是我们之前所用到的登陆拦截器的样子:
但是上面那个不是很友好,加入我们的用户在半个小时内在操作,但是并没有访问需要登录的路径,那这样一来我们半个小时后还是token过期,这样显然是不符合要求的
接下来我们就进行优化,我们在原先拦截器的基础上再增加一个拦截器,目的是拦截一切请求,如下图所示:
3.1 新增RefreshTokenInterceptor拦截器实现大部分功能
// HandlerInterceptor 这是拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {
// 这个类LoginInterceptor 我们并没有给他添加注解,所以这个类并没有交给Spring管理,所以这个地方也没有自动装配的注解
// 这个类的对象使我们自己new出来的(不是Spring帮我们创建的,所以我们不能使用那些注解了)
private StringRedisTemplate stringRedisTemplate;
// 所以我们使用构造函数注入 很重要哦
// 那我们利用构造函数注入,那谁帮我们注入呢?谁用了它,谁就帮我们注入
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 前置拦截 在进入controller之前我们进行登录校验
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token authorization使我们前端定义的一个请求头
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
// 不拦截,直接放行
return true;
}
// 2.基于Token获取Redis中的用户 这里不用get,get只能获取一个字段的值
// entries返回值是一个map
Map<Object,Object> userMap= stringRedisTemplate.opsForHash()
.entries("login:token:"+token);
// 3.判断用户是否存在
// 这个地方不用担心空指针异常,因为如果是空的话entries返回的就是一个什么也没有的map集合而已
if(userMap.isEmpty()){
// 不拦截,直接放行
return true;
}
// 5.将查询到的hash数据转化为UserDTO对象
// false 表示不忽略转换过程中的错误
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
// 6.存在,保存用户信息到ThreadLocal 保存在当前线程里面的
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire("login:token:"+token,30, TimeUnit.MINUTES);
// 8.放行
return true;
}
// 在controller执行之后拦截 这个我们在这里不需要
// @Override
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
// }
// 渲染之后,返回给用户之前 用户业务执行完毕我们要销毁维护信息,避免泄露
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
3.2 修改LoginInterceptor拦截器
此时这个类只用来判断是否有用户
// HandlerInterceptor 这是拦截器
public class LoginInterceptor implements HandlerInterceptor {
// 前置拦截 在进入controller之前我们进行登录校验
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断是否需要拦截(ThreadLocal中是否有用户)
if(UserHolder.getUser() ==null){
// 没有的话进行拦截
response.setStatus(401);
return false;
}
// 有用户则放行
return true;
}
// 在controller执行之后拦截 这个我们在这里不需要
// @Override
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
// }
// 渲染之后,返回给用户之前 用户业务执行完毕我们要销毁维护信息,避免泄露
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
3.3 配置两个拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 拦截器的注册器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor( ))
.excludePathPatterns(
"/user/code",
"/user/login",
"/shop/**",
"/blog/hot",
"/shop-type/**",
"upload/**",
"voucher/**"
).order(1);
// 刷新token拦截器
// registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)); 这两种方式都是可以的
// 这个拦截器需要先执行 .order(0) 就说明是先执行,从0开始,默认就是0 如果都没有写order的话,就按照添加拦截器的顺序执行拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}