0


SpringBoot + Vue前后端分离项目接入CAS单点登录SSO(详细实现过程) - 踩坑记录,源码分析、扩展

CAS(Central Authentication Server)是Yelu大学研发单点登录解决方案。

它包含Server端和Client端,Server一般是每个公司部署一个,Client端则由各个系统自行引入。本文是Java项目,所以本文讨论的都是CAS的Java客户端。

CAS客户端主要做两件事,身份认证(默认通过AuthenticationFilter实现)和票据校验(默认通过Cas20ProxyReceivingTicketValidationFilter实现),基于javax.servlet.Filter。

本文建立在您已了解CAS流程的基础上,如果有不太了解的博友,可以先参考一下这篇博客,讲得很清楚:CAS单点登录原理(包含详细流程,讲得很透彻,耐心看下去一定能看明白!)


背景

现在新开发一个前后端分离系统,后端是SpringBoot(地址:localhost:8082),前端是Vue项目(地址:localhost:8081)。现要求该系统接入CAS Java客户端,实现单点登录

实现分析

CAS客户端基于Filter实现,默认支持的是前后端一体的系统,对于前后端分离的系统,需要对CAS做一些扩展才能正常使用。根本原因在于,前后端分离系统一般是通过ajax通信,而CAS依赖Servlet的重定向,但对于ajax的请求,浏览器是不会响应重定向的(原因会在附录分析)

但对于前后端一体的系统来说就不同了,用户访问系统时,浏览器是先向后端请求网页(html、jsp、thymelaef等),再由网页中的javascript向后端请求数据(jsp、thymelaef可能不需要)。对于浏览器发起的请求,是可以响应重定向的。所以在浏览器向后端请求网页时,Filter发现CAS未登录,就可以按默认逻辑重定向到CAS登录页面了

实现步骤

1.身份认证

引入依赖

<dependency>
    <groupId>org.jasig.cas.client</groupId>
    <artifactId>cas-client-support-springboot</artifactId>
    <version>3.6.2</version>
</dependency>

application.yml中配置属性

cas:
  server-login-url: http://10.121.xx.79/opcnet-sso/login?appid=test #CAS统一登录地址
  validation-type: cas
  server-url-prefix: http://10.121.xx.79/opcnet-sso #CAS服务端地址
  client-host-url: http://localhost:${server.port} #本项目地址

实现自定义的重定向策略

创建一个类SmsAuthenticationRedirectStrategy,实现AuthenticationRedirectStrategy接口,重写redirect方法。默认实现是response.redirect()重定向到单点登录页面,这里修改默认实现,不再执行重定向,而是返回401状态码和错误信息

实现原理详见附录:原理分析1.CAS身份认证

@Slf4j
public class SmsAuthenticationRedirectStrategy implements AuthenticationRedirectStrategy {
    @Override
    public void redirect(HttpServletRequest request, HttpServletResponse response, String potentialRedirectUrl) throws IOException {
        //设置401
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        //响应数据
        Map<String, Object> data = ImmutableMap.<String, Object>builder()
                .put("errorType", "NOT_LOGIN_CAS")
                .put("casLoginUrl", potentialRedirectUrl)
                .build();
        //输出
        PrintWriter out = response.getWriter();
        out.println(JSON.toJSONString(data));
        out.flush();
        out.close();
    }
}

应用自定义的重定向策略

新建一个类SmsCasClientConfigurer,实现CasClientConfigurer,重写configureAuthenticationFilter方法

实现原理详见附录:原理分析1.CAS身份认证

@Configuration
public class SmsCasClientConfigurer implements CasClientConfigurer {
    
    @Override
    public void configureAuthenticationFilter(FilterRegistrationBean authenticationFilter) {
      authenticationFilter.addInitParameter("authenticationRedirectStrategyClass", "com.hnair.sms.repo.component.cas.SmsAuthenticationRedirectStrategy");
    }
}

