文章目录
- 前言
- 第4章 Redis实战
- 4.1 短信登录
- 4.1.1 基于session实现短信登录
- 4.1.1.1 短信登录逻辑梳理
- 4.1.1.2 创建测试项目
- 4.1.1.3 实现发送短信验证码功能
- 4.1.1.4 实现用户登录功能
- 4.1.1.5 实现登录拦截功能
- 4.1.1.6 session共享问题
- 4.1.2 基于Redis实现短信登录
- 4.1.2.1 Key-Value的结构设计
- 4.1.2.2 发送短信验证码功能改造
- 4.1.2.3 用户登录功能改造
- 4.1.2.4 登录拦截功能改造
前言
前面三章我们对Redis的基础知识进行了深入的学习,已经掌握了Redis的基本使用方法。
Redis从入门到精通(一)Redis安装与启动、Redis客户端的使用
Redis从入门到精通(二)Redis的数据类型和常见命令介绍
Redis从入门到精通(三)Jedis客户端、SpringDataRedis客户端
接下来的第4章开始进入实战环节,来学习一个Redis实战项目:短信登录。
第4章 Redis实战
4.1 短信登录
短信登录功能可以基于session实现,也可以基于Redis实现,下面分别介绍这两种方式。
4.1.1 基于session实现短信登录
4.1.1.1 短信登录逻辑梳理
-
1)发送验证码
用户在登录页面输入手机号,点击“发送验证码”按钮。后台收到请求后,校验手机号是否符合格式,如果不符合,则要求用户重新输入手机号。
如果符合,后台随机生成6位数字的验证码,并将验证码保存到session,然后再通过短信的方式将验证码发送给用户(由于没有短信网关,可以使用打印日志的方式模拟)。
-
2)登录与注册
用户获取到验证码后,输入手机号和验证码,并点击“登录”按钮。后台收到请求后,从session中获取之前保存好的验证码,并与用户提交的验证码进行比对,如果不一致,则登录失败。
如果一致,则根据手机号查询数据库的用户信息,如果用户不存在,则创建一个新的用户保存到数据库,如果存在,则直接获取;然后将用户信息保存到session中,方便后续获取当前登录用户信息。
-
3)校验登录状态
用户发起的请求,除了获取验证码、用户登录等少数指定的请求外,一般都要求用户必须处于登录状态。如果用户并非处于登录状态,说明这是一个非法请求,必须进行拦截。
用户发起这些请求时,后台进行拦截,然后从session中拿到用户信息。如果没有获取到用户信息,则表示没有用户没有登录,要进行拦截。如果获取到了用户信息,则说明用户已经登录了,则放行。
4.1.1.2 创建测试项目
下面以一个SpringBoot项目来进行测试。
由于项目的创建不是学习的重点,这里不进行详述。该测试项目的代码已打包上传,有需要请到本文顶部下载绑定的代码资源。
4.1.1.3 实现发送短信验证码功能
-
接口文档
项目 说明 请求方式 GET 请求路径 /user/code 请求参数 phone 返回值 无 -
代码实现
在controller
目录下的UserController类中实现该接口:
@GetMapping("/code")
public BaseResult<String> sendCode(String phone, HttpSession httpSession) {
// 1.校验手机号格式
if(RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return BaseResult.setFail("手机号输入有误!");
}
// 3.符合,随机生成6位数验证码
String code = String.valueOf((int)(Math.random() * 900000 + 100000));
// 4.将验证码保存到session
httpSession.setAttribute("code", code);
// 5.短信方式发送验证码
log.info("发送短信验证码成功,验证码:{}", code);
// 6.返回成功
return BaseResult.setOk("短信验证码已成功发送至手机号" + phone + ",请注意查收!");
}
- 功能测试
特别要注意的是,由于我们是使用HTTP工具进行发包测试的,所以需要设置一下Cookies,因为后端是利用Cookies中的JSESSIONID参数来创建session的。为了确保多次请求拿到的session是同一个,Cookies也必须要一致。
4.1.1.4 实现用户登录功能
-
接口文档
项目 说明 请求方式 POST 请求路径 /user/login 请求参数 phone,code 返回值 无 -
代码实现
在UserController类中编写一个用户登录方法:
@Resource
private IUserService userService;
@PostMapping("/login")
public BaseResult login(@RequestBody LoginForm loginForm, HttpSession httpSession) {
log.info("用户开始登录...{}", loginForm.toString());
// 1.校验手机号
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
// 2.如果不符合,返回错误信息
return BaseResult.setFail("手机号输入有误!");
}
// 3.从session中获取验证码并校验
Object cacheCode = httpSession.getAttribute("code");
if(cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())) {
// 4.验证码不一致,返回错误信息
return BaseResult.setFail("验证码错误!");
}
// 5.一致,根据手机号查询用户
User user = userService.query().eq("phone", loginForm.getPhone()).one();
if(user == null) {
// 6.用户不存在,则创建一个用户
user = new User();
user.setPhone(loginForm.getPhone());
user.setNickName(loginForm.getPhone());
userService.save(user);
}
// 7.将用户信息保存到session中
httpSession.setAttribute("user", user);
log.info("{} 登录成功...", loginForm.getPhone());
return BaseResult.setOk("登录成功");
}
- 功能测试
4.1.1.5 实现登录拦截功能
-
接口文档
用户发起的请求,除了获取验证码、登录等少数指定的请求外,一般都要求用户必须处于登录状态。如果用户并非处于登录状态,说明这是一个非法请求,必须进行拦截。
可以通过拦截器来实现这个功能。
-
代码实现
要创建一个拦截器,只需要创建一个类LoginInterceptor,实现
org.springframework.web.servlet.HandlerInterceptor
接口,并重写其preHandle()
方法。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中的用户 Object user = session.getAttribute("user"); // 3.判断用户是否存在 if(user == null){ // 4.不存在,拦截,返回401状态码 response.setStatus(401); return false; } // 5.存在,放行 return true; } }
其次,要对自定义的拦截器进行注册,让其生效:
@Configuration public class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { // 登录拦截器,排除获取验证码和登录请求 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/user/code", "/user/login" ).order(1); } }
-
功能测试
在未登录的情况下发送请求/user/info
,报401,说明没有通过拦截器的校验:
4.1.1.6 session共享问题
基于session实现短信登录,在服务端单机的情况下是没问题的,但如果服务端采用集群方式,则会出现session共享问题。
每个tomcat中都有一份属于自己的session。假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题。
早期的解决方案是session拷贝,即当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样就可以实现session的共享。但这种方法也有弊端:第一,每台服务器中都有完整的一份session数据,服务器压力过大;第二,session拷贝数据时,可能会出现延迟。
基于此,更好的解决方案是基于Redis来完成,而且Redis数据本身就是共享的。
4.1.2 基于Redis实现短信登录
4.1.2.1 Key-Value的结构设计
由于本案例中要存入Redis的数据比较简单,因此可以考虑使用String类型或Hash类型来存储数据。
这两种方式各有优点,String类型以JSON字符串保存数据,比较直观;而Hash类型可以将对象的每个字段独立存储,可以针对单个字段做CRUD,比较方便。最终根据实际需要选择即可,本案例选择使用String类型。
在基于session实现时,每个用户都有一个独享的session。但Redis的Key是共享的,因此不能再使用基于session方式中的"code"
、"user"
作为Key值。
在设计Key时,需要满足两点要求:第一,Key要有唯一性;第二,Key要方便携带。
在本案例中,如果采用手机号作为Key当然可以,它具备唯一性且方便携带,并且和验证码息息相关。但从安全角度看,手机号毕竟属于敏感数据,每次请求都携带手机号是不合适的。
综合考虑,本案例将采用login:code:{phone}
作为保存验证码的Key;而保存用户信息的Key,会在后台生成一个随机串token,采用login:user:{token}
作为Key,让用户每次请求都携带这个token。
4.1.2.2 发送短信验证码功能改造
- 代码实现(关注修改部分)
@Resource
private StringRedisTemplate stringRedisTemplate;
@GetMapping("/code")
public BaseResult<String> sendCode(String phone, HttpSession httpSession) {
// 1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return BaseResult.setFail("手机号输入有误!");
}
// 3.符合,随机生成验证码
String code = String.valueOf((int)(Math.random() * 900000 + 100000));
// 4.将验证码保存到session
// httpSession.setAttribute("code", code);
// 修改:将验证码保存到Redis
// 采用 login:code:{phone} 作为保存验证码的Key
stringRedisTemplate.opsForValue().set("login:code:" + phone, code);
// 5.短信方式发送验证码
log.info("发送短信验证码成功,验证码:{}", code);
// 6.返回成功
return BaseResult.setOk("短信验证码已成功发送至手机号" + phone + ",请注意查收!");
}
- 功能测试
调用获取验证码接口/user/code?phone=18922102123
后,查看Redis中的数据:
4.1.2.3 用户登录功能改造
- 代码实现(关注修改部分)
@PostMapping("/login")
public BaseResult<String> login(@RequestBody LoginForm loginForm, HttpSession httpSession) throws JsonProcessingException {
log.info("用户开始登录...{}", loginForm.toString());
// 1.校验手机号
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
// 2.如果不符合,返回错误信息
return BaseResult.setFail("手机号输入有误!");
}
// 3.从session中获取验证码并校验
// Object cacheCode = httpSession.getAttribute("code");
// 修改:从Redis中获取验证码
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + loginForm.getPhone());
if(cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())) {
// 4.验证码不一致,返回错误信息
return BaseResult.setFail("验证码错误!");
}
// 5.一致,根据手机号查询用户
User user = userService.query().eq("phone", loginForm.getPhone()).one();
if(user == null) {
// 6.用户不存在,则创建一个用户
user = new User();
user.setPhone(loginForm.getPhone());
user.setNickName(loginForm.getPhone());
userService.save(user);
}
// 7.将用户信息保存到session中
// httpSession.setAttribute("user", user);
// 修改:将用户信息保存到Redis中
// 随机生成token
String token = UUID.randomUUID().toString();
log.info("token = {}", token);
// 保存到Redis
stringRedisTemplate.opsForValue().set("login:user:" + token, new ObjectMapper().writeValueAsString(user));
// 设置token有效期:2小时
stringRedisTemplate.expire("login:user:" + token, 2, TimeUnit.HOURS);
log.info("{} 登录成功...", loginForm.getPhone());
// 将token返回给前端
return BaseResult.setOkWithData(token);
}
- 功能测试
调用用户登录接口/user/login
后,查看Redis中的数据:
4.1.2.4 登录拦截功能改造
- 代码实现(关注修改部分)
@Slf4j
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.获取session
// HttpSession session = request.getSession();
// 2.获取session中的用户
// Object user = session.getAttribute("user");
// 修改:基于用户token获取Redis中的用户信息
// 获取用户携带的token
String token = request.getHeader("authorization");
log.info("token from client => {}", token);
// 基于token获取Redis中的用户信息
String userJosn = stringRedisTemplate.opsForValue().get("login:user:" + token);
log.info("user from redis => {}", userJosn);
// 转为Java对象
User user = null;
if(StrUtil.isNotBlank(userJosn)) {
user = new ObjectMapper().readValue(userJosn, User.class);
}
// 3.判断用户是否存在
if(user == null){
// 4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 5.存在,放行
return true;
}
}
注册LoginInterceptor时传入StringRedisTemplate实例:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器,排除获取验证码和登录请求
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login"
).order(1);
}
}
- 功能测试
调用查询用户详情接口/user/info
(项目中暂未编写该Controller方法)。当携带一个错误token时,报401,说明未通过拦截器校验:
携带一个正确token,报404,说明已经通过了拦截器校验:
…
本节完,更多内容请查阅分类专栏:Redis从入门到精通
感兴趣的读者还可以查阅我的另外几个专栏:
- SpringBoot源码解读与原理分析(已完结)
- MyBatis3源码深度解析(已完结)
- 再探Java为面试赋能(持续更新中…)