前后端分离完成登录状态的校验

笔记导读:

我之前做前后端分离的项目的时候在想,第一次登录一个网站过后后续登录就可以直接访问太神奇了,后来熟练了发现是后端的过滤器和拦截器实现的, 最近在跟着视频组做项目时候我发现自己有点忘记了前端是怎么配合后端完成的,本文带5分钟过一遍登录状态的校验

后端-----登录状态凭证session

需求分析当

比如bilibili ,阿里云,用户登录后,游览器下一此访问用户信息时可以无需登录直接使用,这个时候就想到为什么没有登录时候页面发生跳转,以及登陆后在访问时候去却可以不需要再次登录,前端是根据什么判断用户状态跳转页面的?

拦截器完成校验

这里使用session存放数据完成验证,下一案列使用jwt完成(java web token)
controller:

  *
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     *                  @RequestBody json的后台解析
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        //俩个参数是因为一个是验证码登录  验证码 loginform是密码登录

        return userService.login(loginForm,session);
    }

service实现类:

  @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
//      1.  校验登录时候的手机号是否合法
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式错误");
        }
//        2. 校验验证码
        String scode =(String)  session.getAttribute(HmConstants.CODE_BY_PHOINE);
        String sphone =(String) session.getAttribute(HmConstants.PH_BY_CODE);
        System.out.println(sphone+"|"+phone);
        if (!phone.equals(sphone)){//避免验证码到了 用户用另一手机号填写
            return Result.fail("填写手机号和发送验证码手机号不一致");
        }
         if(Objects.isNull(scode))
        {
            return Result.fail("为空");
        }
         if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equals
            return Result.fail("验证码不一致");
        }
     // 3. 根据手机号在数据查询用户
        User one = query().eq("phone", loginForm.getPhone()).one();
        //判断用户是否存在
        if (Objects.isNull(one)){
            //4.如果不存在创建新用户 并且保存数据
             one = new User();//新申请内存
            one.setPhone(loginForm.getPhone());
            one.setPassword(UUID.randomUUID().toString());//因为是验证码登录注册 所以密码直接uuid 后期用户自己修改
            one.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));
//            one.setCreateTime(new Date().toString());
            boolean b = save(one);//添加用户
            System.out.println("保存结果:"+b);
        }

        //5.存在保存用户信息到session
        //todo 优化后期直接存取redis 以及vo对象
        session.setAttribute(HmConstants.USER_INFO,one);
      log.debug("存储的信息:"+  session.getAttribute(HmConstants.USER_INFO));
//        返回  无需返回登录凭证
        /**
         * 每一个session 都有唯一session id(访问tomcat服务器自动写在cookie中)
         * 请求中携带cookie(sessionid)
         * 游览器根据sessionid 即可放行
         */
        return Result.ok();//前端做了放行
    }

用到的手机号格式判断utls

public class RegexUtils {
    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }
    /**
     * 是否是无效邮箱格式
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email){
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code){
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {
            return true;
        }
        return !str.matches(regex);
    }
}

userholder 用于存放 验证数据

/**
 * 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态
 */
public class UserHolder {
    private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();

    public static void saveUser(UserVo user){
        tl.set(user);
    }

    public static UserVo getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}
拦截器:

对前端发送的请求进行验证
判断是否具有存入session的登录状态凭证(是否能读取到数据) 并且存入凭证

@Slf4j
@Component//交给ioc去管理配置的时候 无需new 一个内存空间 可以使用自动装配
public class loginIterceptor implements HandlerInterceptor {
//    进入controller 获取接口之前
//    每一个请求前对用户进行校验是否放行 登录状态放行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.我们的用户信息存放在session中间的 所以我们从请求中需要获取 session
        HttpSession session = request.getSession();
        //2.获取session中的用户
        Object sessonuser = session.getAttribute(HmConstants.USER_INFO);

        //3.判断session的用户是否纯在
        if (Objects.isNull(sessonuser)){
            //不存在在的拦截   给前端抛出异常 或设置响应体状态码
            response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid
            // 就是以为cookie携带的 写入response 返回给客户端
            // 客户端的request在每次请求都会携带cookie
            // 服务器端根据(cookie)拿到参数session request.getSession,还是用httpsession接受都是如此2

            return false;//fasle 拦截 true 放行
        }

        //4.存在的话把用户信息保存到ThreadLocal (当前线程线程)
        UserHolder.saveUser(BeanCopyUtils.copyBean(sessonuser,UserDTO.class));
//    5. 放行
        return true;
    }
//controller执行之后
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

//    视图渲染之后
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
     //每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞
        UserHolder.removeUser();
    }
}

拦截器写好后需要在配置类交给ioc经行注册

