黑马点评所需的资料文件上传到了链接:https://pan.baidu.com/s/1L2eISvE2tztmGvUBx0wNYQ
提取码:1234
首先将资料中提供的sql文件导入到数据库:
在资料中提供了一个项目源码:
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行
页面流程梳理如下:
具体代码如下:
UserController
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
UserServiceImpl
/**
* 获取验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号是否正确,手机号格式前后端都需要进行校验
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}
//生成验证码,这里使用的是hutool工具类的随机数生成方法,参数表示生成6位数验证码
String code = RandomUtil.randomNumbers(6);
//将验证码存放在session中
session.setAttribute("code",code);
//发送短信验证码成功
log.info("短信验证码发送成功:{}",code);
return Result.ok();
}
UserController
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm,session);
}
UserServiceImpl
/**
* 登录验证
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//判断手机号是否为空
if(phone == null){
return Result.fail("手机号不能为空");
}
//校验手机号是否合法
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}
//判断验证码是否正确
String code = (String)session.getAttribute("code");
if(!code.equals(loginForm.getCode())){
return Result.fail("验证码不正确");
}
//判断用户是否存在
User user = query().eq("phone", phone).one();
if(user == null){
log.info("用户不存在,创建新用户:{}",phone);
//调用创建用户方法
user = createNewUser(phone);
}
//将已登录的用户信息保存在session中,但是不能将用户的所有信息都保存,这里选择保存UserDto而非保存User
session.setAttribute("user" , BeanUtil.copyProperties(user,UserDTO.class));
return Result.ok();
}
/**
* 创建新用户
* @param phone
* @return
*/
public User createNewUser(String phone){
User user = new User();
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
user.setPhone(phone);
save(user);
return user;
}
登录功能完成后,依旧无法登录,因为前端会发送一个请求来获取当前已登陆的用户信息,然后在每次访问服务器时都会携带这个用户信息,服务端需要拿到当前用户信息,已便在后续功能中使用。上述这一流程我们还未完成
我们可以使用ThreadLocal来完成在服务端保存当前已登录用户信息的功能,ThreadLocal可以针对每一个socket连接做到线程隔离,适合用来保存用户信息
在基础代码中就已经提供了关于ThreadLocal的工具类
在UserController中完成以下方法的代码
/**
* 获取当前登录的用户信息
* @return
*/
@GetMapping("/me")
public Result me(){
// 通过ThreadLocal获取当前登录的用户信息并返回
return Result.ok(UserHolder.getUser());
}
当我们登录之后,前端会发送请求到这个方法,然后获取当前已登录用户信息,当然目前还是获取不了,因为我们并未在ThreadLocal中保存当前已登录用户信息。
那么在那里保存用户信息比较合适呢?答案是拦截器
登录拦截功能的实现图示如下:
新建一个LoginInterceptor,编写代码如下:
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("拦截到请求:{}",uri);
//判断当前请求是否携带session
UserDTO user = (UserDTO)request.getSession().getAttribute("user");
if(user == null){
//user为空说明未登录,请求不放行直接返回
log.info("用户未登录,请求{}已被拦截",uri);
response.setStatus(401);
return false;
}
log.info("用户已登录,id为{}",user.getId());
//存在,保存用户信息到ThreadLocal
UserHolder.saveUser(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在请求结束后销毁ThreadLocal中的用户信息
UserHolder.removeUser();
}
}
新建一个MvcConfig配置类,让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
//addInterceptors 添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/user/login",
"/user/code",
"/shop-type/**",
"/upload/**"
);
}
}
再次尝试登录,发现登录后已经可以正常跳转到用户主页了
使用session实现验证登录在单体项目中可行,但是在分布式项目中就会出现问题。
在分布式项目中,每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了,但是这种方案具有两个大问题:
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟,在session拷贝期间如果有用户进行访问的话还是会出现session不存在的情况
由于session登录存在上述问题,因此我们会用redis来替代session,由于redis是是单独部署在其他服务器上的,所有的tomcat都可以对其进行访问,这样就可以解决数据共享的问题了。
既然我们要利用redis存储数据,那么到底使用哪种结构呢?如果存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,需要注意的是由于String存放的是json串而Hash是直接存放字段和值的,因此String会比Hash占用更多的内存空间
我们需要保存在redis中的数据一共有两种,第一种是验证码,第二种是用户信息。那么针对这两种不同的信息,我们应该分别设计怎样的key呢?Redis中的key应该满足两点,第一点是唯一性,第二点是方便携带。
针对验证码,我们可以用手机号来做key,这样的话就可以很好的保证key的唯一性
针对用户信息,我们同样可以使用手机号作为key,但是有一个问题需要考虑,就是此时我们已经不用session进行用户校验了,那么服务器在做登录拦截时使用什么作为校验凭证呢?最好的方案就是使用redis中用户信息的key,前端在访问时通过访问头携带key来访问,如果通过key能在redis中找到数据,说明用户已登录。那么这种情况下我们最好不要使用手机号作为key,这毕竟属于用户比较隐私的信息,我们在后台生成一个随机串token,用这个token来作为key就比较合适了。
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
UserContorller不用修改,直接修改UserService
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 获取验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号是否正确
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}
//生成验证码
String code = RandomUtil.randomNumbers(6);
//将验证码存放在redis中
//这里使用String类型,key使用手机号码,值为验证码
redisTemplate.opsForValue().set(phone,code);
//发送短信验证码成功
log.info("短信验证码发送成功:{}",code);
return Result.ok();
}
/**
* 登录验证
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//从redis中获取验证码
String code = redisTemplate.opsForValue().get(phone);
//判断手机号是否为空
if(phone == null){
return Result.fail("手机号不能为空");
}
//判断验证码是否为空
if(code == null){
return Result.fail("验证码不能为空");
}
//校验手机号是否合法
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}
//判断验证码是否正确
if(!code.equals(loginForm.getCode())){
return Result.fail("验证码不正确");
}
//判断用户是否存在
User user = query().eq("phone", phone).one();
if(user == null){
log.info("用户不存在,创建新用户:{}",phone);
//调用创建用户方法
user = createNewUser(phone);
}
//使用redis保存用户信息
//这里使用uuid随机生成redis的key
String userToken = UUID.randomUUID().toString();
//这里为了避免不同业务的key冲突,给key加上前缀
String userTokenKey = RedisConstants.LOGIN_USER_KEY+userToken;
//使用Hash类型存储用户信息,存储数据前,需要先将对象转换成map集合
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//转换成map集合的过程中还需要做处理,因为StringRedisTemplate只能只针对字符串进行序列化,因此我们要将userDTO中每个 属性都转换成字符串
Map<String, Object> map = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create() //自定义拷贝选项
.ignoreNullValue() //允许属性为null
.setFieldValueEditor((fileName,fileValue)->fileValue.toString()) //对属性值进行编辑,把所有属性值转换成字符串
);
//保存数据到redis
redisTemplate.opsForHash().putAll(userTokenKey,map);
//设置redis有效期
redisTemplate.expire(userTokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//这里需要将token信息返回给前端,前端需要token令牌来访问
return Result.ok(userToken);
}
/**
*
* @param phone
* @return
*/
public User createNewUser(String phone){
User user = new User();
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
user.setPhone(phone);
save(user);
return user;
}
}
LoginInterceptor修改如下:
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
//这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("拦截到请求:{}",uri);
//前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
String token = request.getHeader("authorization");
//判断token是否为空
if(StrUtil.isBlank(token)){
//token为空直接返回
log.info("用户未登录,请求已被拦截:{}",uri);
response.setStatus(401);
return false;
}
//组装key
String key = RedisConstants.LOGIN_USER_KEY + token;
//通过key获取redis中的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);
if(map == null||map.size()==0){
//map为null说明令牌是瞎编的,直接返回
log.info("key不正确,请求已被拦截:{}",uri);
response.setStatus(401);
return true;
}
//将map集合转化为userDto对象
UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//保存用户信息到ThreadLocal
UserHolder.saveUser(user);
//刷新token有效期,用户每访问一次服务器都需要刷新一次token有效期,避免用户在一直活跃的情况下令牌失效
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
log.info("用户已登录,id为{}",user.getId());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在请求结束后销毁ThreadLocal中的用户信息
UserHolder.removeUser();
}
}
MvcConfig代码修改如下:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//在创建对象时将stringRedisTemplate传给拦截器使用
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/user/login",
"/user/code",
"/shop-type/**",
"/upload/**"
);
}
}
在上述代码中,我们为了避免出现活跃用户token失效的情况,在LoginInterceptor中编写了刷新令牌存活时间的代码,但是这样仍然可能出现令牌失效的情况,因为LoginInterceptor并没有拦截所有的访问路径,如果用户登陆了,但是在令牌过期时间内一直访问一些不需要拦截的路径,那么这个拦截器就不会生效,令牌刷新的动作也不会被执行,因此这个方案实际上时有问题的
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,这个拦截器用于拦截器中所有的路径,并负责刷新令牌和进行token的校验,如果判断用户登录了,就将用户信息存放在threadLocal中。无论用户登不登陆,该拦截器都会放行所有的请求,由LoginInterceptor进行未登录请求拦截的处理
整体代码如下:
新建一个RefreshTokenInterceptor拦截器,负责刷新令牌和保存用户信息等工作
@Slf4j
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
//这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("RefreshTokenInterceptor拦截到请求:{}",uri);
//前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
String token = request.getHeader("authorization");
//判断token是否为空
if(StrUtil.isBlank(token)){
//令牌不存在是不需要刷新的,直接放给下一个拦截器处理
log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
return true;
}
//组装key
String key = RedisConstants.LOGIN_USER_KEY + token;
//通过key获取redis中的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);
if(map == null||map.size()==0){
//用户不存在的话也不需要刷新令牌,直接放行给下一个拦截器处理
log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
return true;
}
//将map集合转化为userDto对象
UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//保存用户信息到ThreadLocal
UserHolder.saveUser(user);
//刷新token有效期
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
log.info("用户已登录,RefreshTokenInterceptor已放行请求,用户为{}",user.getId());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在请求结束后销毁ThreadLocal中的用户信息
UserHolder.removeUser();
}
}
修改LoginInterceptor的代码,因为很多工作我们已经在RefreshTokenInterceptor中做了,因此在LoginInterceptor我们只需要判断ThreadLocal中有没有用户信息即可
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("LoginInterceptor拦截到请求:{}",uri);
if(UserHolder.getUser() == null){
//说明用户未登录,直接拦截
log.info("用户未登录,LoginInterceptor未放行请求{}",uri);
response.setStatus(401);
return false;
}
log.info("LoginInterceptor已放行请求:{}",uri);
//说明用户已登录,直接放行
return true;
}
}
还需要在MvcConfig中修改拦截器配置
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 配置拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/user/login",
"/user/code",
"/shop-type/**",
"/upload/**"
).order(1);//
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
这样就大功告成了