之前由于项目需要比较详细地学习了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)));
    }
}

  在这样的设置下,如果认证失败的话会提示具体认证失败的原因;而用户进行无权限访问的时候会返回无权限的提示。

  但用不存在的用户名密码登录后会出现以下返回数据:

sprinboot spring security坐标 spring security entrypoint_java


  与我所设置的认证异常返回值不一致。

  在继续讲解前,我先简单说下我当前的Spring Security配置,我是将不同的登录方式整合在一起,并模仿Spring Security中的UsernamePasswordAuthenticationFilter实现了不同登录方式的过滤器。

  设想通过邮件、短信、验证码和微信等登录方式登录(这里暂时只实现了验证码登录的模板)。

sprinboot spring security坐标 spring security entrypoint_java_02


  以下是配置信息:

/**
 * @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);
    }
}

  其它的设置与本问题无关,就先不放出来了。
  首先我们要知道,AuthenticationEntryPointAccessDeniedHandler是过滤器ExceptionTranslationFilter中的一部分,当ExceptionTranslationFilter捕获到之后过滤器的执行异常后,会调用AuthenticationEntryPointAccessDeniedHandler中的对应方法来进行异常处理。以下是对应的源码:

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的拦截器链:

sprinboot spring security坐标 spring security entrypoint_过滤器_03


  很明显可以发现,我们自定义的过滤器在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后面:

sprinboot spring security坐标 spring security entrypoint_java_04


  此外,我们也可以通过实现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());
    }

    ...
}

  希望这篇博客能帮助到大家,谢谢观看!