@Configuration//申明为配置类
public class MvcConfig implements WebMvcConfigurer {
    @Autowired
 private com.hmdp.Interceptor.loginIterceptor logininterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(logininterceptor).excludePathPatterns(
                "/user/code",
//            拦截器也是chain 可以链式编程       排除无需拦截的请求路径
               "/shop/**",
                "/shop-type/**",
//                "/upload/**",上传图片接口登录后使用
                "/voucher/**",
                "/blog/hot",

                "/user/login"
        );//不用在new 拦截器 并且需要配置拦截路径 避免全部看拦截
    }
}

逻辑梳理:

前端第一次访问后端,servlet会自动用cookie携带sessionID创建session,且客户端再次访问都会携带cookie,当后端接收到前端传递的登录信息,我们调用mysql查询等操作完成以后在session存放用户数据

既然session域里面有数据,那么前端页面是不是可以在页面初始化时候进行访问一个校验判断呢,如果访问到对应数据说明在对应页面是登录状态,否则跳转登录页面,并且拦截登录后才能访问的接口
上面描述是手机号登陆,实际开发中往往为了数据库安全会对保存在数据库的密码进行加密
在这里插入图片描述
每个用户的盐是一个uuid或者时间搓组合的唯一值
在这里插入图片描述
通过这种方式更为安全,当然前端也不能使用明文,

  • 要么传递的时候前端调用第三方库加密,然后配合后端的盐在二次加密
  • 要么就对明文密码进行可解密的加密,后端进行解密,配合明文密码+盐进行保存
    那么现在采用jwt实现登陆校验

后端-----登录状态凭证(Jwt)实现

引入依赖

   <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>

JWT工具类

public class AppJwtUtil {

    // TOKEN的有效期一天(S)
    private static final int TOKEN_TIME_OUT = 3_600;
    // 加密KEY
//    随机生成的加密key这里使用uuid
    private static final String TOKEN_ENCRY_KEY = "23ee9c693446422cb8f84bea449fca3d";
    // 最小刷新间隔(S)
    private static final int REFRESH_TIME = 300;

    // 生产ID
    public static String getToken(Long id){
        Map<String, Object> claimMaps = new HashMap<>();
        claimMaps.put("id",id);
        long currentTime = System.currentTimeMillis();
        return Jwts.builder()
                .setId(UUID.randomUUID().toString())
                .setIssuedAt(new Date(currentTime))  //签发时间
                .setSubject("system")  //说明
                .setIssuer("houhou") //签发者信息
                .setAudience("app")  //接收用户
                .compressWith(CompressionCodecs.GZIP)  //数据压缩方式
                .signWith(SignatureAlgorithm.HS512, generalKey()) //加密方式
                .setExpiration(new Date(currentTime + TOKEN_TIME_OUT * 1000))  //过期时间戳
                .addClaims(claimMaps) //cla信息 存放的个人设置信息
                .compact();
    }

    /**
     * 获取token中的claims信息
     *
     * @param token
     * @return
     */
    private static Jws<Claims> getJws(String token) {
            return Jwts.parser()
                    .setSigningKey(generalKey())
                    .parseClaimsJws(token);
    }

    /**
     * 获取payload body信息
     *
     * @param token
     * @return
     */
    public static Claims getClaimsBody(String token) {
        try {
            return getJws(token).getBody();
        }catch (ExpiredJwtException e){
            return null;
        }
    }

    /**
     * 获取hearder body信息
     *
     * @param token
     * @return
     */
    public static JwsHeader getHeaderBody(String token) {
        return getJws(token).getHeader();
    }

    /**
     * 是否过期
     *
     * @param claims
     * @return -1:有效,0:有效,1:过期,2:过期
     */
    public static int verifyToken(Claims claims) {
        if(claims==null){
            return 1;
        }
        try {
            claims.getExpiration()
                    .before(new Date());
            // 需要自动刷新TOKEN
            if((claims.getExpiration().getTime()-System.currentTimeMillis())>REFRESH_TIME*1000){
                return -1;
            }else {
                return 0;
            }
        } catch (ExpiredJwtException ex) {
            return 1;
        }catch (Exception e){
            return 2;
        }
    }

