我们在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 对异常的处理是通过这两个处理类实现的,处理规则很简单:
- 如果异常是 AuthenticationException,使用 AuthenticationEntryPoint 处理(
我们可以通过自定义AuthenticationEntryPoint来引导用户登录
)。 - 如果异常是 AccessDeniedException 且用户是匿名用户,使用 AuthenticationEntryPoint 处理。
- 如果异常是 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
链接:http://moguhu.com/article/detail?articleId=102