記錄一個 SpringSecurity 和 x-auth-token 一直登入失敗的排查過程

LYX6666發表於2023-02-14

前言

近期要跑一個關於微信登入的Demo(使用 Spring+Angular),為了省事,打算從已有的專案上覆制貼上。我進行了以下操作:

第一步:初始化專案,貼上基本功能,如簡單的頁面、實體、必要的服務和控制器等,如果出現依賴,則視情況貼上依賴或刪掉程式碼
第二步:貼上登入功能,當前這個專案用的和之前學的已經不一樣了,使用的是 SpringSecurity、SpringSession 和x-auth-token,所以第一反應就是貼上所有的過濾器和攔截器,登入功能可能會正常執行
第三步:實現 Demo 的微信登入的功能

然後由於某種問題,在第二步出現了密碼登入失敗的情況,具體表現在:

①輸入正確的使用者名稱密碼後,網頁提示登入失敗

image.png

②控制檯network提示401,沒有響應的 body,console 提示登入失敗

image.png

image.png

image.png

③ 後端的控制檯沒有任何輸出

image.png

④ 如果在 login 方法上打斷點,這個斷點並不會被觸發

image.png
到這裡沒有發現有用資訊,似乎無從下手

我找到原來正常的專案,在 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<HttpMessageConverter<?>> 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<User> 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方法斷點被擊中了,這一步就是剛才猜測的“取出使用者”的過程
image.png

但仔細看,這個方法並沒有被直接呼叫,所以顯示no usage

我回到那個有問題的專案一看,這個方法是這樣的:

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

貼上的時候一看沒有 usage 就覺得不會被呼叫,於是隨便寫,直接返回空了

把正確的方法粘過去,卻發現這個UserServiceImpl並不只是實現了UserService,還有UserDetailsService,這個類實現了兩個介面,而上面的方法是還有UserDetailsService這個介面提供的

// 定義類的語句
public class UserServiceImpl implements UserService, UserDetailsService {
    ....
}

而這個 UserDetailService 也不出意料的是一個內部類:

image.png

所以才會出現 usage 是0,但它實實在在的被呼叫了的情況,我們可以繼續分析,這個方法是取出使用者的,如果 return 是 null,那麼無論輸入什麼使用者名稱密碼,都會密碼錯誤,所以我們看到的“沒有任何報錯”的情況,實際上被系統認為“使用者名稱密碼輸入錯誤”的情況,系統認為這是一個正常的情況

至於為什麼密碼不正確返回的401沒有 response 資訊?觀察程式碼會發現是攔截器對401進行了處理,阻止401導致的報錯

/**
 * 在非cors的情況下,阻止瀏覽器在接收到401時彈窗
 */
export class Prevent401Popup implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    let headers = req.headers;
    headers = headers.append('X-Requested-With', 'XMLHttpRequest');
    req = req.clone({headers});
    return next.handle(req);
  }
}

這樣一來總算都走的通了,登入也正常了

image.png

由於第一次出現這種沒有錯誤資訊的情況,因此在此文中把排查步驟記錄了下來。

總結和反思

反思:

①為什麼沒有問?因為疑難雜症的 debug 太麻煩,而且別人不知道自己埋的坑在哪,所以選擇了自己排查
②為什麼沒有查?因為問題無從下手,不知道如何組織語言去 goooogle

總結:

①不同於手寫程式碼,框架有它自己的呼叫方式,並且屬於“我寫了它就調,沒寫也不報錯”的情況,換言之,如果我的程式碼實現了框架的介面、或者繼承了框架的內部類、或者加了對應的註解,它就能正常執行
②被內部類呼叫的方法不會有顯示呼叫的提示(即usage 為0),但不能忽視
③在我們對專案的細節什麼都不知道的情況下,如果想排查問題,可以使用大量打斷點的方式,從表面上弄明白執行順序
④如果想深入理解執行邏輯,最好的辦法還是系統的學習框架
⑤ debug 的時候必須同時觀察 前端的頁面、瀏覽器的 network、瀏覽器的 console、後端的 console、後端的斷點,才能更好的發現問題
⑥透過這次打斷點的操作,對於程式碼的執行過程有了更多的理解

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

相關文章