Spring boot+Security OAuth2 爬坑日記(4)自定義異常處理 上
為了方便與前端更好的互動,服務端要提供友好統一的資訊返回格式,(他好我也好 ->_-> ),Spring Security OAuth2 提供了自定義異常的入口;我們需要做的就是實現對應的介面,然後將實現的類配置到對應的入口即可。預設的資訊返回格式如下:
{
"error": "invalid_grant",
"error_description": "Bad credentials"
}
需要處理的其實就兩個地方的異常資訊,分別是認證伺服器的異常資訊
和資源伺服器的異常資訊
;現在就從這兩個地方入手
認證伺服器已異常處理
自定義 ExceptionTranslator
BootOAuth2WebResponseExceptionTranslator
實現WebResponseExceptionTranslator
介面,實現其ResponseEntity<OAuth2Exception> translate(Exception e)
方法;認證發生的異常在這裡能捕獲到,在這裡我們可以將我們的異常資訊封裝成統一的格式返回即可,這裡怎麼處理因專案而異,這裡我直接複製了DefaultWebResponseExceptionTranslator
實現方法,我這裡要處理的格式如下:
{
"status":401,
"msg" :"xxxxxxxxxxxxxxxxxxxxxxxxxxxxmsg"
}
-
定義自己的
OAuth2Exception
@JsonSerialize(using = BootOAuthExceptionJacksonSerializer.class) public class BootOAuth2Exception extends OAuth2Exception { public BootOAuth2Exception(String msg, Throwable t) { super(msg, t); } public BootOAuth2Exception
-
定義異常
BootOAuth2Exception
的序列化類public class BootOAuthExceptionJacksonSerializer extends StdSerializer<BootOAuth2Exception> { protected BootOAuthExceptionJacksonSerializer() { super(BootOAuth2Exception.class); } @Override public void serialize(BootOAuth2Exception value, JsonGenerator jgen, SerializerProvider serializerProvider) throws IOException { jgen.writeStartObject(); jgen.writeObjectField("status", value.getHttpErrorCode()); String errorMessage = value.getOAuth2ErrorCode(); if (errorMessage != null) { errorMessage = HtmlUtils.htmlEscape(errorMessage); } jgen.writeStringField("msg", errorMessage); if (value.getAdditionalInformation()!=null) { for (Map.Entry<String, String> entry : value.getAdditionalInformation().entrySet()) { String key = entry.getKey(); String add = entry.getValue(); jgen.writeStringField(key, add); } } jgen.writeEndObject(); } }
-
定義自己的
WebResponseExceptionTranslator
類名為BootOAuth2WebResponseExceptionTranslator
@Component("bootWebResponseExceptionTranslator") public class BootOAuth2WebResponseExceptionTranslator implements WebResponseExceptionTranslator { private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer(); public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception { // Try to extract a SpringSecurityException from the stacktrace Throwable[] causeChain = throwableAnalyzer.determineCauseChain(e); // 異常棧獲取 OAuth2Exception 異常 Exception ase = (OAuth2Exception) throwableAnalyzer.getFirstThrowableOfType( OAuth2Exception.class, causeChain); // 異常棧中有OAuth2Exception if (ase != null) { return handleOAuth2Exception((OAuth2Exception) ase); } ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain); if (ase != null) { return handleOAuth2Exception(new UnauthorizedException(e.getMessage(), e)); } ase = (AccessDeniedException) throwableAnalyzer .getFirstThrowableOfType(AccessDeniedException.class, causeChain); if (ase instanceof AccessDeniedException) { return handleOAuth2Exception(new ForbiddenException(ase.getMessage(), ase)); } ase = (HttpRequestMethodNotSupportedException) throwableAnalyzer .getFirstThrowableOfType(HttpRequestMethodNotSupportedException.class, causeChain); if (ase instanceof HttpRequestMethodNotSupportedException) { return handleOAuth2Exception(new MethodNotAllowed(ase.getMessage(), ase)); } // 不包含上述異常則伺服器內部錯誤 return handleOAuth2Exception(new ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e)); } private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException { int status = e.getHttpErrorCode(); HttpHeaders headers = new HttpHeaders(); headers.set("Cache-Control", "no-store"); headers.set("Pragma", "no-cache"); if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) { headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary())); } BootOAuth2Exception exception = new BootOAuth2Exception(e.getMessage(),e); ResponseEntity<OAuth2Exception> response = new ResponseEntity<OAuth2Exception>(exception, headers, HttpStatus.valueOf(status)); return response; } ..........
-
將
BootOAuth2WebResponseExceptionTranslator
類加入授權伺服器的配置中@Configuration @EnableAuthorizationServer public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter{ ...... @Autowired private WebResponseExceptionTranslator bootWebResponseExceptionTranslator; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { ...... // 處理 ExceptionTranslationFilter 丟擲的異常 endpoints.exceptionTranslator(bootWebResponseExceptionTranslator); ...... } }
到這裡你以為服務端的自定義異常就結束了;然而並沒有結束,在程式碼中我的客戶端資訊每次都是放在請求頭中進行傳送,當我們的客戶端資訊不正確時服務端不會發送錯誤json資訊而是讓你重新登入,在一些app中是不能使用網頁的,所以我們定義一個自己filter來處理客戶端認證邏輯,filter如下:
@Component
public class BootBasicAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private ClientDetailsService clientDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (!request.getRequestURI().equals("/oauth/token") ||
!request.getParameter("grant_type").equals("password")) {
filterChain.doFilter(request, response);
return;
}
String[] clientDetails = this.isHasClientDetails(request);
if (clientDetails == null) {
BaseResponse bs = HttpResponse.baseResponse(HttpStatus.UNAUTHORIZED.value(), "請求中未包含客戶端資訊");
HttpUtils.writerError(bs, response);
return;
}
this.handle(request,response,clientDetails,filterChain);
}
private void handle(HttpServletRequest request, HttpServletResponse response, String[] clientDetails,FilterChain filterChain) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
filterChain.doFilter(request,response);
return;
}
BootClientDetails details = (BootClientDetails) this.clientDetailsService.loadClientByClientId(clientDetails[0]);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(details.getClientId(), details.getClientSecret(), details.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(request,response);
}
// 判斷請求頭中是否包含client資訊,不包含返回false
private String[] isHasClientDetails(HttpServletRequest request) {
String[] params = null;
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null) {
String basic = header.substring(0, 5);
if (basic.toLowerCase().contains("basic")) {
String tmp = header.substring(6);
String defaultClientDetails = new String(Base64.getDecoder().decode(tmp));
String[] clientArrays = defaultClientDetails.split(":");
if (clientArrays.length != 2) {
return params;
} else {
params = clientArrays;
}
}
}
String id = request.getParameter("client_id");
String secret = request.getParameter("client_secret");
if (header == null && id != null) {
params = new String[]{id, secret};
}
return params;
}
public ClientDetailsService getClientDetailsService() {
return clientDetailsService;
}
public void setClientDetailsService(ClientDetailsService clientDetailsService) {
this.clientDetailsService = clientDetailsService;
}
}
寫好我們的filter之後,將其配置在BasicAuthenticationFilter
之前配置如下
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
......
@Autowired
private BootBasicAuthenticationFilter filter;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
......
security.addTokenEndpointAuthenticationFilter(filter);
.......
}
}
到這裡認證伺服器的異常處理的差不多了,下面有個問題;
上述的處理流程只能捕獲ExceptionTranslationFilter
中丟擲的異常,當我在認證伺服器有如下配置時,當使用表單登入發生異常時我們置的WebResponseExceptionTranslator
是捕獲不到異常的;
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
......
@Autowired
private BootBasicAuthenticationFilter filter;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
......
// 允許表單登入
security.allowFormAuthenticationForClients();
.......
}
}
獲取token時需要客戶端在Form表單中帶上客戶端的client_id
和client_secret
,此時的ClientCredentialsTokenEndpointFilter
會去檢查client_id
和client_secret
的合法性,如果不合法丟擲的異常由其自己在filter內部例項化的OAuth2AuthenticationEntryPoint
來處理該異常,所以上面定義的BootOAuth2WebResponseExceptionTranslator
捕獲不到該異常;看如下原始碼分析,重點看中文註釋
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
// filter
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
Authentication authResult;
try {
// 呼叫子類的 attemptAuthentication(request, response) 方法,這裡是呼叫ClientCredentialsTokenEndpointFilter 的attemptAuthentication方法
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
// 客戶端資訊不合法(client_id不存在或client_secret不正確)丟擲的異常,呼叫unsuccessfulAuthentication方法處理
catch (InternalAuthenticationServiceException failed) {
logger.error(
"An internal error occurred while trying to authenticate the user.",
failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
// Authentication failed
unsuccessfulAuthentication(request, response, failed);
return;
}
// Authentication success
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authResult);
}
public abstract Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException,
ServletException;
// 登入失敗處理
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
SecurityContextHolder.clearContext();
if (logger.isDebugEnabled()) {
logger.debug("Authentication request failed: " + failed.toString(), failed);
logger.debug("Updated SecurityContextHolder to contain null Authentication");
logger.debug("Delegating to authentication failure handler " + failureHandler);
}
rememberMeServices.loginFail(request, response);
/**
* 呼叫其子類 ClientCredentialsTokenEndpointFilter 的afterPropertiesSet()方法中的設定的onAuthenticationFailure方法,這個地方有點繞,
* 自己跑幾遍原始碼看看就能理解了,接下來就是去看ClientCredentialsTokenEndpointFilter 中的實現
* */
failureHandler.onAuthenticationFailure(request, response, failed);
}
}
public class ClientCredentialsTokenEndpointFilter extends AbstractAuthenticationProcessingFilter {
// 異常處理
private AuthenticationEntryPoint authenticationEntryPoint = new OAuth2AuthenticationEntryPoint();
private boolean allowOnlyPost = false;
public ClientCredentialsTokenEndpointFilter() {
this("/oauth/token");
}
public ClientCredentialsTokenEndpointFilter(String path) {
super(path);
setRequiresAuthenticationRequestMatcher(new ClientCredentialsRequestMatcher(path));
// If authentication fails the type is "Form"
((OAuth2AuthenticationEntryPoint) authenticationEntryPoint).setTypeName("Form");
}
public void setAllowOnlyPost(boolean allowOnlyPost) {
this.allowOnlyPost = allowOnlyPost;
}
/**
* @param authenticationEntryPoint the authentication entry point to set
*/
public void setAuthenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) {
this.authenticationEntryPoint = authenticationEntryPoint;
}
// 這個方法在bean初始化時呼叫
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (exception instanceof BadCredentialsException) {
exception = new BadCredentialsException