目录
一、实战内容概述
1.导入 SQL
2.有关当前模型
3.导入后端项目
4.导入前端项目
二、短信登录
1.基于 Session 实现登录流程
2.实现发送短信验证码
3.实现短信验证码登录和注册
4.实现登录校验功能
5.隐藏用户敏感信息
6.集群的 session 共享问题
7.Redis 替代 session 的业务流程
8.基于 Redis 实现短信登录
(1)发送验证码
(2) 短信验证码登录、注册
(3)登录状态校验
(4)解决状态登录刷新问题
一、实战内容概述
导入项目:
有关资料,可以进入百度网盘下载(提取码:eh11):百度网盘 请输入提取码百度网盘为您提供文件的网络备份、同步和分享服务。空间大、速度快、安全稳固,支持教育网加速,支持手机端。注册使用百度网盘即可享受免费存储空间https://pan.baidu.com/s/1189u6u4icQYHg_9_7ovWmA&pwd=eh11
1.导入 SQL
创建一个 database 为 hmdp,然后将资料中的 hmdp.sql 中的 sql 语句执行一遍即可。
表 | 说明 |
---|---|
tb_user | 用户表 |
tb_user_info | 用户详情表 |
tb_shop | 商户信息表 |
tb_shop_type | 商户类型表 |
tb_blog | 用户日记表(达人探店日记) |
tb_follow | 用户关注表 |
tb_voucher | 优惠券表 |
tb_voucher_order | 优惠券的订单表 |
2.有关当前模型
该项目采用的是前后端分离开发模式
后端部署在 tomcat 上,前端会部署在 nginx 服务器上。
移动端 / PC 端发起请求时,也就是向 nginx 发起请求,nginx 再向服务端发起请求去查询数据,数据可能来自 Redis 集群,也可能来自 Mysql 集群。再把查询到的数据返回给前端,前端完成渲染即可。
虽然本项目是单体项目,但是将来也会考虑到项目的一个并发能力,所以该项目必须具备水平扩展的能力。
所以我们需要再多台 tomcat 上都来部署我们的代码,形成一个集群。如果单台 tomcat 扛不住压力,nginx 就可以通过负载均衡访问其他 tomcat。
但一旦形成集群,将来就会存在集群间数据共享的一些问题,所以我们后续会进行分析和解决。
3.导入后端项目
将资料中的 hm-dianping.zip 解压之后,放在我们自己的 workspace 里即可。
注意:需要修改 application.yml 中的 MySQL 和 Reids 的连接要素为自己的
启动项目,访问地址:http://localhost:8081/shop-type/list,如果可以看到JSON数据,则说明导入成功。
4.导入前端项目
将资料中的 nginx-1.18.0.zip 解压之后,放在任意目录下即可(不含中文、特殊字符和空格)。
然后在 nginx 所在目录打开一个 cmd 窗口,输入命令:start nginx.exe,即可启动项目
浏览器中 F12 打开开发者模式,然后打开手机模式:
访问地址:http://localhost:8080/,能够看到页面,说明项目部署成功
二、短信登录
1.基于 Session 实现登录流程
① 发送验证码:
- 用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
- 如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户。
② 短信验证码登录、注册:
- 用户将验证码和手机号进行输入,后台从 session 中拿到当前验证码,然后和用户输入的验证码进行校验。如果不一致,则无法通过校验;如果一致,则后台根据手机号查询用户。
- 如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到 session 中,方便后续获得当前登录信息。
③ 校验登录状态:
- 用户在登录成功后,在访问某些关键业务的时候,我们都需要进行登录状态的校验
- 用户在请求的时候,会从 cookie 中携带 JsessionId 到后台,后台通过 JsessionId 从 session中拿到用户信息,如果没有 session 信息,则进行拦截,如果有 session 信息,则将用户信息保存到 threadLocal 中,并放行。
2.实现发送短信验证码
在请求头中,我们可以看到:
- 请求网址: http://localhost:8080/api/user/code?phone=13566775566
- 请求方法: POST
很显然,是调用 UserController 中的 code 方法,携带参数是 phone。
接下来,我们修改 UserController
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
我们将发送验证码的业务放在 service 层,所以要在 IUserSevice 中创建该方法:
public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession session);
}
同时,实现类中也需要重写该方法:
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号(利用util下RegexUtils进行正则验证)
if(RegexUtils.isPhoneInvalid(phone)){
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式不正确!");
}
// 3.符合,生成验证码(hutool工具包中的RandomUtil)
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到session
session.setAttribute("code",code);
// 5.发送验证码(暂时不接入第三方短信 API 接口)
log.debug("发送短信验证码成功,验证码为:{}",code);
// 结束,返回ok
return Result.ok();
}
}
重启项目,刷新浏览器,再次发送验证码:
控制台中打印出了生成的验证码,发送验证码功能完成!
3.实现短信验证码登录和注册
UserController:
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// 实现登录功能
return userService.login(loginForm, session);
}
IUserService:
public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession session);
Result login(LoginFormDTO loginForm, HttpSession session);
}
UserServiceImpl:
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
...
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号(有可能短信获取验证码时手机号是对的 登录时填个错的)
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合 返回错误信息
return Result.fail("手机号格式错误");
}
// 2.校验验证码
String cacheCode = (String) session.getAttribute("code");// 获取保存在session中的code
String code = loginForm.getCode();// 获取用户输入的code
if (cacheCode == null || !cacheCode.equals(code)) {
// 3.验证码不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (null == user) {
// 6.不存在,创建用户并且保存
user = createUserWithPhone(phone);
}
// 7.保存用户到session
session.setAttribute("user", user);
return Result.ok();
}
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;
}
}
启动项目,刷新网址,验证登录功能。
我们发现,登录成功后,页面一闪而过,又回到了未登录的页面。
打开控制台,发现已经插入成功,证明登录没有任何问题。
这是因为,登录状态校验还没有实现,所以登录成功后,校验异常,就自动退出了登录。
4.实现登录校验功能
用户的登录凭证就是 JSessionId,而这个 JSessionId 就存在 cookie 中。
当请求携带 cookie 到服务端,服务端需要通过 cookie 中的 JSessionId 找到 session,再从 session 中找到存入的 user 信息,就可以判断用户是否存在。
为了校验登录状态是否正常,我们需要在 UserController 中定义一个方法,处理该业务。
但是后续随着业务开发,对于其他模块也有可能需要校验登录状态,我们不可能在每一个 controller 中实现相同的业务逻辑,这样会造成代码冗余。
此时,就会联想到拦截器,我们可以将登录校验的操作放在拦截器中进行, 它会在所有 controller 执行之前触发!
但同时又产生了其他问题:
拦截器确实可以完成校验,但在后续的 controller 中,我们是需要用户信息的,拦截器该如何将这个用户信息传递给后续的 controller 呢?传递过程中的线程安全问题又该如何保障?
我们之前提到了 ThreadLocal:
- 每个用户其实对应都是去找 tomcat 线程池中的一个线程来完成工作的, 使用完成后再进行回收
- 每个请求都是独立的,在每个用户去访问工程时,可以使用 threadlocal 来做到线程隔离,每个线程操作自己的一份数据
- 在 threadLocal 中,无论是 put 方法和 get 方法, 都是获得当前用户的线程,然后从线程中取出线程的成员变量 map
- 只要线程不一样,map 就不一样,所以可以通过这种方式来做到线程隔离
LoginInterceptor:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取session中的用户信息
User user = (User) session.getAttribute("user");
// 3.判断用户是否存在
if (user == null) {
// 4.不存在,则拦截,返回401状态码(未授权)
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(user);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
UserHolder.removeUser();
}
}
注意:提供的资料中 UserHolder 中定义的 user 是 UserDTO 类型,因为后续会改。这里暂时全部将其改为 User 类型,相关调用处也改成 User 类型,就不会报错了。
MvcConfig:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
UserController:
@GetMapping("/me")
public Result me() {
// 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}
重启项目,运行结果:
可以看到,登录校验功能已经成功实现,并且返回了用户信息。
5.隐藏用户敏感信息
从上面的结果中可以看到,虽然用户信息返回了,但是返回了用户全部的信息,甚至连密码都有!
这些敏感信息对外暴露是极其不安全的,我们需要隐藏这些敏感信息。
归根究底,这是因为我们在向 Session 中保存用户信息时,保存的就是一个完整的 User 对象。
所以,我们需要保存的时候,隐藏某些信息。
我们有一个 UserDTO 类,使用 hutool 工具包中的 BeanUtil 类的 copyProperties 方法,可以将User 对象按属性自动拷贝到 UserDTO 对象当中。
同时,因为此时存的是 UserDTO 对象,所以在拦截器中取出的 user 也应该是 UserDTO 类型,存进 ThreadLocal 的对象类型也应该是 UserDTO 类型。
此时,UserHolder 中定义的 user 也要全部改回 UserDTO 类型,相关调用处也改回 UserDTO 类型。
重启项目,运行结果:
可以看出,获取到的用户信息中已经隐藏了敏感信息, 同时这样也可以减小内存占用。
6.集群的 session 共享问题
session 共享问题:多台 Tomcat 并不共享 session 存储空间,当请求切换到不同的 tomcat 服务器时,导致数据丢失的问题。
每个 tomcat 中都有一份属于自己的 session,假设用户第一次访问第一台 tomcat,并且把自己的信息存放到第一台服务器的 session 中。
但是第二次这个用户访问到了第二台 tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的 session,所以此时整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?
早期的解决方案: session 拷贝,就是说虽然每个 tomcat 上都有不同的 session,但是每当任意一台服务器的 session 修改时,都会同步给其他的 Tomcat 服务器的 session,这样的话,就可以实现 session 的共享了。
缺点:
- 每台服务器中都有完整的一份 session 数据,造成资源浪费,同时服务器压力过大。
- session 拷贝数据时,可能会出现延迟,可能会出现数据不一致。
所以,我们不得不换一种 session 的替代方案,它应该满足:
- 数据共享
- 内存存储(session就是基于内存存储的,登录校验这种操作的访问频率非常高,如果读写性能较差,很难满足高并发的需求)
- key、value 结构
综上,基于以上三点,我们不难想到,这个替代方案就是 Redis!!!
7.Redis 替代 session 的业务流程
因为 Redis 是 key,value 结构的,所以每次在存入数据时,我们都需要考虑:
- key 的命名:
- 唯一性
- 方便携带(因为要根据 key 取 value)
- value 的数据结构
- 数据的有效期
① 对于验证码:
value 的数据结构:验证码是一个 6 位数随机的字符串,所以我们用 string 存储即可、
key 的命名:不能像 session一样用 code 来当做 key,因为 redis 是数据共享的,多个用户验证码的 key 都是 code 就乱套了。
我们需要确保每一个用户使用手机号发送验证码时,存储验证码的 key 都是不一样的。所以,我们干脆使用手机号 phone 来作为验证码的 key。
这样的话,不仅可以保证 key 的唯一性,还可以有助于将来获取验证码进行验证。
过去我们使用 session,可以通过 cookie 找到 sessionId。但现在是 redis,我们需要手动用 key 获取 value。
而我们在登录时,刚好使用的就是手机号和验证码,所以正好可以用手机号作为 key 来取出验证码!
② 对于用户信息:
value 的数据结构:用户信息是一个对象,为了方便后续进行修改,推荐使用 Hash 结构
key 的命名:这里用手机号 phone 当然也可以,但是一般不推荐。我们在设计 key 的命名时,不光要考虑唯一性,还要考虑方不方便携带。
一般情况下,建议生成一个随机的 token 作为 key 存储用户数据,这样唯一性的要求就满足了。
此外,我们还要考虑到,后续进行登录校验时,我们需要根据 key 来获取用户数据进行校验。
在过去登录校验时,我们的请求会携带 cookie,从 cookie 中获取 sessionId,找到 session 进而找到用户信息。因为现在已经没有 session 了,所以我们登录校验时携带的登录凭证就不再是 sessionId,而是这个 token了。
但登录校验时如何取到这个 token 进行携带呢?它并不像 sessionId 一样,浏览器会帮我们自动维护,所以我们只能手动保存,然后手动获取。为此,登录完成后,我们还需要将 token 返还给客户端进行保存。
前端是如何做到每次请求都会携带 token 的呢?
同时,这又解释了为什么不能用手机号作为 key,如果我们将手机号存在浏览器中,就会存在一定的安全性问题,可能会有泄露的风险。
8.基于 Redis 实现短信登录
(1)发送验证码
UserServiceImpl 中修改 sendCode 方法
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号(利用util下RegexUtils进行正则验证)
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式不正确!");
}
// 3.符合,生成验证码(hutool工具包中的RandomUtil)
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到redis当中
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码(暂时不接入第三方短信 API 接口)
log.debug("发送短信验证码成功,验证码为:{}", code);
// 结束,返回ok
return Result.ok();
}
这里的 key 使用 "login:code" 的前缀形式,并设置有效期 2 分钟,我们可以定义一个常量类来替换这里的 "login:code" 和 2 ,让代码显得更专业一点。
(2) 短信验证码登录、注册
UserServiceImpl 中修改 login 方法
@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();// 获取用户输入的code
if (cacheCode == null || !cacheCode.equals(code)) {
// 3.验证码不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (null == user) {
// 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);
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token给客户端
return Result.ok(token);
}
我们这里参考 session,也给 token 设置了一个有效期,时间为 30 分钟。
但 session 失效的前提是 30 分钟不访问,才会失效,一旦访问,时间就会重新计算。而我们这里的时间就是彻彻底底的 30 分钟后失效,不合理,我们也希望做到只要有请求访问,就会重置时间。
我们不难想到,可以通过拦截器,只要用户经过登录校验的拦截器,就证明他处于登录状态,我们就可以重置有效期。
这个重置有效期的操作,我们就放在下方和登录拦截校验一起实现。
(3)登录状态校验
LoginInterceptor:
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(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)) {
// 不存在,则拦截,返回401状态码(未授权)
response.setStatus(401);
return false;
}
// 2.基于token获取redis中的用户信息
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3.判断用户是否存在(不能直接判断等于null,如果没数据entries方法会返回一个空map)
if (userMap.isEmpty()) {
// 4.不存在,则拦截,返回401状态码(未授权)
response.setStatus(401);
return false;
}
// 5.将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
// 6.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
UserHolder.removeUser();
}
}
注意:
在拦截器 LoginInterceptor 类中,不能使用 @Resource 注解直接注入 StringRedisTemplate 对象。
因为 LoginInterceptor 类的对象是我们在 MvcConfig 中手动 new 出来的,不是由 spring 创建的,所以拦截器对象不在 springIOC 容器中,自然也就不能使用 spring 的相关注解。
同时,由于 LoginInterceptor 的构造函数发生修改,MvcConfig 也需要修改:
@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",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
由于 MvcConfig 有 @Configuration 注解,已经加入了 ioc 容器,所以这里可以直接使用 @Resource 注解注入 StringRedisTemplate 对象。
运行结果:
我们发现运行出错,显示服务器异常,打开控制台:
控制台显示我们在存入数据的时候,类型转换异常,long 类型无法转换为 string 类型。
在我们的 userDTO 对象里,只有 id 是 long 类型的,说明是它出现了问题,为什么呢?
原因分析:
我们使用的是 StringRedisTemplate,它限定了 RedisTemplate 的 key 和 value 都只能是 String 类型,也就是只能操作 String 类型的数据,因为它使用 StringRedisSerializer 作为序列化器。
在之前基础篇的案例里,我们可以将 user 对象存进去,是因为在存之前我们使用 ObjectMapper 将 user 对象序列化成了 json 字符串,这个字符串是 String 类型,所以满足要求。
在这里,我们传入的是一个 userMap,它在底层是将 userMap 中的 key 和 value 作为Redis 中的 field 和 value 进行操作的。
- userMap 中的 key 也就是 user 的属性名("id","nickname","icon"),是 String 类型满足要求。
- userMap 中的 value 也就是 user 的属性值,所以这里的 id 值是一个 long 类型,显然不满足只能操作 String 类型的数据的要求。
所以,为了满足要求,我们必须要将 userMap 中的 value 全部转变成 String类型:
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
// 忽略空的值
.setIgnoreNullValue(true)
// 修改字段值 字段名 字段值 -> 修改后的字段名 修改后的字段值
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
此时,重启项目,就可以正常运行了
我们发现,每次校验登录状态的时候,请求头中就会携带一个名叫 authorization 的 token。
我们查看 redis 数据库,也能够清晰的看到存储了两条数据 code 和 token。
(4)解决状态登录刷新问题
上面我们已经完成了 token 有效期实时刷新的问题,也就是只要用户一直在访问页面,就可以一直刷新 token 的有效期。
但事实上,真的完成了吗?
我们的刷新逻辑写在了 LoginInterceptor 中,它只拦截需要登录校验的那些路径。
对于商品浏览这些不需要验证登录状态的路径,它并没有做出拦截,自然也就不会刷新 token 的有效期。
所以,这个功能还并没有完全实现,我们需要优化!
我们可以新增一个拦截器,在第一个拦截器里面拦截一切路径,负责刷新 token 有效期。第二个拦截器 LoginInterceptor 根据 ThreadLocal 是否存在用户信息决定放不放行。
RefreshTokenInterceptor(刷新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)) {
// 没有token,提前放行
return true;
}
// 2.基于token获取redis中的用户信息
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3.判断用户是否存在(不能直接判断等于null,如果没数据entries方法会返回一个空map)
if (userMap.isEmpty()) {
// 4.不存在,则拦截,返回401状态码(未授权)
response.setStatus(401);
return false;
}
// 5.将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
// 6.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
UserHolder.removeUser();
}
}
Question:这里有人会疑惑,为什么 token 为空,就直接放行了呢?放行不就没有刷新 token 了吗?
我一开始也很疑惑,有的人说放行意味着它不需要登录校验,所以就没有 token,那这样解释又回到前面的逻辑了,不需要登录校验就不刷新有效期。所以这个解释是错的!!!
其实原因很简单,因为第一个拦截器只负责刷新有效期,不用管其他的。所以遇到任何异常,我们都不管,直接放行就好,下面会有其他拦截器管这些问题的,这不是它该负责的事。
如果还不理解,看下方:
要弄清楚这个问题,我们先得明白,什么情况下 token 为空?查看前端代码:
这段代码是定义在 common.js 中,它规定了:所有用 ajax 发起的异步请求,都会携带一个 token,可以近似理解为所有请求都会携带 token,哪怕是商品浏览
在浏览器的结果中,也证实了这一点,确实会携带 token,这跟登录校不校验根本无关!
所以只要你发起任何请求,都会携带 token,也就是任何请求都会进入后续刷新 token 的逻辑。
那什么时候 token 不为空呢?
前端将 token 传给服务端,token 为空,说明 sessionStorage 中没有 token 这个东西,前端取不到。
我们分析一下 sessionStorage 中没有 token 的原因:
- 浏览器关闭,会话结束,token 失效
- 根本没有登录,没登录自然也就不会将 token 返回前端(客户端),让前端把 token 保存在 sessionStorage 中。
问题找到了,那开始分析后果:在这两种情况下,携带为空的 token 的请求直接放行会造成什么样的后果?
这两种情况其实可以归为一类,浏览器关了,整个会话结束。重新打开浏览器访问网址,需要重新登录,自然也是没有登录的状态。所以可以一并纳入情况 ② 分析。
要知道,第一个拦截器只负责刷新 token 有效期 和 保存 / 删除 ThreadLocal 中的用户信息。
没登陆应该拦截还是放行,这个逻辑是在登录校验拦截器里面实现的,第一个拦截器可以理解为他只是一个过滤器,只是对 token 做一些前置的操作罢了,不做任何拦截和放行的逻辑实现。
所以不管遇到什么样的情况,第一个拦截器都会放行。这里因为 token 为空放行,是因为如果 token 为空(没登陆),根本没有必要(或者说无法)执行下面的逻辑,我们提前放行了而已。
同时,由于前置处理都放进了 RefreshTokenInterceptor,我们的 LoginInterceptor 就可以进行简化了:
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;
}
}
修改拦截器的配置(WebMvcConfigurer):
@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",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
细节:
① 拦截器的执行顺序可以由 order 来指定,order的值越小,优先级越高。
② 如果未设置拦截路径,则默认是拦截所有路径
至此,短信登录业务全部完成。我们重启服务器,登录,然后去 Redis 的图形化界面查看 token 的ttl,如果每次切换任何界面之后,ttl 都会重置,那么说明我们的代码没有问题。