2.响应401

前端拦截401

前端发请求工具使用的是axios,响应拦截器配置如下

/**
 * 1.200:
 * success为true:请求成功
 * success为false:请求失败,返回可读信息
 *
 * 2.401:
 * errorType为NOT_LOGIN_CAS:CAS校验不通过,跳转到CAS登录界面
 *
 * 3.其他:(例如400,404,500,502,504等)
 * 均显示为未知错误
 */
request.interceptors.response.use(
  response => {
    if (response.data.success) {
      return Promise.resolve(response.data);
    } else {
      showMessage("请求失败,异常信息:" + response.data.message, "error")
      return Promise.reject()
    }
  },
  error => {
    let status = error.response.status;
    let data = error.response.data;

    if (status === 401) {
      if (data.errorType && data.errorType === "NOT_LOGIN_CAS") {
        //发现CAS未登录,跳转到CAS统一登录页面
        location.href = data.casLoginUrl
      } else {
        return Promise.reject(error)
      }
    } else {
      showMessage("未知错误,请联系管理员", "error")
      return Promise.reject(error)
    }
  }
)

3.票据检验

实现自定义的票据校验过滤器

新建一个类SmsCas20ProxyReceivingTicketValidationFilter,继承自Cas20ProxyReceivingTicketValidationFilter,重写onSuccessfulValidation方法。因为默认的票据校验过滤器在验证成功后,会重定向到源请求地址,这里修改为重定向到前端首页

实现原理详见附录:原理分析2.CAS票据检验

public class SmsCas20ProxyReceivingTicketValidationFilter extends Cas20ProxyReceivingTicketValidationFilter {
    @SneakyThrows
    @Override
    protected void onSuccessfulValidation(HttpServletRequest request, HttpServletResponse response, Assertion assertion) {
        //重定向到前端首页。此处为方便阅读使用了硬编码,实际应用时,应写入配置文件
        response.sendRedirect("localhost:8081");
    }
}

应用自定义的票据校验过滤器

回到第1步创建的类SmsCasClientConfigurer,新添加一个重写的configureValidationFilter方法,重新设置过滤器

实现细节详见附录:原理分析2.CAS票据检验

@Configuration
public class SmsCasClientConfigurer implements CasClientConfigurer {
    
    @Override
    public void configureAuthenticationFilter(FilterRegistrationBean authenticationFilter) {
      authenticationFilter.addInitParameter("authenticationRedirectStrategyClass", "com.hnair.sms.repo.component.cas.SmsAuthenticationRedirectStrategy");
    }

    @Override
    public void configureValidationFilter(FilterRegistrationBean validationFilter) {
        validationFilter.setFilter(new SmsCas20ProxyReceivingTicketValidationFilter());
    }
}

4.效果演示

分别启动前后端项目,访问前端首页,发起了一个ajax请求

注:由于前端使用了代理,所以network显示请求的域是localhost:8081,实际请求的域是localhost:8082

该请求被AuthenticationFilter拦截,并发现CAS未登录,准备跳转至登录页面,进入了SmsAuthenticationRedirectStrategy

前端收到401响应

进入ajax失败回调

location.href跳转至CAS登录页面。从地址栏可以注意到,源请求url作为查询参数赋值给了service,登录完成后,浏览器会回调service中的url

输入账号密码后,执行票据校验,校验成功后,进入SmsCas20ProxyReceivingTicketValidationFilter。接着,浏览器从CAS登录页面跳转到前端首页,CAS登录完成

附录:原理分析

1.CAS身份认证

关于ajax与重定向

前面已经提到,由于前后端交互使用的是ajax,对于ajax请求,浏览器不会响应重定向。下方示例为ajax处理重定向的场景。

可以看到响应状态码是302,后面紧跟了CAS登录请求的URL。正常来说此时浏览器应该要跳转了,但实际却不会,而且该ajax请求也并没有结束,它仍在请求重定向后的url,请求成功后,进入ajax成功回调,这时ajax才完成。

