解決Spring Security中AuthenticationEntryPoint不生效相關問題

之前由於項目需要比較詳細地學習瞭Spring Security的相關知識,並打算實現一個較為通用的權限管理模塊。由於項目是前後端分離的,所以當認證或授權失敗後不應該使用formLogin()的重定向,而是返回一個json形式的對象來提示沒有授權或認證。   

這時,我們可以使用AuthenticationEntryPoint對認證失敗異常提供處理入口,而通過AccessDeniedHandler對用戶無授權異常提供處理入口

在這裡我的代碼如下

/**
 * 對已認證用戶無權限的處理
 */
@Component
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("utf-8");
        httpServletResponse.setContentType("application/json;charset=utf-8");
		// 提示無權限        
        httpServletResponse.getWriter().print(JSONObject.toJSONString(new BaseResult<String>(NO_PERMISSION, false, null)));
    }
}
/**
 * 對匿名用戶無權限的處理
 */
@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("utf-8");
        httpServletResponse.setContentType("application/json;charset=utf-8");
		// 認證失敗        
        httpServletResponse.getWriter().print(JSONObject.toJSONString(new BaseResult<String>(e.getMessage(), false, null)));
    }
}

在這樣的設置下,如果認證失敗的話會提示具體認證失敗的原因;而用戶進行無權限訪問的時候會返回無權限的提示。   

用不存在的用戶名密碼登錄後會出現以下返回數據

在這裡插入圖片描述

與我所設置的認證異常返回值不一致。

在繼續講解前,我先簡單說下我當前的Spring Security配置,我是將不同的登錄方式整合在一起,並模仿Spring Security中的UsernamePasswordAuthenticationFilter實現瞭不同登錄方式的過濾器。   

設想通過郵件、短信、驗證碼和微信等登錄方式登錄(這裡暫時隻實現瞭驗證碼登錄的模板)。

在這裡插入圖片描述   

以下是配置信息

/**
 * @Author chongyahhh
 * 驗證碼登錄配置
 */
@Component
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class VerificationLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final VerificationAuthenticationProvider verificationAuthenticationProvider;
    @Qualifier("tokenAuthenticationDetailsSource")
    private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource;
    @Override
    public void configure(HttpSecurity http) throws Exception {
        VerificationAuthenticationFilter verificationAuthenticationFilter = new VerificationAuthenticationFilter();
        verificationAuthenticationFilter.setAuthenticationManager(http.getSharedObject((AuthenticationManager.class)));
        http
                .authenticationProvider(verificationAuthenticationProvider)
                .addFilterAfter(verificationAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 將VerificationAuthenticationFilter加到UsernamePasswordAuthenticationFilter後面
    }
}
/**
 * @Author chongyahhh
 * Spring Security 配置
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final AuthenticationEntryPoint jsonAuthenticationEntryPoint;
    private final AccessDeniedHandler jsonAccessDeniedHandler;
    private final VerificationLoginConfig verificationLoginConfig;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                    .apply(verificationLoginConfig) // 用戶名密碼驗證碼登錄配置導入
                .and()
                    .exceptionHandling()
                    .authenticationEntryPoint(jsonAuthenticationEntryPoint) // 註冊自定義認證異常入口
                    .accessDeniedHandler(jsonAccessDeniedHandler) // 註冊自定義授權異常入口
                .and()
                    .anonymous()
                .and()
                    .formLogin()
                .and()
                    .csrf().disable(); // 關閉 csrf,防止首次的 POST 請求被攔截
    }
    @Bean("customSecurityExpressionHandler")
    public DefaultWebSecurityExpressionHandler webSecurityExpressionHandler(){
        DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
        handler.setPermissionEvaluator(new CustomPermissionEvaluator());
        return handler;
    }
}

以下是實現的驗證碼登錄過濾器

模仿UsernamePasswordAuthenticationFilter繼承AbstractAuthenticationProcessingFilter實現。

/**
 * @Author chongyahhh
 * 驗證碼登錄過濾器
 */
