iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 16

Day16:Auth Service - AuthEntryPoint

  • 分享至 

  • xImage
  •  

昨天,我們對登入功能列出了幾個場景進行測試,大部分的情況下我們如預期地收到了想要的回應:正常登入的情形下收到 JWT Token,與格式錯誤的情形下收到 400 Bad Request。

但透過 DaoAuthenticationProvider 為我們進行身分驗證失敗的案例(使用者不存在、密碼錯誤),收到的回應卻不如預期,錯誤訊息也不算明確。針對這個情況我們昨天提出了解決方案:「加入 AuthenticationEntryPoint

今天預計針對昨天提到的 AuthenticationEntryPoint 進行簡單說明,說明後將其加入 SecurityFilterChain ,最後驗證這個做法是否能解決我們的問題。

什麼是 AuthenticationEntryPoint

AuthenticationEntryPoint 介面顧名思義是一種認證入口點,根據官方文件的說明,這個介面其中僅有一個方法commence 。當認證過程中遇到異常,拋出 AuthenticationException 相關的 Exception(UsernameNotFoundException就是其子類),ExceptionTranslationFilter 會調用此方法來接管驗證異常後續的處理,也就是說,我們想要管理驗證異常後的回應,可以透過自行實作這個介面並在commence 方法中定義我們希望的回應。

為了了解整個背後的整個過程,我們可以稍微看一下ExceptionTranslationFilter 的原始碼:

  1. ExceptionTranslationFilter 執行doFilter方法時捕捉到了AuthenticationException

    // doFilter
    ...
    catch (Exception var8) {
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var8);
        RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
        if (securityException == null) {
            securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
        }
    
        if (securityException == null) {
            this.rethrow(var8);
        }
    
        if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", var8);
        }
    
        // 執行這個方法
        this.handleSpringSecurityException(request, response, chain, securityException);
    }
    ...
    
  2. 此時的securityException 不為空,因此會執行handleSpringSecurityException,其實作內容如下:

    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
        if (exception instanceof AuthenticationException) {
            // 執行這個方法
            this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
        } else if (exception instanceof AccessDeniedException) {
            this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
        }
    }
    
  3. 因為本例拋出的 Exception 屬於AuthenticationException,因此執行handleAuthenticationException,其實作內容如下:

    private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
        this.logger.trace("Sending to authentication entry point since authentication failed", exception);
        // 執行這個方法
        this.sendStartAuthentication(request, response, chain, exception);
    }
    
  4. sendStartAuthentication中,最後呼叫了authenticationEntryPointcommence方法,呼叫方法前設定上下文、cache的做法跟昨天的 log 訊息也符合:

    protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        this.securityContextHolderStrategy.setContext(context);
        this.requestCache.saveRequest(request, response);
        this.authenticationEntryPoint.commence(request, response, reason);
    }
    

這時候的我們疑問可能是,預設的 AuthenticationEntryPoint 是什麼?

事實上除了自行實作 AuthenticationEntryPoint 外,Spring 還有提供幾個不同情況下適用的實作,並且在 ExceptionHandlingConfigurer 的文件 有提到,如果我們沒有手動指定驗證入口點,那麼預設就會是 Http403ForbiddenEntryPoint(顯然他的 HTTP Status Code 回傳的是 403 Forbidden),也就是昨天錯誤訊息中曾出現的認證入口點!

SecurityFilterChain:加入 HttpStatusEntryPoint

了解了驗證異常背後的機制以後,我們可以試著指定 AuthenticationEntryPoint,看看能否解決這個問題。如果只是希望在驗證失敗時收到 401 Unatuthorized,我們可以先使用 Spring 提供的實作 HttpStatusEntryPoint。 簡單解決這個問題。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .csrf(csrf -> csrf.disable())
            **.exceptionHandling(exception -> exception.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))**
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(HttpMethod.POST,"/users/**").permitAll()
                    .anyRequest().authenticated()
            );

    return http.build();
}
  1. 在 HttpSecurity 的設定中,透過 .exceptionHandling() 方法來自訂例外處理的行為。
  2. 接著,呼叫 .authenticationEntryPoint() 來指定處理「認證失敗」的入口點。
  3. HttpStatusEntryPoint 是一個很單純的實作,作用就是回傳一個指定的 HTTP 狀態碼,所以在建立 HttpStatusEntryPoint 時,我們傳入 HttpStatus.UNAUTHORIZED,明確地告訴 Spring Security 在認證失敗時應回傳 401 Unauthorized。

再次測試

設定好 AuthEntryPoint 後,我們可以再次對昨天不符合預期的案例進行測試。

