1.流程分析
1.1发送短信验证码
提交手机号的时候要进行校验手机号,校验成功才会去生成验证码,将验证码保存到session,发生他把这部分那。
1.2短信验证码登录/注册
如果提交手机号和验证码之后,校验一致才进行根据手机号查询用户,进行创建新用户/登录成功,将信息保存到session进行返回。
1.3校验登录状态
前端传递过来cookie,携带其中的sessionID,从session中获取到用户信息,校验是否存在,存在就将用户保存到TheadLocal,不存在就拦截。
2.实现发送短信验证码
利用一些封装的工具生成验证码和校验手机号。
利用session进行存储验证码,方便校验,比较不错。
@Override
public Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 3. 生成验证码String code = RandomUtil.randomNumbers(6);// 2. 保存验证码到sessionsession.setAttribute(LOGIN_CODE_KEY + phone, code);// 发送验证码// todo 实现发送验证码log.debug("发送短信验证码成功: {}", code);// 返回OKreturn Result.ok();
}
3.短信验证码登录
最重要的可能是封装的思想吧,封装一定的常量和借助mybatis-plus的高级的功能。
重点:抽取逻辑,mybatis-plus高级功能。
/*** 实现登录功能** @param loginForm* @param session* @return*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1. 校验手机号String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式不正确");}// 2. 校验验证码// 从session中获取到验证码String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY + phone);// 校验验证码String code = loginForm.getCode();if (!StrUtil.equals(code, phoneForCode)) {return Result.fail("验证码错误!");}// 3. 查询用户是否存在// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();// userQueryWrapper.eq("phone", phone);// User user = userMapper.selectOne(userQueryWrapper);User user = query().eq("phone", phone).one();// 不存在则注册if (user == null) {user = createUserWithPhone(phone);}// 4. 保存信息到session中session.setAttribute("user", user);return Result.ok();
}/*** 封装一个创建用户的逻辑** @param phone* @return*/
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.登录验证功能
需要封装一个登录校验的功能供前端进行调取使用。
重点:通过SpringMVC进行拦截请求,封装数据到ThreadLocal中。
使用SpringMVC统一拦截请求可以方便将数据存储到ThreadLocal中,这样就无需在每个接口中进行配置了十分方便。
ThreadLocal是每个tomcat创建的请求线程中独有的,不会被其它线程访问到的。
封装拦截器,从session中获取到数据即可,最后一定要在请求后拦截器中将ThreadLocal中的数据删除。
package com.hmdp.utils;import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.beans.BeanUtils;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;/*** 登录拦截器*/
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 获取sessonHttpSession session = request.getSession();// 2. 获取用户信息User user = (User) session.getAttribute("user");// 3. 处理用户不存在if (user == null) {response.setStatus(401);return false;}// 4. 存储数据到ThreadLocalUserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user, userDTO);UserHolder.saveUser(userDTO);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
将拦截器配置到SpringBoot中,可以进行配置什么路径需要排除,什么无需排除。
/*** MVC拦截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);}
}
通过拦截器可以完成登录校验功能。
5.集群session共享问题
多台tomcat是并不进行共享session存储空间的,虽然tomact提供了将session共享到多台tomcat,但是这样性能太差了,还会有很多问题,所以为了进行实现分布式服务器tomcat共享session,建议使用redis进行替代session,这样就可以做到分布式session了。
6.基于redis实现
其实就是使用redis去利用键值对的形式进行存储用户的信息.
key的设计:项目名:业务名:类型:id。
这里只使用项目名,类型和id,使用这种结构式key可以大大的帮助到我们。
其实就是把使用session的部分换为使用redis了,存储key-value,取出key-value都使用redis即可。
重要点:使用token令牌替代了cookie。
使用redis的时候,一定要使用token吗?未必的,只是说将token作为一个帮助客户端和服务端之间进行身份认证的手段,完全也可以进行使用分布式session,使用redis存储session,前端依然携带cookie而来,所以这只是一种手段而已,各有所长。
6.1获取验证码
session => redis。
没有太多亮点,就简单分析一下key的构造吧。
login:code:phone,典型的业务+类型+分辨标识key,这样就很好的能架构出合理的key了。
public Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}// 1. 生成验证码String code = RandomUtil.randomNumbers(6);// 2. 保存验证码到session// session.setAttribute(LOGIN_CODE_KEY, code);// 2. 保存验证码到redis// 构造keyString key = LOGIN_CODE_KEY + phone;stringRedisTemplate.opsForValue().set(key, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);// 3. 发送验证码// todo 实现发送验证码log.debug("发送短信验证码成功: {}", code);// 返回OKreturn Result.ok();
}
6.2注册/登录
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1. 校验手机号String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式不正确");}// 2. 校验验证码// 从session中获取到验证码// String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY);// 从redis中获取到验证码String codeKey = LOGIN_CODE_KEY + phone;String phoneForCode = stringRedisTemplate.opsForValue().get(codeKey);// 校验验证码String code = loginForm.getCode();if (!StrUtil.equals(code, phoneForCode)) {return Result.fail("验证码错误!");}// 3. 查询用户是否存在// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();// userQueryWrapper.eq("phone", phone);// User user = userMapper.selectOne(userQueryWrapper);User user = query().eq("phone", phone).one();// 不存在则注册if (user == null) {user = createUserWithPhone(phone);}// 4. 保存信息到session中// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));// 4. 保存信息到redis中// 4.1 随机生成token, 作为登录令牌String token = UUID.randomUUID().toString();// 4.2 将User对象转换为Hash存储UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));// 4.3 存储数据到redisString userKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(userKey, userMap);// 4.4 设置stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return Result.ok(token);
}
6.2.1key的设计
其中key的设计是login:token:token,也是遵循的业务+类型+标识的设计思想。
6.2.2token的设计
这里的token很值得分析一下,尤其是可以对比苍穹外卖的进行分析。
这里的key仅仅是用来作为一个获取redis中数据使用的,并不是加密携带payload负载数据的,
其实就是使用了token替代了sessionID,但是其实还是有一个更为方便的解决方法的。
6.2.3存储hash数据到redis中的注意事项
1.使用什么API?
使用的是StringRedisTemplate中的opsForHash.putAll(),这个方法接收两个参数,key => 字符串,value => Map,它可以将Map中的key-value全部存入redis的hash中,十分方便。
2.Map中数据规范是什么样的?
由于我们进行使用的Redis客户端是stringRedisTemplate,这就限制了我们存储hash数据的时候,map中的key-value都必须是string类型的,如果出现了其它类型:比如Long类型,就会抛出错误,所以我们在将DTO转换为Map的时候,必须对value进行处理。
借助hutool工具类中的BeanUtil.beanToMap就可以完成这个操作。
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));
6.2.4Redis中Key设置时间的问题
使用stringRedisTemplate中的expire进行设置key的存活时间,传入key,time,TimeUnit即可。
6.3登录认证拦截器
我们需要将以前使用session进行将数据取出存入ThreadLocal的逻辑变更为使用前端传递来的token进行获取数据,从redis中获取用户数据DTO进行使用。
6.3.1思考:没有被SpringIOC托管的对象如何注入Bean
鉴于我们的登录拦截器配置类是我们自定义的,并且没有托管到SpringIOC容器,所以我们不能使用Resouce/Autowired。
那应该怎么办呢?我们发现MVC拦截器配置类是使用@Configuration进行注解的,这个类会被托管到SpringIOC容器,而且我们的自定义拦截器也被该类实例化了一个对象,所以完全可以通过该类将Bean在自定义拦截器实例化的时候,传递进来。
在构造函数被回调的时候,接收StringRedisTemplate对象,进行赋值给自身字段。
/*** 登录拦截器*/
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}}
将StringRedisTemplate进行注入到MVC配置类中,在调用拦截器构造函数的时候进行注入StringRedisTemplate进去即可。
/*** MVC拦截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);}
}
6.3.2整体登录校验流程
/*** 登录拦截器*/
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 获取sesson// HttpSession session = request.getSession();// 1. 获取请求头中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {response.setStatus(401);return false;}// 2. 获取用户信息// UserDTO user = (UserDTO) session.getAttribute("user");String userKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);// 3. 处理用户不存在if (userMap.isEmpty()) {response.setStatus(401);return false;}// 4. 将用户数据map -> userDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 存储数据到ThreadLocalUserHolder.saveUser(user);// 6. 刷新token有效期stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 7. 放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
1.整体流程:先从request请求头中获取到authorization中的token数据 => 然后封装一个userkey => 然后封装出来一个KEY,在redis客户端中获取到userDTO数据 => 然后处理用户不存在(redis获取不到数据的情况)=> 将用户数据map转换为userDTO => 将用户DTO存储到ThreadLocal中即可 => 最后进行刷新token有效期。
6.3.3刷新token时间
token在一定时间后会过期,但是如果在用户持续使用的过程中过期,那真是一个糟糕的事件,所以要采用拦截器刷新token时间的方式,这样就是在用户持续使用的时候,可以帮用户进行刷新token,延期,不会导致用户持续使用的时候过期。
在拦截器中进行token续期,是一个非常聪明的决策,这里采用的续期策略是,只要发送了请求,在token有效期内还在使用,就将时间续期到原始状态。
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
7.登录拦截器和刷新缓存拦截器如何设置?
7.1拆分的思路
主要是因为有一些请求是打不到登录拦截器的,一些不需要登录的请求根本打不到登录校验拦截器,所以我们不能将刷新token时间放在登录拦截器中去做,因为这样就会有可能用户看的都是不需要登录的接口数据,这样就会导致token无法进行续期,有可能出现用户看着看着就token过期了,所以为了避免这种情况的发生,可以进行设置两个拦截器:1.刷新token拦截器,所有接口都可以进行刷新token请求,当用户进行发送请求之后,从redis中获取到用户的token数据(因为前端会将token传递过来),当查询到数据的时候,就会去更新token时间,如果有数据将数据存储到ThreadLocal中。2.登录状态拦截器,仅仅进行拦截需要登录状态才能进行访问的接口,在拦截器中进行看一下ThreadLocal是否有数据,有就放行,无则滚蛋。
7.2实现token刷新拦截器
/*** 更新拦截器*/
public class RefreshInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 获取sesson// HttpSession session = request.getSession();// 1. 获取请求头中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;}// 2. 获取用户信息// UserDTO user = (UserDTO) session.getAttribute("user");String userKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);// 3. 处理用户不存在if (userMap.isEmpty()) {return true;}// 4. 将用户数据map -> userDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 存储数据到ThreadLocalUserHolder.saveUser(user);// 6. 刷新token有效期stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 7. 放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
7.3实现登录拦截器
/*** 登录拦截器*/
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (UserHolder.getUser() == null) {response.setStatus(401);return false;}return true;}}
7.4SpringMVC配置拦截器
拦截器的优先级:可以在registry注册的时候进行指定order,里面的排序数越小的越先执行,如果不进行指定优先级,就按代码的顺序进行注册,越靠上进行注册的拦截器,越先执行。
这里将刷新token的拦截器放在最前面,指定的顺序数是最小的。
/*** MVC拦截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}