前言
近期要跑一個關於微信登入的Demo(使用 Spring+Angular),為了省事,打算從已有的專案上覆制貼上。我進行了以下操作:
第一步:初始化專案,貼上基本功能,如簡單的頁面、實體、必要的服務和控制器等,如果出現依賴,則視情況貼上依賴或刪掉程式碼
第二步:貼上登入功能,當前這個專案用的和之前學的已經不一樣了,使用的是 SpringSecurity、SpringSession 和x-auth-token,所以第一反應就是貼上所有的過濾器和攔截器,登入功能可能會正常執行
第三步:實現 Demo 的微信登入的功能
然後由於某種問題,在第二步出現了密碼登入失敗的情況,具體表現在:
①輸入正確的使用者名稱密碼後,網頁提示登入失敗
②控制檯network提示401,沒有響應的 body,console 提示登入失敗
③ 後端的控制檯沒有任何輸出
④ 如果在 login 方法上打斷點,這個斷點並不會被觸發
到這裡沒有發現有用資訊,似乎無從下手
我找到原來正常的專案,在 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方法斷點被擊中了,這一步就是剛才猜測的“取出使用者”的過程
但仔細看,這個方法並沒有被直接呼叫,所以顯示no usage
我回到那個有問題的專案一看,這個方法是這樣的:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
貼上的時候一看沒有 usage 就覺得不會被呼叫,於是隨便寫,直接返回空了
把正確的方法粘過去,卻發現這個UserServiceImpl並不只是實現了UserService,還有UserDetailsService,這個類實現了兩個介面,而上面的方法是還有UserDetailsService這個介面提供的
// 定義類的語句
public class UserServiceImpl implements UserService, UserDetailsService {
....
}
而這個 UserDetailService 也不出意料的是一個內部類:
所以才會出現 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);
}
}
這樣一來總算都走的通了,登入也正常了
由於第一次出現這種沒有錯誤資訊的情況,因此在此文中把排查步驟記錄了下來。
總結和反思
反思:
①為什麼沒有問?因為疑難雜症的 debug 太麻煩,而且別人不知道自己埋的坑在哪,所以選擇了自己排查
②為什麼沒有查?因為問題無從下手,不知道如何組織語言去 goooogle
總結:
①不同於手寫程式碼,框架有它自己的呼叫方式,並且屬於“我寫了它就調,沒寫也不報錯”的情況,換言之,如果我的程式碼實現了框架的介面、或者繼承了框架的內部類、或者加了對應的註解,它就能正常執行
②被內部類呼叫的方法不會有顯示呼叫的提示(即usage 為0),但不能忽視
③如果想排查問題,可以使用大量打斷點的方式,從表面上弄明白執行順序
④如果想深入理解執行邏輯,最好的辦法還是系統的學習框架
⑤ debug 的時候必須同時觀察 前端的頁面、瀏覽器的 network、瀏覽器的 console、後端的 console、後端的斷點,才能更好的發現問題
⑥透過這次打斷點的操作,對於程式碼的執行過程有了更多的理解
demo 地址:https://github.com/liuyuxuan6...