案例2:密碼錯誤

  • Request Body :

    {
    	"email": "test@example.com",
    	"password": "wrong-password"
    }
    
    
  • 預期結果

    • HTTP Status Code:401 Unauthorized
  • 實際結果:

    符合預期,我們收到了 401 Unauthorized!

https://ithelp.ithome.com.tw/upload/images/20250930/20178099bICOaCPy4g.png

案例3:使用者不存在

功能測試

  • Request Body :

    {
    	"email": "userNotExist@example.com",
    	"password": "password123"
    }
    
    
  • 預期結果

    • HTTP Status Code:401 Unauthorized
  • 實際結果:

    符合預期,我們收到了 401 Unauthorized!

https://ithelp.ithome.com.tw/upload/images/20250930/201780996dRvA5Y7Rt.png

自訂 AuthenticationEntryPoint

雖然到目前為止我們已經蠻清楚整個認證異常背後的流程如何進行,但不免還是想親眼看看拋出的錯誤是不是像文件說的一樣為 BadCredentialsException ,也很好奇為什麼使用者不存在拋出的不是我們在 UserDetailsServiceImpl 指定的 UsernameNotFoundException ,所以我們還是嘗試著自訂 AuthenticationEntryPoint 來追蹤一下詳細內容,順便感受一下跟使用 HttpStatusEntryPoint 的差別。

新增一個 AuthEntryPoint的類別,實作 AuthenticationEntryPoint 介面,實作細節如下:

@Component // 註冊為 Spring Bean
public class AuthEntryPoint implements AuthenticationEntryPoint {

    private static final Logger logger = LoggerFactory.getLogger(AuthEntryPoint.class);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {

		// 打印錯誤訊息與異常堆疊追蹤
        logger.error("Unauthorized error: {}", authException.getMessage());
        authException.printStackTrace(System.err);

        // 設置 HTTP 狀態碼為 401 Unauthorized
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        // 設置回應內容類型為 JSON
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        // 構建一個 JSON 格式的錯誤回應
        Map<String, Object> errorDetails = new HashMap<>();
        errorDetails.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        errorDetails.put("error", "Unauthorized");
        errorDetails.put("message", authException.getMessage());
        errorDetails.put("path", request.getServletPath());

        // 將錯誤回應寫入 HttpServletResponse 的輸出流
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), errorDetails);
    }
}

在這個自訂的入口點,我們打印出錯誤訊息與異常堆疊、指定 Http Status Code 並設定ResponseBody 資訊。將這個自訂的類別注入 SecurityConfig ,並指定這個入口點作為驗證異常的入口點:

@Autowired
private AuthEntryPoint authEntryPoint;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .csrf(csrf -> csrf.disable())
            // 新增這一行
            .exceptionHandling(exception -> exception.authenticationEntryPoint(authEntryPoint))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(HttpMethod.POST,"/users/**").permitAll()
                    .anyRequest().authenticated()
            );

    return http.build();
}
}

再打一次API,並模擬使用者不存在的情況,回應如下:

https://ithelp.ithome.com.tw/upload/images/20250930/20178099gwPc5jYCzy.png

Http Status Code如我們預期,並且 ResponseBody 也正確出現了我們的自訂訊息。

接下來看看 log 有什麼新的資訊:

DaoAuthenticationProvider    : Failed to find user 'userNotExist@example.com'
AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
HttpSessionRequestCache        : Saved request http://localhost:8081/users/login?continue to session
AuthEntryPoint       : Unauthorized error: 憑證錯誤
BadCredentialsException: 憑證錯誤

終於,看到 BadCredentialsException,跟著異常堆疊,我們也看到在 AbstractUserDetailsAuthenticationProviderauthenticate方法中,UsernameNotFoundException 被隱藏的細節:

protected boolean hideUserNotFoundExceptions = true;
...
catch (UsernameNotFoundException ex) {
                this.logger.debug("Failed to find user '" + username + "'");
                if (!this.hideUserNotFoundExceptions) {
                    throw ex;
                }

                throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }

原來是為了不暴露太多資訊,這裡將使用者不存在的資訊給隱藏了,統一回傳 BadCredentialsException


今天我們深入追查了 Spring Security 處理認證失敗的流程,理解了為何 403 Forbidden 會出現,也從原始碼中發現了框架為了安全性,而將 UsernameNotFoundException 地隱藏在 BadCredentialsException 背後的設計。

在釐清問題的根源後,我們透過指定已實作的類別 (HttpStatusEntryPoint) 或實作自訂的 AuthenticationEntryPoint,讓我們的 API 在驗證失敗時,能夠回傳我們所期望的 401 Unauthorized 以及更完整錯誤訊息。


上一篇
Day15:Auth Service - 登入功能測試與除錯
系列文
吃出一個SideProject!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言