昨天,我們對登入功能列出了幾個場景進行測試,大部分的情況下我們如預期地收到了想要的回應:正常登入的情形下收到 JWT Token,與格式錯誤的情形下收到 400 Bad Request。
但透過 DaoAuthenticationProvider
為我們進行身分驗證失敗的案例(使用者不存在、密碼錯誤),收到的回應卻不如預期,錯誤訊息也不算明確。針對這個情況我們昨天提出了解決方案:「加入 AuthenticationEntryPoint
」
今天預計針對昨天提到的 AuthenticationEntryPoint
進行簡單說明,說明後將其加入 SecurityFilterChain
,最後驗證這個做法是否能解決我們的問題。
AuthenticationEntryPoint
介面顧名思義是一種認證入口點,根據官方文件的說明,這個介面其中僅有一個方法commence
。當認證過程中遇到異常,拋出 AuthenticationException
相關的 Exception(UsernameNotFoundException
就是其子類),ExceptionTranslationFilter
會調用此方法來接管驗證異常後續的處理,也就是說,我們想要管理驗證異常後的回應,可以透過自行實作這個介面並在commence
方法中定義我們希望的回應。
為了了解整個背後的整個過程,我們可以稍微看一下ExceptionTranslationFilter
的原始碼:
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);
}
...
此時的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);
}
}
因為本例拋出的 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);
}
sendStartAuthentication
中,最後呼叫了authenticationEntryPoint
的commence
方法,呼叫方法前設定上下文、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),也就是昨天錯誤訊息中曾出現的認證入口點!
了解了驗證異常背後的機制以後,我們可以試著指定 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();
}
設定好 AuthEntryPoint 後,我們可以再次對昨天不符合預期的案例進行測試。
Request Body :
{
"email": "test@example.com",
"password": "wrong-password"
}
預期結果:
實際結果:
符合預期,我們收到了 401 Unauthorized!
功能測試
Request Body :
{
"email": "userNotExist@example.com",
"password": "password123"
}
預期結果:
實際結果:
符合預期,我們收到了 401 Unauthorized!
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,並模擬使用者不存在的情況,回應如下:
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
,跟著異常堆疊,我們也看到在 AbstractUserDetailsAuthenticationProvider
的authenticate
方法中,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 以及更完整錯誤訊息。