解决Spring Security中AuthenticationEntryPoint不生效相关问题
作者:冲鸭hhh
之前由于项目需要比较详细地学习了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()); } ... }
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。