从下方第二个截图可以看出,源请求接收到是重定向后url的响应,状态码为200,响应数据为CAS登录页面的html文本。

对于ajax这样处理的原因,感兴趣的博友可以参考一下这篇文章:ajax 遇到重定向,ajax 重定向跨域问题

使用401响应来代替重定向

后端重定向行不通,可以返回别的响应码,比如401、500或者200,然后让前端来控制浏览器跳转。本例中选择了401响应

下面是AuthenticationFilter类的doFilter方法,主要逻辑是当发现CAS未登录时,就调用this.authenticationRedirectStrategy.redirect重定向到CAS登录页面(为方便阅读,只保留了与本文相关的代码)

public class AuthenticationFilter extends AbstractCasFilter {

    /**
    * 默认的重定向策略,详见下方DefaultAuthenticationRedirectStrategy源码
    */
    private AuthenticationRedirectStrategy authenticationRedirectStrategy = new DefaultAuthenticationRedirectStrategy();

    @Override
    public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
                               final FilterChain filterChain) throws IOException, ServletException {

        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;

        final HttpSession session = request.getSession(false);
        final Assertion assertion = session != null ? (Assertion) session.getAttribute(CONST_CAS_ASSERTION) : null;

        //CAS已登录的判断条件是:session存在且包含用户信息
        if (assertion != null) {
            filterChain.doFilter(request, response);
            return;
        }

        //生成重定向的URL
        final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl,
            getProtocol().getServiceParameterName(), constructServiceUrl(request, response);, this.renew, this.gateway, this.method);
        //重定向到CAS登录页面
        this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
    }

    //其余代码省略...
}

this.authenticationRedirectStrategy采用了策略模式,默认策略为DefaultAuthenticationRedirectStrategy,源码如下

public final class DefaultAuthenticationRedirectStrategy implements AuthenticationRedirectStrategy {

    @Override
    public void redirect(final HttpServletRequest request, final HttpServletResponse response,
                         final String potentialRedirectUrl) throws IOException {
        response.sendRedirect(potentialRedirectUrl);
    }
}

如何修改这个默认策略呢?

查看AuthenticationFilter的初始化方法,这里设置了this.authenticationRedirectStrategy(为方便阅读,只保留了与本文相关的代码)

public class AuthenticationFilter extends AbstractCasFilter {
    @Override
    protected void initInternal(final FilterConfig filterConfig) throws ServletException {
        if (!isIgnoreInitConfiguration()) {
            super.initInternal(filterConfig);

            //从用户给定的authenticationRedirectStrategyClass参数中获取类的全限定名并获取其Class对象,然后通过反射创建实例
            final Class<? extends AuthenticationRedirectStrategy> authenticationRedirectStrategyClass = getClass(ConfigurationKeys.AUTHENTICATION_REDIRECT_STRATEGY_CLASS);

            if (authenticationRedirectStrategyClass != null) {
                this.authenticationRedirectStrategy = ReflectUtils.newInstance(authenticationRedirectStrategyClass);
            }
        }
    }
}

所以说,我们需要给AuthenticationFilter设置authenticationRedirectStrategyClass参数,参数值就是【实现步骤 - 1.身份认证 - 实现自定义的重定向策略】中创建的类SmsAuthenticationRedirectStrategy的全限定名

如何设置这个值呢?

一般来说,SpringMVC项目引入CAS客户端,通常是在web.xml中配置相关过滤器。而SpringBoot项目由于其自动配置的特性,整合到SpringBoot的三方库一般会提供自动配置类。

cas-client-support-springboot也提供了一个自动配置类:org.jasig.cas.client.boot.configuration.CasClientConfiguration,该类配置了CAS流程所需要的所有过滤器,下面是注册AuthenticationFilter的代码。它使用FilterRegistrationBean注册过滤器(相当于在web.xml中配置<filter>)