public class VerificationAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String VERIFICATION_CODE = "verificationCode";
    private boolean postOnly = true;
    public VerificationAuthenticationFilter() {
        super(new AntPathRequestMatcher(SECURITY_VERIFICATION_CODE_LOGIN, "POST"));
        // 繼續執行攔截器鏈,執行被攔截的 url 對應的接口
        super.setContinueChainBeforeSuccessfulAuthentication(true);
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String verificationCode = this.obtainVerificationCode(request);
        System.out.println("驗證中...");
        String username = this.obtainUsername(request);
        String password = this.obtainPassword(request);
        username = (username == null) ? "" : username;
        password = (password == null) ? "" : password;
        username = username.trim();
        VerificationAuthenticationToken authRequest = new VerificationAuthenticationToken(username, password);
        //this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
    private String obtainPassword(HttpServletRequest request) {
        return request.getParameter(PASSWORD);
    }
    private String obtainUsername(HttpServletRequest request) {
        return request.getParameter(USERNAME);
    }
    private String obtainVerificationCode(HttpServletRequest request) {
        return request.getParameter(VERIFICATION_CODE);
    }
    private void setDetails(HttpServletRequest request, VerificationAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
    private boolean validate(String verificationCode) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        HttpSession session = request.getSession();
        Object validateCode = session.getAttribute(VERIFICATION_CODE);
        if(validateCode == null) {
            return false;
        }
        // 不分區大小寫
        return StringUtils.equalsIgnoreCase((String)validateCode, verificationCode);
    }
}

其它的設置與本問題無關,就先不放出來瞭。   

首先我們要知道,AuthenticationEntryPoint和AccessDeniedHandler是過濾器ExceptionTranslationFilter中的一部分,當ExceptionTranslationFilter捕獲到之後過濾器的執行異常後,會調用AuthenticationEntryPoint和AccessDeniedHandler中的對應方法來進行異常處理。

以下是對應的源碼

private void handleSpringSecurityException(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, RuntimeException exception)
			throws IOException, ServletException {
		if (exception instanceof AuthenticationException) { // 認證異常
			...
			sendStartAuthentication(request, response, chain,
					(AuthenticationException) exception); // 在這裡調用 AuthenticationEntryPoint 的 commence 方法
		} else if (exception instanceof AccessDeniedException) { // 無權限
			Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
			if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
				...
				sendStartAuthentication(
						request,
						response,
						chain,
						new InsufficientAuthenticationException(
							messages.getMessage(
								"ExceptionTranslationFilter.insufficientAuthentication",
								"Full authentication is required to access this resource"))); // 在這裡調用 AuthenticationEntryPoint 的 commence 方法
			} else {
				...
				accessDeniedHandler.handle(request, response,
						(AccessDeniedException) exception); // 在這裡調用 AccessDeniedHandler 的 handle 方法
			}
		}
	}

在ExceptionTranslationFilter抓到之後的攔截器拋出的異常後就進行以上判斷:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
		try {
			chain.doFilter(request, response);
			logger.debug("Chain processed normally");
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
			RuntimeException ase = (AuthenticationException) throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);
			if (ase == null) {
				ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
						AccessDeniedException.class, causeChain);
			}
			if (ase != null) {
				if (response.isCommitted()) {
					throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
				}
				// 這裡進入上面的方法!!!
				handleSpringSecurityException(request, response, chain, ase);
			}
			else {
				// Rethrow ServletExceptions and RuntimeExceptions as-is
				if (ex instanceof ServletException) {
					throw (ServletException) ex;
				}
				else if (ex instanceof RuntimeException) {
					throw (RuntimeException) ex;
				}
				// Wrap other Exceptions. This shouldn't actually happen
				// as we've already covered all the possibilities for doFilter
				throw new RuntimeException(ex);
			}
		}
	}

