记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程

前言

近期要跑一个关于微信登录的Demo(使用 Spring+Angular),为了省事,打算从已有的项目上复制粘贴。我进行了以下操作:

第一步:初始化项目,粘贴基本功能,如简单的页面、实体、必要的服务和控制器等,如果出现依赖,则视情况粘贴依赖或删掉代码
第二步:粘贴登录功能,当前这个项目用的和之前学的已经不一样了,使用的是 SpringSecurity、SpringSession 和x-auth-token,所以第一反应就是粘贴所有的过滤器和拦截器,登录功能可能会正常运行
第三步:实现 Demo 的微信登录的功能

然后由于某种问题,在第二步出现了密码登录失败的情况,具体表现在:

①输入正确的用户名密码后,网页提示登录失败

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第1张图片

②控制台network提示401,没有响应的 body,console 提示登录失败

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第2张图片

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第3张图片

image.png

③ 后端的控制台没有任何输出

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第4张图片

④ 如果在 login 方法上打断点,这个断点并不会被触发

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第5张图片
到这里没有发现有用信息,似乎无从下手

我找到原来正常的项目,在 login 方法上打断点发现:

⑤输入正确密码的情况下,后端 C层 login 会被触发,否则不会

于是推出:密码的正确性应该是过滤器来校验,而不是用 login 方法校验,login 只负责校验成功后更新登录信息

排查

那么问题可能出在这些过滤器上,于是在后端尽可能打断点来排查

既然鉴权功能一般是过滤器和拦截器来实现的,于是排查方法就是把所有处理请求的方法打上断点

打完断点重启项目,就可以看到真正的执行顺序,分为两个阶段

一是后端启动时进行的一系列初始化,二是发起请求时的处理过程

先来看启动时是如何初始化的(省去了微信相关的步骤):

首先是加载主类

public static void main(String[] args) {
    SpringApplication.run(WebSoctetAndStompStudyApplication.class, args);
}

过滤器执行构造函数(不止一个过滤器)

  public WechatAuthFilter(WxMaService wxMaService, UserRepository userRepository) {
    this.wxMaService = wxMaService;
    this.userRepository = userRepository;
  }

校验工具执行构造函数

  public SuperPasswordBCryptPasswordEncoder(OneTimePassword oneTimePassword) {
    super();
    this.oneTimePassword = oneTimePassword;
  }

header 处理

  /**
   * 使用header认证来替换默认的cookie认证
   */
  @Bean
  public HttpSessionStrategy httpSessionStrategy() {
    return new HeaderAndParamHttpSessionStrategy();
  }

密码工具

  @Bean
  PasswordEncoder passwordEncoder() {
    return this.passwordEncoder;
  }

SpringSecurity 设置路由

 /**
   * 设置开放权限的路由
   * https://spring.io/guides/gs/securing-web/
   *
   * @param http http安全
   * @throws Exception 异常
   */
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            // 开放端口
            .antMatchers("/h2-console/**").permitAll()
            .antMatchers("/Data").permitAll()
            .antMatchers("/user/resetPassword").permitAll()
            .antMatchers("/user/getLoginQrCode/**").permitAll()
            .antMatchers("/wechat/**").permitAll()
            .antMatchers("/websocket/**").permitAll()
            .antMatchers("/user/sendVerificationCode", "/favicon.ico").permitAll()
            .anyRequest().authenticated()
            .and()
            // 添加通过header获取host信息的过滤器
            // 过滤器执行链请参考:https://docs.spring.io/spring-security/site/docs/5.5.1/reference/html5/#servlet-security-filters
            .addFilterBefore(this.headerRequestHostFilter, BasicAuthenticationFilter.class)
            // 添加微信认证过滤器
            .addFilterBefore(this.wechatAuthFilter, BasicAuthenticationFilter.class)
            .httpBasic()
            .and().cors()
            .and().csrf().disable();
    http.headers().frameOptions().disable();
  }

url处理

  /**
   * URL忽略大小写
   *
   * @param configurer 配置信息
   */
  @Override
  public void configurePathMatch(final PathMatchConfigurer configurer) {
    final AntPathMatcher pathMatcher = new AntPathMatcher();
    pathMatcher.setCaseSensitive(false);
    configurer.setPathMatcher(pathMatcher);
  }

jsonview

  /**
   * 配置JsonView
   */
  @Override
  public void configureMessageConverters(final List> converters) {
    final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().defaultViewInclusion(true).build();
    converters.add(new MappingJackson2HttpMessageConverter(mapper));
  }

以上大概是项目启动时执行的流程,不用细看。

然后是前端发起请求的时的流程

我找到那个运行正常的生产项目,正常情况下应该是:

①获取 session 数据

  @Override
  public String getRequestedSessionId(HttpServletRequest request) {
    String token = request.getHeader(this.headerName);
    return (token != null && !token.isEmpty()) ? token : request.getParameter(this.headerName);
  }

② header 过滤器

    public HostHeaderHttpServletRequest(HttpServletRequest request) {
      super(request);
      this.request = request;
    }

③微信过滤器

@Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    final boolean debug = this.logger.isDebugEnabled();

    String code = request.getHeader(this.codeKey);
    if (code != null) {
      try {
        WxMaJscode2SessionResult wxMaJscode2SessionResult = wxMaService.getUserService().getSessionInfo(code);
        String openid = wxMaJscode2SessionResult.getOpenid();
        Optional optionalUser = userRepository.findByOpenid(openid);
        WeChatUser wechatUser = new WeChatUser(new User(), wxMaJscode2SessionResult.getSessionKey(), wxMaJscode2SessionResult.getOpenid());
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken;
        if (optionalUser.isPresent()) {
          wechatUser.setUser(optionalUser.get());
        }
        // 设置认证用户:微信用户、安全令牌设置为openid、认证权限为空(后期可变更为正确的微信权限名称)
        usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
            wechatUser,
            openid,
            wechatUser.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
      } catch (WxErrorException exception) {
        this.logger.warn("虽然接收到了code,但是没有通过code换取有效的微信数据: " + exception.getMessage());
        exception.printStackTrace();
      }
    }

    filterChain.doFilter(request, response);
  }

④ 比对用户名密码

  @Override
  public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (rawPassword == null) {
      throw new IllegalArgumentException("rawPassword cannot be null");
    }

    if (oneTimePassword.matches(rawPassword, encodedPassword)) {
      return true;
    }

    return super.matches(rawPassword, encodedPassword);
  }

  @Override
  public boolean matches(CharSequence rawPassword, String encodedPassword) {
    // 增加微信扫码后使用webSocket uuid充当用户名与密码进行认证
    if (this.userService.checkWeChatLoginUuidIsValid(rawPassword.toString())) {
      if (this.logger.isDebugEnabled()) {
        this.logger.info("校验微信扫码登录成功");
      }
      return true;
    }

    // 当有一次性密码(每个密码仅能用一次)且未使用时,验证用户是否输入了超密
    Optional oneTimePassword = this.getPassword();
    return oneTimePassword.isPresent() && oneTimePassword.get().equals(rawPassword.toString());
  }

⑤ 密码比对正确后,拦截器放行,访问到controller

  @RequestMapping("login")
  @JsonView(LoginJsonView.class)
  public User login(Principal user) {
    return this.userService.getByUsername(user.getName());
  }

⑥调用Service,return,登录成功

但在有问题的Demo 中,我遇到的情况是,在第三步(微信过滤器)执行之后就直接返回了,没有密码比对的过程,C 层 login 的断点始终不会被触发,后端的控制台也没有任何报错,只有网页上显示登录失败

然后我又检查了一遍所有过滤器和配置文件,均没有发现问题

用户

正当不知道怎么办的时候,我发现遗漏了一个地方:过滤器和拦截器怎么能取出用户名和密码呢?必然不能直接取出吧?肯定是调用了service 来取出的,但 UserService 是能通过编译的,说明没有问题,当时感觉很费解,然后找到那个正常的项目,把UserService所有方法打上断点

果然有新的发现,在③微信过滤器处理和④比对密码之间,UserService的 loadUserByUsername方法断点被击中了,这一步就是刚才猜测的“取出用户”的过程
记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第6张图片

但仔细看,这个方法并没有被直接调用,所以显示no usage

我回到那个有问题的项目一看,这个方法是这样的:

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return null;
    }

粘贴的时候一看没有 usage 就觉得不会被调用,于是随便写,直接返回空了

把正确的方法粘过去,却发现这个UserServiceImpl并不只是实现了UserService,还有UserDetailsService,这个类实现了两个接口,而上面的方法是还有UserDetailsService这个接口提供的

// 定义类的语句
public class UserServiceImpl implements UserService, UserDetailsService {
    ....
}

而这个 UserDetailService 也不出意料的是一个内部类:

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第7张图片

所以才会出现 usage 是0,但它实实在在的被调用了的情况,我们可以继续分析,这个方法是取出用户的,如果 return 是 null,那么无论输入什么用户名密码,都会密码错误,所以我们看到的“没有任何报错”的情况,实际上被系统认为“用户名密码输入错误”的情况,系统认为这是一个正常的情况

至于为什么密码不正确返回的401没有 response 信息?观察代码会发现是拦截器对401进行了处理,阻止401导致的报错

/**
 * 在非cors的情况下,阻止浏览器在接收到401时弹窗
 */
export class Prevent401Popup implements HttpInterceptor {
  intercept(req: HttpRequest, next: HttpHandler): Observable> {
    let headers = req.headers;
    headers = headers.append('X-Requested-With', 'XMLHttpRequest');
    req = req.clone({headers});
    return next.handle(req);
  }
}

这样一来总算都走的通了,登录也正常了

记录一个 SpringSecurity 和 x-auth-token 一直登录失败的排查过程_第8张图片

由于第一次出现这种没有错误信息的情况,因此在此文中把排查步骤记录了下来。

总结和反思

反思:

①为什么没有问?因为疑难杂症的 debug 太麻烦,而且别人不知道自己埋的坑在哪,所以选择了自己排查
②为什么没有查?因为问题无从下手,不知道如何组织语言去 goooogle

总结:

①不同于手写代码,框架有它自己的调用方式,并且属于“我写了它就调,没写也不报错”的情况,换言之,如果我的代码实现了框架的接口、或者继承了框架的内部类、或者加了对应的注解,它就能正常执行
②被内部类调用的方法不会有显示调用的提示(即usage 为0),但不能忽视
③在我们对项目的细节什么都不知道的情况下,如果想排查问题,可以使用大量打断点的方式,从表面上弄明白执行顺序
④如果想深入理解执行逻辑,最好的办法还是系统的学习框架
⑤ debug 的时候必须同时观察 前端的页面、浏览器的 network、浏览器的 console、后端的 console、后端的断点,才能更好的发现问题
⑥通过这次打断点的操作,对于代码的执行过程有了更多的理解

demo 地址:https://github.com/liuyuxuan6...

你可能感兴趣的:(spring)