1. 程式人生 > >Spring boot+Security OAuth2 爬坑日記(4)自定義異常處理 上

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" }
  1. 定義自己的OAuth2Exception

    @JsonSerialize(using = BootOAuthExceptionJacksonSerializer.class)
    public class BootOAuth2Exception extends OAuth2Exception {
        public BootOAuth2Exception(String msg, Throwable t) {
            super(msg, t);
        }
    
        public BootOAuth2Exception
    (String msg) { super(msg); } }
  2. 定義異常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();
            }
    }
    
  3. 定義自己的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;
    
        }
        
        ..........
    
    
  4. 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_idclient_secret,此時的ClientCredentialsTokenEndpointFilter 會去檢查client_idclient_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