注意最后面几行代码,它支持用this.casClientConfigurer进行自定义修改

@Configuration
@EnableConfigurationProperties(CasClientConfigurationProperties.class)
public class CasClientConfiguration {

    @Bean
    public FilterRegistrationBean casAuthenticationFilter() {
        //通过FilterRegistrationBean注册一个过滤器
        final FilterRegistrationBean authnFilter = new FilterRegistrationBean();
        //application.yml中validation-type: cas,所以执行的是new AuthenticationFilter()
        final Filter targetCasAuthnFilter =
            this.configProps.getValidationType() == EnableCasClient.ValidationType.CAS
                || configProps.getValidationType() == EnableCasClient.ValidationType.CAS3
                ? new AuthenticationFilter()
                : new Saml11AuthenticationFilter();

        initFilter(authnFilter,
            targetCasAuthnFilter,
            2,
            constructInitParams("casServerLoginUrl", this.configProps.getServerLoginUrl(), this.configProps.getClientHostUrl()),
            this.configProps.getAuthenticationUrlPatterns());

        //支持自定义配置AuthenticationFilter过滤器
        if (this.casClientConfigurer != null) {
            this.casClientConfigurer.configureAuthenticationFilter(authnFilter);
        }
        return authnFilter;
    }

    //其余代码省略...
}

casClientConfigurer是一个接口,源码如下:

public interface CasClientConfigurer {
    default void configureAuthenticationFilter(FilterRegistrationBean authenticationFilter) {
    }

    default void configureValidationFilter(FilterRegistrationBean validationFilter) {
    }

    default void configureHttpServletRequestWrapperFilter(FilterRegistrationBean httpServletRequestWrapperFilter) {
    }

    default void configureAssertionThreadLocalFilter(FilterRegistrationBean assertionThreadLocalFilter) {
    }
}

下面是casClientConfigurer初始化的代码,可以看出,它通过@autowired注入依赖。所以就有了【实现步骤 - 1.身份认证 - 应用自定义的重定向策略】,重写configureAuthenticationFilter方法后,就可以给AuthenticationFilter设置authenticationRedirectStrategyClass参数了

@Configuration
@EnableConfigurationProperties(CasClientConfigurationProperties.class)
public class CasClientConfiguration {

    private CasClientConfigurer casClientConfigurer;

    @Autowired(required = false)
    void setConfigurers(final Collection<CasClientConfigurer> configurers) {
        if (CollectionUtils.isEmpty(configurers)) {
            return;
        }
        if (configurers.size() > 1) {
            throw new IllegalStateException(configurers.size() + " implementations of " +
                "CasClientConfigurer were found when only 1 was expected. " +
                "Refactor the configuration such that CasClientConfigurer is " +
                "implemented only once or not at all.");
        }
        this.casClientConfigurer = configurers.iterator().next();
    }
}

2.CAS票据检验

重定向到CAS登录页面后,用户需要输入账号密码登录,前面说到,登录成功后,浏览器会跳转到service参数指定的url,且会在该url后面追加一个参数ticket,即CAS-Server签发的票据。另外从前面的中截图可以看到service的参数值(解码后为:http://localhost:8082/sms/repo/sys/current-user,也就是第1步身份认证时被AuthenticationFilter拦截的url)

刚才说到,登录成功后,浏览器会跳转到http://localhost:8082/sms/repo/sys/current-user。那登录成功后,页面将会是这样的

很明显这不是预期的效果,我们希望登录成功后浏览器跳转到前端的首页,如何实现呢?

下面是注册票据校验过滤器的代码,可以看到使用的是Cas20ProxyReceivingTicketValidationFilter,它的执行顺序为1(AuthenticationFilter的执行顺序为2),意味的请求会先进入Cas20ProxyReceivingTicketValidationFilter的doFilter方法

@Configuration
@EnableConfigurationProperties(CasClientConfigurationProperties.class)
public class CasClientConfiguration {

    @Bean
    @ConditionalOnProperty(prefix = "cas", name = "skipTicketValidation", havingValue = "false", matchIfMissing = true)
    public FilterRegistrationBean casValidationFilter() {
        final FilterRegistrationBean validationFilter = new FilterRegistrationBean();
        final Filter targetCasValidationFilter;
        switch (this.configProps.getValidationType()) {
            //根据由application.yml中的配置,进入case CAS分支
            case CAS:
                targetCasValidationFilter = new Cas20ProxyReceivingTicketValidationFilter();
                break;
            case SAML:
                targetCasValidationFilter = new Saml11TicketValidationFilter();
                break;
            case CAS3:
            default:
                targetCasValidationFilter = new Cas30ProxyReceivingTicketValidationFilter();
                break;
        }

        initFilter(validationFilter,
            targetCasValidationFilter,
            1,
            constructInitParams("casServerUrlPrefix", this.configProps.getServerUrlPrefix(), this.configProps.getClientHostUrl()),
            this.configProps.getValidationUrlPatterns());

        if (this.casClientConfigurer != null) {
            this.casClientConfigurer.configureValidationFilter(validationFilter);
        }
        return validationFilter;
    }

    //其余代码省略...
}

查看Cas20ProxyReceivingTicketValidationFilter源码可知,它自身并没有实现doFilter方法,doFilter方法在它的父类中实现

public class Cas20ProxyReceivingTicketValidationFilter extends AbstractTicketValidationFilter {
}

AbstractTicketValidationFilter的doFilter方法(为方便阅读,只保留了与本文相关的代码)

public abstract class AbstractTicketValidationFilter extends AbstractCasFilter {

    @Override
    public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
                               final FilterChain filterChain) throws IOException, ServletException {

        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;
        //从请求参数中获取ticket
        final String ticket = retrieveTicketFromRequest(request);

        //ticket存在,才执行票据校验逻辑,否则跳过该过滤器
        if (CommonUtils.isNotBlank(ticket)) {

            try {
                //票据校验,返回包含当前登录用户的信息
                final Assertion assertion = this.ticketValidator.validate(ticket,
                        constructServiceUrl(request, response));

                //this.useSession默认值为true,用户信息将被存入session
                if (this.useSession) {
                    request.getSession().setAttribute(CONST_CAS_ASSERTION, assertion);
                }
                //校验成功回调,预留给子类实现
                onSuccessfulValidation(request, response, assertion);

                //this.redirectAfterValidation默认为true,校验完成后,会重定向到request.getRequestUrl(),在本例中,就是:http://localhost:8082/sms/repo/sys/current-user
                if (this.redirectAfterValidation) {
                    response.sendRedirect(constructServiceUrl(request, response));
                    return;
                }
            }
        }

        filterChain.doFilter(request, response);

    }

    protected void onSuccessfulValidation(final HttpServletRequest request, final HttpServletResponse response,
            final Assertion assertion) {
        // nothing to do here.
    }
}

看到这里,浏览器没有出现预期效果的原因也就清晰了,doFilter方法在校验成功后,默认重定向到request.getRequestUrl(),我们要修改这个重定向url

所以就有了前面【实现步骤 - 3.票据检验】,新建了一个类SmsCas20ProxyReceivingTicketValidationFilter继承自Cas20ProxyReceivingTicketValidationFilter,重写onSuccessfulValidation方法,重定向到前端首页就可以了。

标签: java spring boot ajax

本文转载自: https://blog.csdn.net/qq2456939181/article/details/127935967
版权归原作者 逍遥子2016 所有, 如有侵权,请联系我们删除。

“SpringBoot + Vue前后端分离项目接入CAS单点登录SSO(详细实现过程) - 踩坑记录,源码分析、扩展”的评论:

还没有评论