    /**
     * 由字符串生成加密key
     *
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getEncoder().encode(TOKEN_ENCRY_KEY.getBytes());
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    public static void main(String[] args) {
        System.out.println(UUID.randomUUID().toString().replace("-", ""));
//       Map map = new HashMap();
//           map.put("id","11");
//        输入id进行加密
        System.out.println(AppJwtUtil.getToken(1102L));
//        解密
        Jws<Claims> jws = AppJwtUtil.getJws("eyJhbGciOiJIUzUxMiIsInppcCI6IkdaSVAifQ.H4sIAAAAAAAAADWLSwrDMAwF76J1DLLkX3MbCxvqQKjBNrSU3j3KovAWbxjmC8dssEOo5BPHYGJxxbhEZJKIGPbJClYkdggbtDxhtxHZo-MQNhhLtB6fMet5-zEUn6-lU8yrKObe9dd3_6fxcadNnbVIvwvlMJ1ThAAAAA.x9g1ieEmEc6AoRTu3dtiPC96_GBg-q7KvWoFHicDU1d-3WHl1HF-SoJK2EtCvlTSydtvhEMX6N5zv1YVUjBB8A");
//        获取jwt加密的信息
        Claims claims = jws.getBody();

        System.out.println(claims.get("id"));

    }

}

登陆逻辑

@Override
    public ResponseResult login(UserDto details) {
        //        最后返回给前端的数据
        Map<String, Object> map=new HashMap<String, Object>();



        if(details==null){
            return ResponseResult.errorResult(1002,"没有登陆参数");
        }
        User user = lambdaQuery().eq(details.getName() != null, User::getName, details.getName()).one();
         if (user== null){
            return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST);
        }

        String salt = user.getSalt();
        String s = DigestUtils.md5DigestAsHex((salt + details.getPassword()).getBytes(StandardCharsets.UTF_8));
        if (!s.equals(user.getPassword())){
            return ResponseResult.errorResult(1001,"密码错误");
        }
        //        说明登陆成功,返回jwt

        String token = AppJwtUtil.getToken(user.getId().longValue());

//        返回给前端 前端登陆后的请求交给过滤器器进行携带
        map.put("token", token);
//        由于返回给前端的信息有些敏感信息不饿能返回,这里需要设置为空
        UserDto dto = new UserDto();
        BeanUtils.copyProperties(user, dto);
        dto.setPassword("");//密码设置为空
        map.put("user", dto);

        return ResponseResult.okResult(map);

    }

拦截器和配置类注册

@Component
@Slf4j
public class AuthorInterceptor implements HandlerInterceptor {
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        ThreadUserLocalUtil.clear();
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = null;
// 如果是 OPTIONS 请求,我们就让他通过,不管他
        if (request.getMethod().equals("OPTIONS")) {
            response.setStatus(HttpServletResponse.SC_OK);
            return true;
            // 如果不是,我们就把token拿到,用来做判断
        }else {
            token = request.getHeader("Authorization");
            log.info("token:{}",token);

            if (token == null){
                log.info("token为空");
                response.setStatus(401);
                return false;
            }
            Claims claims = AppJwtUtil.getClaimsBody(token);

            int validationResult = AppJwtUtil.verifyToken(claims);

            if (validationResult == 1 || validationResult == 2) {

                response.setStatus(401);
                return false;
            }
//            -1:有效,0:有效,1:过期,2:过期


            Long id = (Long)claims.get("id");

            if (id == null){
                response.setStatus(401);
                return false;
            }

            User user = new User();
            user.setId(id);
            ThreadUserLocalUtil.setThreadLocal(user);
            return true;
        }
        }



}

配置类

@Configuration//申明为配置类
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
       registry.addInterceptor(new AuthorInterceptor()).order(1).excludePathPatterns(
               "/login/**","/upload/**"
//               注册
       );
    }
}

关于threadlocal

如果看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
除了公用接口和login接口都经行拦截,包括校验接口(只有登录后在校验),有登录凭证再经行校验(是否session过期需要登录之类)

校验接口:

当然除了在session中存储 还可以在redis中完成该操作
核心思路分析:

​> 每个tomcat中都有一份属于自己的session,但是如果我的后端是个集群假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户请求带着token访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟
后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
userholder 把用户信息存放发在线程容器中

/**
 * 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态
 */
public class UserHolder {
    private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();

    public static void saveUser(UserVo user){
        tl.set(user);
    }

    public static UserVo getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

token 保存在redis中

/**
 * 通过拦截器实现登录校验功能 过滤器也可以完成这个功能
 * springsecurity 也可以完成对应功能
 * controller 也叫做hander
 * 拦截器设置好后需要配置才能生效
 */
//单一拦截器就可以做到对请求查询redis 刷新token有效期 但是连续访问的请求如果在拦截器排除的路径之外的我公共接口 时间到了以后又会过期 重而用户重新登录
//    所以我们需要把功能分成多个部分实现 特定功能
@Slf4j

/**
 * 2. 这个拦截器的功能则是判断是否需要做拦截
 */
public class loginIterceptor implements HandlerInterceptor {
//    进入controller 获取接口之前
//    每一个请求前对用户进行校验是否放行 登录状态放行
    //拦截器在ioc初始化之前执行 所以不能在使用的类上使用自动装配 即使加入@Compoent,Conreoller,Service,mapper等注解注入ioc也不行
    private StringRedisTemplate stringRedisTemplate; //所以现在我们不装配这个对象等拦截器加入配配置类的时候作为构造函数的参数注入

    public loginIterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.我们的用户信息存放在Redis中间的,token为key 所以我们从请求头中需要获取token
        String token = request.getHeader("authorization");
        //1.2判断是否为空
        boolean b = StringUtils.isEmpty(token);
        if (b){
            //这个拦截器的主要目的是刷新token数据凭证的有效期
            response.setStatus(401);
            return false;//拦截请求 未携带token 处于未登录状态
        }
        //2.获取redis中的用户  entries 包含了为空判断的方法
        Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(HmConstants.LOGIN_Token + token);
        //3.判断存储的的用户是否纯在
        if (map.isEmpty()){
            //不存在在的拦截   给前端抛出异常 或设置响应体状态码
            response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid
          

            return false;//fasle 拦截 true 放行
        }
//4. 获取到的是map数据需要转换为user对象 所以需要bean的转化
        UserVo userVo= BeanUtil.fillBeanWithMap(map,new UserVo(), false);
        //4.存在的话把用户信息保存到ThreadLocal (当前线程线程)
        UserHolder.saveUser(userVo);

//    6. 放行
        return true;
    }
//controller执行之后
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

//    视图渲染之后
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
     //每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞
        UserHolder.removeUser();
    }
}

拦截器的工作还是从解析当前请求所属用户,并把当前用户信息保存到处理当前请求的线程
使用redis后的login

 @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
//      1.  校验登录时候的手机号是否合法
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式错误");
        }
//        2. 校验验证码 因为验证码读取校验的时候是通过手机号 取数据 所以不存在验证码正常手机号对不上
        String scode =stringRedisTemplate.opsForValue().get(HmConstants.CODE_BY_PHOINE+phone);

         if(Objects.isNull(scode))
        {
            return Result.fail("验证码失效");
        }
         if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equals
            return Result.fail("验证码不一致");
        }
     // 3. 根据手机号在数据查询用户
        User one = query().eq("phone", loginForm.getPhone()).one();
        //判断用户是否存在
        if (Objects.isNull(one)){
            //4.如果不存在创建新用户 并且保存数据
          User user = new User();//新申请内存

            user.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));
            user.setPhone(loginForm.getPhone());

            boolean b = save(user);//添加用户

            System.out.println("保存结果:"+b);
        }

        //5.存在保存用户信息到session/redis

//5.1一个token发送给前端作为登录令牌  将userInfo转化为map 存储
        String token = UUID.randomUUID().toString(true);//不携带-符号

        Map<String, Object> hashMap = new HashMap<>();
        UserVo userVo = BeanUtil.copyProperties(one, UserVo.class);
      hashMap=BeanUtil.beanToMap(userVo,new HashMap<>(), CopyOptions.create()//创建复制选项 自定义
              .setIgnoreNullValue(true).setFieldValueEditor(
                      (fieldName,fieldValue)->fieldValue.toString()));//所有fieldname 对应的value转化为toString
//上面的操作用put挨个放入map 到id时候tostring 也是一样的
        stringRedisTemplate.opsForHash().putAll(HmConstants.LOGIN_Token+token,hashMap);//建议使用putAll 不需要服务端经行多次io流操作
        stringRedisTemplate.expire(HmConstants.LOGIN_Token+token,60*60,TimeUnit.SECONDS);   //  设置用户在redis中有效期为1小时
        //   在拦截器设置用户发起一次请求更新一次有效器 让做到 只有不操作的时候才会一小时过期

        return Result.ok(token);//前端做了放行 token返回给前端 使其后续访问需要登录状态的接口用token作为校验凭证
    }

在这里插入图片描述

此时就讲前端传递的token令牌和用户前缀作为key,用户信息为value传入redis,每次请求携带的时候后端拦截器进行校验,这里就做到每个每次用户访问的时候,携带的token与用户信息无关,如果是jwt字符携带用户信息还是有可能被用户解码,为此这样做的话用户信息就不会被泄露

分析俩种方式

两种思路:

1.JWT 方案: 用户登录成功后,后端生成一个包含用户信息的 JWT(JSON Web Token),将其返回给前端。前端在后续请求中将 JWT 携带在请求头中,后端通过解码 JWT 获取用户信息,从而验证用户的身份和登录状态。
2.UUID 结合 Redis 方案: 用户登录成功后,后端生成一个带有用户信息的 UUID,并将该 UUID 作为 key,用户信息存储在 Redis 中。后端将 UUID 返回给前端,前端在后续请求中将该 UUID 携带在请求头中,后端通过 Redis 中的 key 获取用户信息,验证用户的身份和登录状态。

让我们分析一下这两种思路的优缺点:

JWT 方案:

优点

  • 无状态: JWT 是无状态的,后端不需要在服务器端存储用户信息。用户信息被编码在 JWT 中,前端发送请求时携带 JWT 即可,后端通过解码 JWT 获取用户信息。
  • 扩展性: JWT 可以包含任意 JSON 数据,因此可以轻松地扩展存储其他与用户相关的信息。

缺点

  • 无法主动登出: 一旦 JWT 发送到前端,后端无法主动使其失效。需要依赖前端清除 JWT 来实现登出。
  • Token 大小: 如果用户信息较多,JWT 可能变得较大,增加网络传输开销。
token 结合 Redis 方案:

登陆成功后讲token当前线程用户的token保存到redis,下次请求带着基础token解密后,去redis读取数据,如果存在说明当前用户在登陆状态,不存在则说明没有登陆
优点

  • 可控制登出: 后端可以在服务器端主动删除 Redis 中的用户信息,实现主动登出的控制。

缺点

  • 需要服务器存储: 需要在服务器端存储用户信息,增加了服务器端的负担。
  • 无法扩展额外信息: 相较于 JWT,UUID 方案无法轻松地扩展存储其他用户相关信息。

共同考虑的因素

  • 安全性: JWT 内置了签名机制,能够验证 token 的真实性和完整性。而 UUID 方案需要依赖传输通道的安全性和 Redis 的安全配置。
  • 时效性: JWT 可以设置过期时间,而 UUID 方案可能需要额外的机制来处理 token 的过期。redis 的expire
    前后端协作: 需要确保前后端对 token 的处理方式一致,尤其是在登录和登出时的行为。

选择其中一种方案通常取决于具体的应用场景、需求和偏好。如果对于主动登出、扩展性和无状态有较高要求,JWT 可能更适合;如果对于服务器存储负担较为敏感,且需要主动登出的功能,UUID 结合 Redis 方案可能更合适。

前端实现

在需要登录后才显示的页面中加入钩子函数 页面初始化前访问校验登录状态接口
在这里插入图片描述
如果后端传来凭证显示该页面 否则跳转登录页面

在这里插入图片描述

axios拦截器

前端还可以自己写axois的拦截,根据状态码完成以上功能

import axios from 'axios'
import router from '../router';
// import {useRouter} from "vue-router";只有vue组件可以
//没用这理
const request = axios.create({
    baseURL: '/api',  // 注意!! 这里是全局统一加上了 '/api' 前缀,也就是说所有接口都会加上'/api'前缀在,页面里面写接口的时候就不要加 '/api'了,否则会出现2个'/api',类似 '/api/api/user'这样的报错,切记!!!
    timeout: 60000 //三分终
})

// request 拦截器
//使用方式 request.   和原生axios.没区别
// 可以自请求发送前对请求做一些处理
// 比如统一加token,对请求参数统一加密
request.interceptors.request.use(config => {
    config.headers['Content-Type'] = 'application/json;charset=utf-8';


    // 判断是否是登录页面的URL,如果是,则不进行Token检查
    if (config.url === '/login') {
        return config;
    }

    /**
     * 设置权限验真用于后期登录
     */
    const token = localStorage.getItem('token');



    // 如果token存在,则在请求头中添加Authorization字段
    if (!token) {
        console.log("拦截器没有收到token");
    }
    else
        config.headers.Authorization = token;
    return config
}, error => {
    return Promise.reject(error)
});

// response 拦截器
// 可以在接口响应后统一处理结果
request.interceptors.response.use(
    response => {
        let res = response.data; //这里数据进行了处理 res就单指数据
        // 如果是返回的文件
        if (response.config.responseType === 'blob') {
            return res
        }
        if (response.status === 401){
            router.push('/');
            console.log("请求权限错误")
        }
        if (response.status === 408){
            alert('登录过期')
            router.push('/');
            console.log("登录过期错误")
        }
        if (response.status!== 200 ){
                console.log("请求错误")
            console.log(response.errorMessage)
        }
        // 兼容服务端返回的字符串数据
        if (typeof res === 'string') {
            try {
                res = res ? JSON.parse(res) : res;
            } catch (error) {
                console.error('Error parsing JSON:', error);
            }
        }

        return res;
    },
     error => {
         if (error.response) {
             const status = error.response.status;
             if (status === 401) {
                 alert('请登录后操作')
                 localStorage.clear();
                 router.push('/');
             } else if (status === 408){
                 alert('登录过期重新登录')
                 localStorage.clear();//删除过期的token
                 console.error('登录过期重新登录')
                 router.push('/');

             }else {
                 console.error(`请求错误,状态码:${status}`, error.message);
             }
         } else {
             console.error('请求失败,未收到响应:', error.message);
         }

         // 返回一个 Promise.reject(),确保错误继续向外传递
         return Promise.reject(error);
     }
)


export default request



拦截器实现的大体逻辑
除开采用拦截器意外,还可以使用vue的全局路由守卫