綜上,我們考慮攔截器鏈沒有到達ExceptionTranslationFilter便拋出異常並結束處理;或是經過瞭ExceptionTranslationFilter,但之後的異常沒被其抓取便處理結束。   

我們首先看一下當前Security的攔截器鏈

在這裡插入圖片描述

  

很明顯可以發現,我們自定義的過濾器在ExceptionTranslationFilter之前,所以在拋出異常後,應該會處理後直接終止執行鏈。   

由於篇幅原因,這裡不具體給出debug過程,直接給出結果。   

我們查看VerificationAuthenticationFilter繼承的AbstractAuthenticationProcessingFilter中的doFilter方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
     	// 在此處進行 url 匹配,如果不是該攔截器攔截的 url,就直接執行下一個攔截器的攔截
		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);
			return;
		}
		if (logger.isDebugEnabled()) {
			logger.debug("Request is to process authentication");
		}
		Authentication authResult;
		try {
			// 調用我們實現的 VerificationAuthenticationFilter 中的 attemptAuthentication 方法,進行登錄邏輯驗證
			authResult = attemptAuthentication(request, response);
			if (authResult == null) {
				return;
			}
			sessionStrategy.onAuthentication(authResult, request, response);
		} catch (InternalAuthenticationServiceException failed) {
			logger.error(
					"An internal error occurred while trying to authenticate the user.",
					failed);
			unsuccessfulAuthentication(request, response, failed);
			return;
		} catch (AuthenticationException failed) {
			//
			// 註意這裡,如果登錄失敗,我們拋出的異常會在這裡被抓取,然後通過 unsuccessfulAuthentication 進行處理
			// 翻閱 unsuccessfulAuthentication 中的代碼我們可以發現,如果我們沒有設置認證失敗後的重定向url,就會封裝一個401的響應,也就是我們上面出現的情況
			// 
			unsuccessfulAuthentication(request, response, failed);
			// 執行完成後直接中斷攔截器鏈的執行
			return;
		}
		// 如果登錄成功就繼續執行,我們設置的 continueChainBeforeSuccessfulAuthentication 為 true
		if (continueChainBeforeSuccessfulAuthentication) {
			chain.doFilter(request, response);
		}
		successfulAuthentication(request, response, chain, authResult);
	}

通過這段代碼的分析,原因就一目瞭然瞭,如果我們繼承AbstractAuthenticationProcessingFilter來實現我們的登錄驗證邏輯,無論該過濾器在ExceptionTranslationFilter的前面或後面,都無法順利觸發ExceptionTranslationFilter中的異常處理邏輯,因為AbstractAuthenticationProcessingFilter會對認證異常進行自我消化並中斷攔截器鏈的進行,所以我們隻能通過其他的Filter來封裝我們的登錄邏輯攔截器,如:GenericFilterBean。   

為瞭保證攔截器鏈能順利到達ExceptionTranslationFilter

我們需要滿足兩個條件:     

1、自定義的認證過濾器不能通過繼承AbstractAuthenticationProcessingFilter實現;     

2、自定義的認證過濾器應在ExceptionTranslationFilter後面:

在這裡插入圖片描述   

此外,我們也可以通過實現AuthenticationFailureHandler的方式來處理認證異常。

public class JsonAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().print(JSONObject.toJSONString(new BaseResult<String>(exception.getMessage(), false, null)));
    }
}
public class VerificationAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String VERIFICATION_CODE = "verificationCode";
    private boolean postOnly = true;
    public VerificationAuthenticationFilter() {
        super(new AntPathRequestMatcher(SECURITY_VERIFICATION_CODE_LOGIN, "POST"));
        // 繼續執行攔截器鏈,執行被攔截的 url 對應的接口
        super.setContinueChainBeforeSuccessfulAuthentication(true);
        // 設置認證失敗處理入口
        setAuthenticationFailureHandler(new JsonAuthenticationFailureHandler());
    }
    ...
}

以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。

推薦閱讀: