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
链接:http://moguhu.com/article/detail?articleId=102

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值