import Vue from 'vue'
import './plugins/axios'
import App from './App.vue'
import router from './router'
import store from './store'
//引用element ui
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import './plugins/element.js'
import './assets/global.css'
import request from "@/utils/request";
//不会产生生产提示
Vue.config.productionTip = false
//使用组件
Vue.use(ElementUI, { size: "mini" });
//全局引用 这样可以直接使用 this.request完成引用
Vue.prototype.request=request


router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token');

  // 如果token不存在且当前路径不是/login,则重定向到登录页面
  if (!token && to.path !== '/login') { //否则一定跳转
    next("/login");
    return; // 重要: 在这里添加 return,确保不会继续执行下面的 next()
  }

  // 如果token存在或已经在登录页面,则继续导航
  next();
});

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')  //实列化挂载

了解Springsecurity实现

这个案列使用的是在Springsecurity中的jwt完成验证
login接口

@PostMapping("/login")
public ResponseResult login(@RequestBody User user){
    if(!StringUtils.hasText(user.getUserName())){
        //提示 必须要传用户名
        throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
    }
    return blogLoginService.login(user);
}

对应实现

@Override
public ResponseResult login(User user) {
    //生成一个包含用户名密码的token
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
    Authentication authenticate = authenticationManager.authenticate(authenticationToken);//传入认证信息管理器 返回认证信息
    //判断是否认证通过1
    if(Objects.isNull(authenticate)){
        throw new RuntimeException("用户名或密码错误");
    }
    //获取userid 生成token
    LoginUser loginUser = (LoginUser) authenticate.getPrincipal();//login user 实现了useservice 使其不是使用内存的账户 密码
    String userId = loginUser.getUser().getId().toString();
    //用userid生成的token不建议
    String jwt = JwtUtil.createJWT(userId);
    //把用户信息存入redis
    redisCache.setCacheObject("bloglogin:"+userId,loginUser);

    //把token和userinfo封装 返回
    //把User转换成UserInfoVo
    UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);
    BlogUserLoginVo vo = new BlogUserLoginVo(jwt,userInfoVo);
    return ResponseResult.okResult(vo);
}

校验逻辑

登录后用户信息生成token,返回给前端,让前端携带token,后端则是使用该token生成前的值生成建K,用户信息作为V存入redis,校验的时候用于过滤请求

jwt过滤器


@Component
public class   JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取请求头中的token
        String token = request.getHeader("token");
        if(!StringUtils.hasText(token)){
            //说明该接口登录状态  直接放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析获取userid
        Claims claims = null;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (Exception e) {
            e.printStackTrace();
            //token超时  token非法
            //响应告诉前端需要重新登录
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }
        String userId = claims.getSubject();
        //从redis中获取用户信息
        LoginUser loginUser = redisCache.getCacheObject("bloglogin:" + userId);
        //如果获取不到
        if(Objects.isNull(loginUser)){
            //说明登录过期  提示重新登录
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }
        //存入SecurityContextHolder
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(request, response);
    }

前端页面访问需要登录状态的页面时候也同上,带着token发送请求交给过滤器经行解析;如果能查到用户数据说明登录状态,否则跳到登录页面

PS:这个案列有用到了springsecurity 所以security需要详细配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;
    @Autowired
    AccessDeniedHandler accessDeniedHandler;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许  匿名访问(登录状态后不能访问)
                .antMatchers("/login").anonymous()
                //注销接口需要认证才能访问
                .antMatchers("/logout").authenticated()
                .antMatchers("/user/userInfo").authenticated()
//                .antMatchers("/upload").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();//登不登陆都可以访问

        //配置异常处理器
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);
        //关闭默认的注销功能
        http.logout().disable();
        //把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
        //过滤器铁链执行前执行
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //允许跨域
        http.cors();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
        /**
         * matches("用户输入明文","数据的密文");//对比数据库输入明文,和数据库加密文
         *encode()//对明文加密
         * 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。
         */
    }
}

关键代码

  //过滤器铁链执行前执行
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

分布式中的常见登陆逻辑

上面我们讨论的是一般单体项目的前后端登陆验证,如果我们的业务范围扩大呢,比如分布式微服务

那么前端需要nginx负载均衡,后端就要通过网关进行路由匹配来进行验证登陆了
gate-way配置文件 主要是负责服务发现nacos
bootstrap

server:
  port: 51601
spring:
  application:
    name: gateway
  cloud:

    nacos:
      discovery:
        server-addr: 192.168.249.132:8848
      config:
        server-addr: 192.168.249.132:8848
        file-extension: yml

网关在nacos的具体配置

