Spring Security缓存请求详解

我们在Java开发中,可能会经常使用到Spring Security来实现系统的权限控制。在企业级应用中,也有可能会使用Spring Security集成CAS来实现单点登录和权限控制。当然一些独立的系统(如人事管理系统等),也有可能会直接使用本系统的用户名密码认证。但是各位同学有没有想过,当系统由一个页面跳转至登录页时,当完成登录之后是如何跳转回原页面的呢?下面以Spring Security + CAS的场景为Demo介绍。

请求流程

Spring Security作为权限管理框架的粗略实现流程如下:

Spring Security中依赖org.springframework.security.web.FilterChainProxy 来执行所有的Filter。Security包含登录和授权两方面内容,登录主要集中在org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter,它对于用户名密码登录CAS登录分别有如下的认证过滤器:

授权操作主要集中在 org.springframework.security.web.access.intercept.FilterSecurityInterceptor。所有的请求到了这一个filter,如果这个filter之前没有执行过的话,那么首先执行 super.beforeInvocation(fi) 这个是由AbstractSecurityInterceptor提供,它是Spring Security处理鉴权的入口。

代码实现

ExceptionTranslationFilter

ExceptionTranslationFilter 是Spring Security的核心filter之一,用来处理AuthenticationException和AccessDeniedException两种异常。AuthenticationException指的是未登录状态下访问受保护资源,AccessDeniedException指的是登陆了但是由于权限不足(比如普通用户访问管理员界面)。

当发生异常时,ExceptionTranslationFilter 持有两个处理类,分别是AuthenticationEntryPoint和AccessDeniedHandler。

ExceptionTranslationFilter 对异常的处理是通过这两个处理类实现的,处理规则很简单:

1、如果异常是 AuthenticationException,使用 AuthenticationEntryPoint 处理(我们可以通过自定义AuthenticationEntryPoint来引导用户登录)。

2、如果异常是 AccessDeniedException 且用户是匿名用户,使用 AuthenticationEntryPoint 处理。

3、如果异常是 AccessDeniedException 且用户不是匿名用户,如果否则交给 AccessDeniedHandler 处理。

主要判断逻辑如下所示:

private void handleSpringSecurityException(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, RuntimeException exception)
			throws IOException, ServletException {
		if (exception instanceof AuthenticationException) {
			logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);

			sendStartAuthentication(request, response, chain, (AuthenticationException) exception);
		} else if (exception instanceof AccessDeniedException) {
			if (authenticationTrustResolver.isAnonymous(SecurityContextHolder.getContext().getAuthentication())) {
				logger.debug("Access is denied (user is anonymous); redirecting to authentication entry point", exception);

				sendStartAuthentication(
						request,
						response,
						chain,
						new InsufficientAuthenticationException("Full authentication is required to access this resource"));
			} else {
				logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);

				accessDeniedHandler.handle(request, response, (AccessDeniedException) exception);
			}
		}
	}

AuthenticationEntryPoint 默认实现是 LoginUrlAuthenticationEntryPoint, 该类的处理是转发或重定向到登录页面,如下所示:

/**
 * 默认跳转到登录URL
 */
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {

	String redirectUrl = null;
	if (useForward) {
		if (forceHttps && "http".equals(request.getScheme())) {
			// First redirect the current request to HTTPS.
			// When that request is received, the forward to the login page will be
			// used.
			redirectUrl = buildHttpsRedirectUrlForRequest(request);
		}

		if (redirectUrl == null) {
			String loginForm = determineUrlToUseForThisRequest(request, response, authException);
			if (logger.isDebugEnabled()) {
				logger.debug("Server side forward to: " + loginForm);
			}
			RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
			dispatcher.forward(request, response);
			return;
		}
	} else {
		// redirect to login page. Use https if forceHttps true
		redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
	}

	redirectStrategy.sendRedirect(request, response, redirectUrl);
}

当我们使用Security与CAS集成时,可以重写 AuthenticationEntryPoint 如下:

public final void commence(final HttpServletRequest servletRequest, final HttpServletResponse response,
		final AuthenticationException authenticationException) throws IOException, ServletException {
	
	final String urlEncodedService = createServiceUrl(servletRequest, response);
	final String redirectUrl = createRedirectUrl(urlEncodedService);

	preCommence(servletRequest, response);

	String type = servletRequest.getHeader(HEADER_RESPONSE_CONTENT_TYPE);
	String requestURI = servletRequest.getServletPath();
	LOG.info("invalid session for content type < " + type + ">" + " and url:" + requestURI);

	// web端ajax异步session失效跳转到登录页面
	if (servletRequest.getHeader("x-requested-with") != null
			&& servletRequest.getHeader("x-requested-with").equalsIgnoreCase("XMLHttpRequest")) {
		LOG.info("web端ajax异步调用session失效,需要登录");
		PrintWriter writer = response.getWriter();
		writer.write(SESSION_TIME_OUT_MSG);
		writer.close();
		return;
	}

	if (RESPONSE_TYPE_APPLICATION_JSON.equalsIgnoreCase(type) && isNotCheckUserUrl(requestURI)) {
		LOG.info("return json format data of session timeout.");
		PrintWriter writer = response.getWriter();
		writer.write(JSON_MSG_INVALID_SESSION);
		writer.close();
	} else {
		response.sendRedirect(redirectUrl);
	}
}

AccessDeniedHandler 默认实现是 AccessDeniedHandlerImpl。该类对异常的处理是返回403错误码。如下所示:

public void handle(HttpServletRequest request, HttpServletResponse response,
		AccessDeniedException accessDeniedException) throws IOException, ServletException {
	if (!response.isCommitted()) {
		if (errorPage != null) {
			// Put exception into request scope (perhaps of use to a view)
			request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
			// Set the 403 status code.
			response.setStatus(HttpServletResponse.SC_FORBIDDEN);

			// forward to error page.
			RequestDispatcher dispatcher = request.getRequestDispatcher(errorPage);
			dispatcher.forward(request, response);
		} else {
			response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
		}
	}
}

用户未登录的情况下访问受保护资源,ExceptionTranslationFilter 捕获到AuthenticationException异常。页面需要跳转,ExceptionTranslationFilter在跳转前使用requestCache缓存request。

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
		AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the existing Authentication is no longer considered valid
	SecurityContextHolder.getContext().setAuthentication(null);
	requestCache.saveRequest(request, response);
	logger.debug("Calling Authentication entry point.");
	authenticationEntryPoint.commence(request, response, reason);
}

AbstractAuthenticationProcessingFilter

当用户在CAS完成登录操作之后,会让浏览器重定向至 https://www.moguhu.com/login/cas?ticket=ST-xxx (高版本Security,低版本的后缀为 /j_spring_cas_security_check)。校验通过后会进入到 CasAuthenticationFilter ,如下所示:


此时会调用如下方法(AbstractAuthenticationProcessingFilter):

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, 
	FilterChain chain, Authentication authResult) throws IOException, ServletException {

	if (logger.isDebugEnabled()) {
		logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
	}
	SecurityContextHolder.getContext().setAuthentication(authResult);
	rememberMeServices.loginSuccess(request, response, authResult);

	// Fire event
	if (this.eventPublisher != null) {
		eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
	}

	successHandler.onAuthenticationSuccess(request, response, authResult);
}

上面的successHandler.onAuthenticationSuccess() (SavedRequestAwareAuthenticationSuccessHandler) 方法实现如下:

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, 
	Authentication authentication) throws ServletException, IOException {
	// 根据SESSIONID获取到上一次缓存的地址
	SavedRequest savedRequest = requestCache.getRequest(request, response);

	if (savedRequest == null) {
		super.onAuthenticationSuccess(request, response, authentication);
		return;
	}
	String targetUrlParameter = getTargetUrlParameter();
	if (isAlwaysUseDefaultTargetUrl()
			|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
		requestCache.removeRequest(request, response);
		super.onAuthenticationSuccess(request, response, authentication);

		return;
	}

	clearAuthenticationAttributes(request);

	// 跳转至上次缓存的URL
	String targetUrl = savedRequest.getRedirectUrl();
	logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
	getRedirectStrategy().sendRedirect(request, response, targetUrl);
}

RequestCache的实现是 org.springframework.security.web.savedrequest.HttpSessionRequestCache,其实就是在Session中以key为 SPRING_SECURITY_SAVED_REQUEST 存储链接。到此,上面的跳转流程就通了。


参考:https://blog.coding.net/blog/Explore-the-cache-request-of-Security-Spring