【小众点评项目】 开发日记 DAY 01

1.基于Session的登录

1.1 登录流程

  • 用户在提交手机号后,会验证手机号是否合法。如果合法,则会调用发送验证码的相关程序,给用户发送验证码,并将验证码保存到session中。
  • 用户将验证码和手机号输入系统,系统根据用户输入的验证码和session中的比较,如果相同,则允许登录,否则登陆失败。此外,如果用户不存在,则创建用户。并将用户信息保存到session中。
  • 用户发送请求是,会通过cookie将sessionId携带到后台,后台从session中拿到用户信息,如果存在用户信息,则放行,并将用户信息保存到ThreadLocal,否则不放行。
    在这里插入图片描述

1.2 具体实现

在这里插入图片描述

1.2.2 代码实现

验证码发送

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(
    // 4.保存验证码到 session
    session.setAttribute("code",code);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if(cacheCode == null || !cacheCode.toString().equals(code)){
         //3.不一致,报错
        return Result.fail("验证码错误");
    }
    //一致,根据手机号查询用户
    User user = query().eq("phone", phone).one
    //5.判断用户是否存在
    if(user == null){
        //不存在,则创建
        user =  createUserWithPhone(phone);
    }
    //7.保存用户信息到session中
    session.setAttribute("user",use);
    return Result.ok();
}

登录拦截器
在这里插入图片描述

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;
    }
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

为了保证用户信息的安全,我们在session中保存的用户信息应该隐藏掉敏感信息,比如密码。所以我们在存入session的时候要敏感信息,这里我们使用userDTO来实现。

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

修改登录方法中的代码

//7.保存用户信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));

2.基于Session登录存在的问题分析

2.1 Nginx服务器和集群环境

手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。

在引入了Nginx服务器和集群环境下,每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题
在这里插入图片描述

2.2 思路分析

为解决session共享问题,早期的做法是进行session拷贝,保证每一台tomcat中都有全部的session信息。这样就可以实现session共享了,但是这种方式两大问题

  • 每台服务器都有全部session,导致服务器压力过大
  • session拷贝过程中可能存在延迟

3 使用Redis实现登录流程

3.1 Key-value的设计

我们要使用redis实现登录首先就是要设计key。key应该满足一下条件

  • 唯一性
  • 脱敏性

为了满足这两条,我们最终采用了随机生成一个token作为key。“login:” + token
接下来就是选择合适的数据结构,因为我们保存的用户信息非常少,值包含基本信息,因此这里我们就选择最简单的String类型保存用户信息。将用户信息序列化成Json保存到value中。
当然,我们也将验证码保存到redis中 key “code:” + phone value为验证码的值。
在这里插入图片描述

3.2 整体访问流程

  • 用户点击发送验证码,系统将验证码保存到redis中,key “code:” + phone value为验证码的值。
  • 用户登陆,根据手机号到redis中查询验证码。如果登录成功,则随机生成一个token返回到前端,并且登录用户的信息写入到redis中。key为“login:” + token,value为用户的JSON字符串。
  • 验证登录状态。用户发送请求,携带token,后端根据token到redis中查找用户信息,如果查找到,则将用户信息保存到ThreadLocal并放行。
    在这里插入图片描述
    关于token,如果用户登录成功,我们会返回给前端一个token。前端保存这个token,然后以后每次发送请求,都会带上这个token,下面是前端的相关代码。
// request拦截器,将用户token放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
  config => {
    if(token) config.headers['authorization'] = token
    return config
  },
  error => {
    console.log(error)
    return Promise.reject(error)
  }
)

3.3 代码实现

@Override
public Result sendCode(String phone, HttpSession session) {
    //1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        //2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误");
    }
    //3. 符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    //4. 将验证码保存到Redis
    // session.setAttribute("code", code);
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //5. 发送验证码
    System.out.println(code);
    log.debug("发送短信验证码成功,验证码:{}", code);
    //返回ok
    return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm) {
    //1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }
    //2. 校验验证码
    // TODO 2.1 从Redis中获取验证码 key为手机号
    // Object cacheCode = session.getAttribute("code");
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        //3. 不一致,报错
        return Result.fail("验证码错误");
    }
    //4.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
    //5. 判断用户是否存在
    if (user == null) {
        //6. 不存在,创建新用户
        user = createUserWithPhone(phone);
    }
    // 使用UUID生成Token
    String token = UUID.fastUUID().toString(true);
    // 将用户转化为Map
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 因为我们保存的到Redis的时候使用的是String类型,然而在userDto中ID是Long类型,因此会报错
    // 使用setFieldValueEditor将userDto所有字段都转化成String类型,然后保存
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create()
            .setIgnoreNullValue(true)
            .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 将Map以Hash形式保存到Redis
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 设置Key有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);
    // session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
    // 这里将token返回给前台,前台会放到sessionStorage中存放,下次再发送请求的时候将在header中添加auth字段中把token带回给服务器
    return Result.ok(token);
}

3.4 登陆状态刷新的问题

3.4.1 问题分析

我们知道session的有效期是30分钟,如果30分钟之内用户没有操作,则session自动过期。然而我们目前实现的基于redis的登录方法还无法做到这一点。

// 设置Key有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);

目前的情况是,我们只在用户登录的时候设置了有效期30分钟。也就是说用户即使和系统还有交互,但是30分钟以后用户的登录状态也会失效。这肯定是不能接受的,因此我们还要进行优化。

3.4.2 思路在这里插入图片描述

存在的问题:但是这种方案存在一些问题,因为我们的拦截器并不是什么请求都拦截的,一些商家信息和主页信息用户不登录也是可以访问的,所以如果只是在登录拦截器上设置token的过期时间,那么用户登录后一直在访问不需要登录验证的页面,那么这个重新设置token有效期的代码就不会执行,因此用户还是会在30分钟之后失去登录状态。

方案二:我们建立一个新的拦截器,让他拦截所有的请求,对所有请求都放行。他的作用是判断用户如果登录,那么就重置token有效期。这样一来,只要用户发送请求,token有效期必然重置。
在这里插入图片描述

3.4.3 代码实现
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)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        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(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}
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的方式设置拦截器的拦截顺序。数字越小,拦截器的顺序越靠前。

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // 默认拦截器拦截所有请求 使用order控制拦截器的先后顺序
        registry.addInterceptor(new RedisRefreshInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/user/code",
                        "/user/login"
                ).order(0);
    }
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值