本博客为个人学习笔记,学习网站:黑马程序员Redis入门到实战 实战篇之验证码登录
目录
基于Session实现登录流程
发送短信验证码
短信验证码登录、注册
校验登录状态
session共享问题
Redis代替session的业务流程
设计Key的结构
设置Key的细节
整体访问流程
代码
解决状态登录刷新问题
基于Session实现登录流程
发送短信验证码
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号;如果手机号合法,后台此时生成对应的验证码,同时将验证码保存到session中,然后再通过短信的方式将验证码发送给用户。
短信验证码登录、注册
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息。
校验登录状态
用户在请求时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行 。
发送短信验证码
Controller层代码
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
Service层代码
//Service接口类代码
public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession session);
}
//Service接口实现类代码
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号码格式错误!");
}
// 3.如果符合,生成随机6位验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到session
session.setAttribute("code", code);
// 5.模拟发送验证码到手机(控制台)
log.debug("你收到的黑马点评短信验证码为:" + code);
return Result.ok();
}
}
短信验证码登录、注册
如果我们在信息传输时使用User类,通过浏览器可以观察到此时用户的全部信息都被暴露,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了。
UserDTO类代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
Controller层代码
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// 实现登录功能
return userService.login(loginForm, session);
}
@GetMapping("/me")
public Result me() {
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
Service层代码
//Service接口类方法代码
Result login(LoginFormDTO loginForm, HttpSession session);
//Service接口实现类代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号码格式错误!");
}
// 2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 3.验证码不一致则报错
return Result.fail("验证码错误");
}
// 4.一致 -> 根据手机号查询用户
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.若用户不存在 -> 创建新用户
user = createUserWithPhone(phone);
}
// 7.将user转换为UserDTO类,保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
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;
}
校验登录状态
通过拦截器实现登录校验功能
拦截器类代码
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.若用户存在则保存用户信息到 ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
拦截器配置类代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
//排除不需要拦截的路径
"/shop/**",
"/shop-type/**",
"/voucher/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
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的业务流程
设计Key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
设置Key的细节
我们可以使用String结构,就是一个简单的key,value键值对的方式。但是关于key的处理,session的特性是每个用户都拥有自己的session,各个用户之间的session相互独立,互不干扰。但redis的key是共享的,为避免干扰,我们不能使用code(验证码)来作为key了,我们需要找到一个能唯一标识各个用户的key。
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。
整体访问流程
当注册完成后,用户登录时会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
代码
接口参数修改,去掉session参数
public interface IUserService extends IService<User> {
Result sendCode(String phone);
Result login(LoginFormDTO loginForm);
}
修改Service接口实现类代码,将验证码保存到redis并设置有效时间为2min
@Override
public Result sendCode(String phone) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号码格式错误!");
}
// 3.如果符合,生成随机6位验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到redis并设置有效时间 // set key value ex 120
stringRedisTemplate.opsForValue().set("login:code" + phone, code, 2, TimeUnit.MINUTES);
// 5.模拟发送验证码到手机(控制台)
log.debug("你收到的黑马点评短信验证码为:" + code);
return Result.ok();
}
修改Service接口实现类登录代码,
@Override
public Result login(LoginFormDTO loginForm) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号码格式错误!");
}
// 2.从redis中获取验证码并进行校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 3.验证码不一致则报错
return Result.fail("验证码错误");
}
// 4.一致 -> 根据手机号查询用户
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);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
// 7.3 存储
stringRedisTemplate.opsForHash().putAll("login:token:" + token, userMap);
// 7.4 设置有效期
stringRedisTemplate.expire("login:token:" + token, 30, TimeUnit.MINUTES);
// 8 返回token
return Result.ok(token);
}
修改拦截器代码
注意
由于该LoginInterceptor的对象是我们手动创建,手动new出来的,并不是通过spring创建的,因此StringRedisTemplate对象的注入不能采用@Autowire,@Resource等方式,只有spring/springboot帮我们创建的对象才可以使用@Autowire,@resource
public class LoginInterceptor implements HandlerInterceptor {
/*
* 注意!!!
* 由于该LoginInterceptor的对象是我们手动创建,手动new出来的,并不是通过spring创建的
* 因此StringRedisTemplate对象的注入不能采用@Autowire,@Resource等方式
* 只有spring/springboot帮我们创建的对象才可以使用@Autowire,@resource
* */
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中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
// 3.判断用户是否存在(entries如果获取到一个为null的Map时,会返回一个空Map,因此下面的if判断只需要判断该Map是否为空就行)
if (userMap.isEmpty()) {
// 4.若用户不存在则拦截,返回401状态码
response.setStatus(401);
System.err.println("不放行!");
return false;
}
// 5.将查询到的Hash数据转换为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.若用户存在则保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8.放行
System.err.println("放行!");
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
拦截器配置类代码
注意
在LoginInterceptor类中的注释中,我们说到:由于LoginInterceptor类并不是通过spring创建的,因此不能通过注解的形式注入StringRedisTemplate类,只能通过构造函数添加。
而配置类MvcConfig是通过注解@Configuration由spring创建的,因此可以用注解的形式注入StringRedisTemplate类。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/*
* 在LoginInterceptor类中的注释中,我们说到
* 由于LoginInterceptor类并不是通过spring创建的,因此不能通过注解的形式注入StringRedisTemplate类,只能通过构造函数添加
* 而配置类MvcConfig是通过注解@Configuration由spring创建的,因此可以用注解的形式注入StringRedisTemplate类
* */
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
//排除不需要拦截的路径
"/shop/**",
"/shop-type/**",
"/voucher/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
再次运行,输入手机号获取验证码,填写验证码点击登录后出现报错,内容如下图
报错:显示Long类型无法转换为String类型
修改接口实现类中,login方法中的代码
解决状态登录刷新问题
由于我们设置的拦截器只能拦截有关登录请求,使得其他无关登录的请求无法被拦截,将导致部分请求无法及时更新token的有效期,因此我们再设置一个拦截器,用来专门拦截所有请求,更新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中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
// 3.判断用户是否存在(entries如果获取到一个为null的Map时,会返回一个空Map,因此下面的if判断只需要判断该Map是否为空就行)
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(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8.放行
System.err.println("放行!");
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
修改原来的拦截器代码
由于新增的拦截器在获取请求头中的token,并基于token获取到redis中的用户时,会将该用户转换为UserDTO对象,并存储到UserHolder中,因此原来的拦截器不必再做多余的解析token操作,只需要根据UserHolder中的User是否存在来判断用户的登录状态并决定是否放行即可。
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;
}
}
修改拦截器配置类:添加新拦截器,令其拦截所有请求,同时为两个拦截器设置order(order越小则越先执行)
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
//排除不需要拦截的路径
"/shop/**",
"/shop-type/**",
"/voucher/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}