spring:
  cloud:
    gateway:
      globalcors:
        add-to-simple-url-handler-mapping: true 
        # 将全局CORS配置添加到SimpleUrlHandlerMapping
        corsConfigurations:
          '[/**]':
            allowedHeaders: "*" 
            # 允许所有请求头
            allowedOrigins: "*" 
            # 允许所有来源
            allowedMethods:
              - GET
              - POST
              - DELETE
              - PUT
              - OPTION
               # 允许的HTTP请求方法,包括OPTIONS(通常应该是OPTIONS而不是OPTION)
      routes:
        # 平台管理
        - id: user
          uri: lb://leadnews-user 
          # 将匹配 /user/** 的请求路由到名为 "user" 的服务  
          predicates:
            - Path=/user/** 
            # 匹配路径是以 /user/ 开头的请求
          filters:
            - StripPrefix=1 
            # 去掉请求路径中的一个前缀(通常是去掉服务名的前缀) filters 下的 - StripPrefix=1: 当请求匹配到这个路由规则时,会执行此处定义的过滤器。StripPrefix=1 表示去掉请求路径中的一个前缀。在这个场景下,如果请求路径是 /user/example,经过这个过滤器后,会将 /user 去掉,转发到后端服务的路径就变成了 /example。
             # 文章微服务
        - id: article
          uri: lb://leadnews-article
          predicates:
            - Path=/article/**
          filters:
            - StripPrefix= 1
#搜索微服务
        - id: leadnews-search
          uri: lb://leadnews-search
          predicates:
            - Path=/search/**
          filters:
            - StripPrefix= 1

微服务的登陆处理逻辑变成了网关统一入口,入口进行授权或者处理以后将重要信息放在请求头,这个请求链放给其他微服务
那么这个时候就有俩个情况,

  • 登陆验证token交给网关过滤器就好,其他单体的微服务不做验证拦截
  • 通过网关验证以后依旧做登陆拦截,只是拦截凭证是和网关进行协商,而不是客户端的token,比如我这里的微服务放行要求是请求带有网关处理过的用户id
网关 gate-way

过滤器代码

@Component
@Slf4j

public class AuthorizeFilter implements Ordered, GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        if (isLoginRequest(request)) {
      //  不对登陆请求做处理
            return chain.filter(exchange);
        }

        String token = request.getHeaders().getFirst("token");

        if (StringUtils.isBlank(token)) {
            return handleUnauthorized(response);
        }

        try {
        //解析token
            Claims claimsBody = AppJwtUtil.getClaimsBody(token);
            int validationResult = AppJwtUtil.verifyToken(claimsBody);

            if (validationResult == 1 || validationResult == 2) {
            //用户权限校验
                return handleUnauthorized(response);
            }

            addUserIdToHeader(exchange, claimsBody);

        } catch (Exception e) {
            log.error("处理身份验证时发生错误", e);
            return handleUnauthorized(response);
        }

        return chain.filter(exchange);
    }
//判断是都登陆的请求
    private boolean isLoginRequest(ServerHttpRequest request) {
        return request.getURI().getPath().contains("/login");
    }

    private Mono<Void> handleUnauthorized(ServerHttpResponse response) {
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        return response.setComplete();
    }
//添加id 到请求头
    private void addUserIdToHeader(ServerWebExchange exchange, Claims claimsBody) {
        Object userId = claimsBody.get("id");
        exchange = exchange.mutate().request(exchange.getRequest().mutate().header("userId", userId.toString()).build()).build();
    }

    @Override
    public int getOrder() {
        return 0;
    }
}
单体微服务

拦截器,我的要求是判断是否请求头带有网关处理的用户id

public class AuthInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//        获取请求token,保存到当前线程
        String userid = request.getHeader("userId");
        if (userid != null){
            ApUser apUser = new ApUser();
            apUser.setId(Integer.valueOf(userid));
            //因为只在线程保存了id,其他信息不需要建议这步骤放在redis

            ThreadUserLocalUtil.setThreadLocal(apUser);
            return true;

        }
        else
            System.out.println("User not found");
        return true;

    }


//    处理玩请求进行销毁,避免线程堵塞
//    @Override
//    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//    ThreadUserLocalUtil.clear();
//    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        ThreadUserLocalUtil.clear();
    }
}

主要目的是判断是否登陆有凭证,并且保存到当前线程,随取随用
注册拦截器

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
     registry.addInterceptor(new AuthInterceptor());
    }
}
  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Guns-Separation是Guns后台管理系统的前后端分离版本,项目采用前后端分离架构,代码简洁,功能丰富,开箱即用,开创快速开发平台新趋势。 Guns-Separation功能介绍: 1、主控面板:控制台页面,可进行工作台,分析页,统计等功能的展示。 2、用户管理:对企业用户和系统管理员用户的维护,可绑定用户职务,机构,角色,数据权限等。 3、应用管理:通过应用来控制不同维度的菜单展示。 4、机构管理:公司组织架构维护,支持多层级结构的树形结构。 5、职位管理:用户职务管理,职务可作为用户的一个标签,职务目前没有和权限等其他功能挂钩。 6、菜单管理:菜单目录,菜单,和按钮的维护是权限控制的基本单位。 7、角色管理:角色绑定菜单后,可限制相关角色的人员登录系统的功能范围。角色也可以绑定数据授权范围。 8、字典管理:系统内各种枚举类型的维护。 9、访问日志:用户的登录和退出日志的查看和管理。 10、操作日志:用户的操作业务的日志的查看和管理。 11、服务监控:服务器的运行状态Java虚拟机信息,jvm等数据的查看。 12、在线用户:当前系统在线用户的查看。 13、数据监控:druid控制台功能,可查看sql的运行信息。 14、公告管理:系统的公告的管理。 15、文件管理:文件的上传下载查看等操作,文件可使用本地存储,阿里云oss,腾讯cos接入,支持拓展。 16、定时任务:定时任务的维护,通过cron表达式控制任务的执行频率。 17、系统配置:系统运行的参数的维护,参数的配置与系统运行机制息息相关。 18、邮件发送:发送邮件功能。 19、短信发送:短信发送功能,可使用阿里云sms,腾讯云sms,支持拓展。 Guns-Separation快速开始 准备以下环境: 1、npm,jdk1.8,maven 3.6或以上版本。 2、需要准备一个mysql 5.7数据库。 3、您的IDE需要安装lombok插件。 前端运行: 1、cd _web/ 2、npm install 3、npm run serve 后端运行: 1、将数据库文件_sql/guns-separation.sql导入到数据库 2、修改guns-main/src/main/resources/application-local.yml文件,修改为您的数据库ip,账号和密码 3、运行guns-main/src/main/java/cn/stylefeng/guns/GunsApplication类,即可启动后端程序 框架优势: 1、模块化架构设计,层次清晰,业务层推荐写到单独模块,方便升级。 2、前后端分离架构,分离开发,分离部署,前后端互不影响。 3、前端技术采用vue + antdv + axios。 4、后端采用spring boot + mybatis-plus + hutool等,开源可靠。 5、基于spring security(jwt) + 用户UUID双重认证。 6、基于AOP实现的接口粒度的鉴权,最细粒度过滤权限资源。 7、基于hibernate validator实现的校验框架,支持自定义校验注解。 8、提供Request-No的响应header快速定位线上异常问题。 9、在线用户可查,可在线踢人,同账号登录可同时在线,可单独在线(通过系统参数配置)。 10、支持前端 + 后端在线代码生成(后续开放)。 11、支持jenkins一键部署,另自带docker maven插件,支持docker部署。 12、文件,短信,缓存,邮件等,利用接口封装,方便拓展。 13、文件默认使用本地文件,短信默认使用阿里云sms,缓存默认使用内存缓存。 14、文档齐全,持续更新,视频教程将发布到Bilibili(后续开放)。 演示账号密码:superAdmin/123456 Guns-Separation v1.1更新内容: 1、增加上传图片的预览功能 2、完善数据范围分配时候的判断逻辑 3、授权数据取消父级子级关联 4、【前端】工作台界面使用静态数据、环境显示抽屉默认设置为全显示 5、统一日志打印格式 6、修复邮件发送异常的问题 7、修复菜单遍历没有修改子应用的问题 8、默认去掉oss,cos,短信的依赖包,减少了默认打包体积 9、【pr合并】修改密码加密方式为bcrypt 10、修复定位bug
实现微信扫码登录前后端分离流程如下: 1. 前端生成登录二维码:前端页面加载时,向后端发送请求获取登录二维码的参数信息,包括appid和redirect_uri等,后端根据这些参数生成登录二维码的URL,并返回给前端。 2. 前端展示二维码:前端使用第三方库(如qrcode.js)将生成的登录二维码展示给用户。 3. 用户扫码确认登录:用户使用微信扫描前端展示的二维码,微信客户端会将用户的微信账号与该二维码关联,并向后端发送确认登录的请求。 4. 后端验证登录状态后端接收到微信客户端发送的确认登录请求后,根据请求中的参数进行验证,包括校验appid、redirect_uri、code等信息的有效性。 5. 后端获取用户信息:验证通过后,后端使用code参数向微信服务器发送请求,获取用户的access_token和openid等信息。 6. 后端生成登录凭证:后端根据获取到的用户信息生成自己的登录凭证(如JWT),并将该凭证返回给前端。 7. 前端保存登录状态前端接收到后端返回的登录凭证后,可以将该凭证保存在本地(如localStorage或cookie)用于后续的请求验证和会话管理。 8. 后续请求的验证:前后端分离后,后续的请求需要在请求头中携带登录凭证进行验证,后端根据凭证的有效性判断用户的登录状态。 这就是前后端分离实现微信扫码登录的大致流程,通过这种方式可以实现用户使用微信账号进行快速登录和注册。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

蓝胖子不是胖子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值