CAS单点登录(十三)——客户端前后端分离接入

最近在工作上遇到CAS前后端分离接入,后端是使用的GO,前端则是Ant Design,通过Restful Api进行数据交互,最后是同域下完成接入,始终感觉不理想,打算仔细研究一下前后端分离接入CAS方案,并进行总结如下。果真问题是学习的好老师,有疑问才能去解决。

前面一些列的文章介绍CAS,具体原理我就再在这里复述了,如果读者还不太熟悉原理,可以去翻翻前面的文章——CAS单点登录(一)——初识SSO

一、关于Session、Cookie及JSESSIONID的作用

我们知道CAS是基于Session的认证方式,即CAS是把认证信息放在了Session的attribute中(可通过request.getSession().getAttribute(“const_cas_assertion”)),这个我们在前面也讲解过。

我们知道HTTP协议是一种无状态协议,每次服务端接收到客户端的请求时都是一个全新的请求,服务器并不知道客户端的历史请求记录;

为了弥补Http的无状态特性,session应运而生。服务器可以利用session存储客户端在同一个会话期间的一些操作记录,而服务端的这个session对应到浏览器端则是名为JSESSIONID的cookie,JSESSIONID的值就是session的id。

a、服务器如何判断客户端发送过来的请求是属于同一个seesion?

用session的id来进行区分。如果id相同,那就认为是同一个会话。在Tomcat中,session的id的默认名字是JSESSIONID。对应到前端就是名为JSESSIONID的cookie。

b、session的id是在什么时候创建,又是怎样在前后端传输的?

Tomcat在第一次接收到一个请求时会创建一个session对象,同时生成一个session id,并通过响应头的Set-Cookie:"JSESSIONID=XXXXXXX"命令,向客户端发送要求设置Cookie的响应。

前端在后续的每次请求时,都会带上所有cookie信息,自然也就包含了JSESSIONID这个cookie。然后Tomcat据此来查找到对应的session,如果指定session不存在(比如我们随手编一个JSESSIONID,那对应的session肯定不存在),那么就会创建一个新的session,其id的值就是请求中的JSESSIONID的值。

这里有一个坑,导致后面浏览器设置cookie不成功,始终无法认证成功,后面再提示。

二、cas-client默认登录验证分析

这里以java客户端3.5.1为例,进行大致的分析。我们在配置文件中,进行了CAS登录的拦截配置,在源码CasCustomConfig中。如下:

package net.anumbrella.sso.config;

import org.jasig.cas.client.authentication.AuthenticationFilter;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.session.SingleSignOutHttpSessionListener;
import org.jasig.cas.client.util.AssertionThreadLocalFilter;
import org.jasig.cas.client.util.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

/**
 * @author Anumbrella
 */
@Configuration
@Component
public class CasCustomConfig {

    @Autowired
    SpringCasAutoconfig autoconfig;

    private static boolean casEnabled = true;

    public CasCustomConfig() {
    }

    @Bean
    public SpringCasAutoconfig getSpringCasAutoconfig() {
        return new SpringCasAutoconfig();
    }

    @Bean
    public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListener() {
        ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listener = new ServletListenerRegistrationBean<SingleSignOutHttpSessionListener>();
        listener.setEnabled(casEnabled);
        listener.setListener(new SingleSignOutHttpSessionListener());
        listener.setOrder(1);
        return listener;
    }

    /**
     * 该过滤器用于实现单点登出功能,单点退出配置,一定要放在其他filter之前
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean singleSignOutFilter() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new SingleSignOutFilter());
        filterRegistration.setEnabled(casEnabled);
        if (autoconfig.getSignOutFilters().size() > 0) {
            filterRegistration.setUrlPatterns(autoconfig.getSignOutFilters());
        } else {
            filterRegistration.addUrlPatterns("/*");
        }
        filterRegistration.addInitParameter("casServerUrlPrefix", autoconfig.getCasServerUrlPrefix());
        filterRegistration.setOrder(3);
        return filterRegistration;
    }

    /**
     * 该过滤器负责用户的认证工作
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean authenticationFilter() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new AuthenticationFilter());
        filterRegistration.setEnabled(casEnabled);
        if (autoconfig.getAuthFilters().size() > 0) {
            filterRegistration.setUrlPatterns(autoconfig.getAuthFilters());
        } else {
            filterRegistration.addUrlPatterns("/*");
        }
        if (autoconfig.getIgnoreFilters() != null) {
            filterRegistration.addInitParameter("ignorePattern", autoconfig.getIgnoreFilters());
        }
        filterRegistration.addInitParameter("casServerLoginUrl", autoconfig.getCasServerLoginUrl());
        filterRegistration.addInitParameter("serverName", autoconfig.getServerName());
        filterRegistration.addInitParameter("useSession", autoconfig.isUseSession() ? "true" : "false");
        filterRegistration.addInitParameter("redirectAfterValidation", autoconfig.isRedirectAfterValidation() ? "true" : "false");
        filterRegistration.setOrder(4);
        return filterRegistration;
    }

    /**
     * 该过滤器负责对Ticket的校验工作,使用CAS 3.0协议
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean cas30ProxyReceivingTicketValidationFilter() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
        filterRegistration.setEnabled(casEnabled);
        if (autoconfig.getValidateFilters().size() > 0) {
            filterRegistration.setUrlPatterns(autoconfig.getValidateFilters());
        } else {
            filterRegistration.addUrlPatterns("/*");
        }
        filterRegistration.addInitParameter("casServerUrlPrefix", autoconfig.getCasServerUrlPrefix());
        filterRegistration.addInitParameter("serverName", autoconfig.getServerName());
        filterRegistration.setOrder(5);
        return filterRegistration;
    }

    @Bean
    public FilterRegistrationBean httpServletRequestWrapperFilter() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new HttpServletRequestWrapperFilter());
        filterRegistration.setEnabled(true);
        if (autoconfig.getRequestWrapperFilters().size() > 0) {
            filterRegistration.setUrlPatterns(autoconfig.getRequestWrapperFilters());
        } else {
            filterRegistration.addUrlPatterns("/*");
        }
        filterRegistration.setOrder(6);
        return filterRegistration;
    }

    /**
     * 该过滤器使得可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
     * 比如AssertionHolder.getAssertion().getPrincipal().getName()。
     * 这个类把Assertion信息放在ThreadLocal变量中,这样应用程序不在web层也能够获取到当前登录信息
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean assertionThreadLocalFilter() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new AssertionThreadLocalFilter());
        filterRegistration.setEnabled(true);
        if (autoconfig.getAssertionFilters().size() > 0) {
            filterRegistration.setUrlPatterns(autoconfig.getAssertionFilters());
        } else {
            filterRegistration.addUrlPatterns("/*");
        }
        filterRegistration.setOrder(7);
        return filterRegistration;
    }
}

单点登录与单点退出的配置,信息匹配认证过滤器等。比如登录验证过滤器AuthenticationFilter的doFilter,如下:

    public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        // 判断请求是否不需要过滤,就是我们配置spring.cas.ignore-filters属性的地方,表示
        // CAS对该路由不进行拦截,直接放行
        if(this.isRequestUrlExcluded(request)) {
            this.logger.debug("Request is ignored.");
            filterChain.doFilter(request, response);
        } else {
            HttpSession session = request.getSession(false);
            Assertion assertion = session != null?(Assertion)session.getAttribute("_const_cas_assertion_"):null;
            // 如果存在assertion,即认为这是一个已通过认证的请求,予以放行
            if(assertion != null) {
                filterChain.doFilter(request, response);
            } else {
                  // 不存在 assertion,那么就来判断这个请求是否是用来校验ST的(校验通过后会将信息写入assertion)
                String serviceUrl = this.constructServiceUrl(request, response);
                String ticket = this.retrieveTicketFromRequest(request);
                boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
				// 校验ST的请求,是否予以放行
                if(!CommonUtils.isNotBlank(ticket) && !wasGatewayed) {
                    this.logger.debug("no ticket and no assertion found");
                    String modifiedServiceUrl;
                    if(this.gateway) {
                        this.logger.debug("setting gateway attribute in session");
                        modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
                    } else {
                        modifiedServiceUrl = serviceUrl;
                    }
                    this.logger.debug("Constructed service url: {}", modifiedServiceUrl);
                    String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
                    this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
                    this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
                } else {
                    filterChain.doFilter(request, response);
                }
            }
        }
    }

可以看到CAS正是通过session中是否有assertion的信息来判断一个请求是否合法。

而这个assertion信息,当我们在登陆成功后第一次重定向回客户端校验ST之后(这里的客户端指的是后台,此时重定向回客户端的请求附带有ST参数)写入session中的。

票据验证我们配置的是cas30ProxyReceivingTicketValidationFilter,查看源码可以cas30ProxyReceivingTicketValidationFilter继承自Cas20ProxyReceivingTicketValidationFilter。在Cas20ProxyReceivingTicketValidationFilter父类AbstractTicketValidationFilter源码里,我们可以看到对票据验证和设置。

    public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        if(this.preFilter(servletRequest, servletResponse, filterChain)) {
            HttpServletRequest request = (HttpServletRequest)servletRequest;
            HttpServletResponse response = (HttpServletResponse)servletResponse;
            String ticket = this.retrieveTicketFromRequest(request);
            if(CommonUtils.isNotBlank(ticket)) {
                this.logger.debug("Attempting to validate ticket: {}", ticket);

                try {
                   // 验证票据并设置相关属性
                    Assertion e = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
                    this.logger.debug("Successfully authenticated user: {}", e.getPrincipal().getName());
                    request.setAttribute("_const_cas_assertion_", e);
                    if(this.useSession) {
                        request.getSession().setAttribute("_const_cas_assertion_", e);
                    }

                    this.onSuccessfulValidation(request, response, e);
                    if(this.redirectAfterValidation) {
                        this.logger.debug("Redirecting after successful ticket validation.");
                        response.sendRedirect(this.constructServiceUrl(request, response));
                        return;
                    }
                } catch (TicketValidationException var8) {
                    this.logger.debug(var8.getMessage(), var8);
                    this.onFailedValidation(request, response);
                    if(this.exceptionOnValidationFailure) {
                        throw new ServletException(var8);
                    }
                    response.sendError(403, var8.getMessage());
                    return;
                }
            }
            filterChain.doFilter(request, response);
        }
    }

上面的流程看完后,我们知道当第一次重定向回客户端的请求肯定是可以通过CAS的认证的,那么只要这个后续的请求和第一个是同一个session,那就一定可以通过CAS认证。

前面我们也说了,只要请求中的JSESSIONID是一致的,那就会被认定是同一个session。也就是我们只有保证前端JSESSIONID一致即可。

三、实战分析

讲解了那么多,我们还是来实战分析一下。这里我们有一个前后端分离的项目,前端front-demo,基于Ant Design改造,后端client-demo,源用上一次的Spring Boot代码,同理通过Restful Api进行数据交互。

我本地的IP为172.16.67.228,front-demo前端启动8000端口,client-demo后端启动8080端口,CAS服务启动为8443端口。

front

success

这是在没有接入CAS的时候,现在我们更改client-demo,接入CAS。这里为了前端确定是否登录,这里我忽略一个用户信息接口,使得前端可以进行请求,走client-demo原来的校验逻辑,如果未登录就返回401。

spring.cas.ignore-filters=/api/user/info
    private class SecurityInterceptor extends HandlerInterceptorAdapter {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
            HttpSession session = request.getSession();
            if (session != null) {
                System.out.println("requst path " + request.getServletPath());
                Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);

                if (assertion != null) {
                    System.out.println("cas user ---------> " + assertion.getPrincipal().getName());
                }

                User value = (User) session.getAttribute(SESSION_LOGIN);
                if (value != null) {
                    return true;
                }
            }
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }

由于这里是前后端分离,所有我们需要做一些配置。首先然后判断前端是否需要登录,所以我们在CAS忽略登录信息接口/api/user/info,当返回401时,我们进行CAS跳转登录。

if(status === 401){
	window.location.href="https://sso.anumbrella.net:8443/cas/login?service=http://172.16.67.228:8080/api/user/caslogin"
}

这个/api/user/caslogin是CAS登录成功后,后端回调接口。如下:

    @RequestMapping(value = "/caslogin", method = RequestMethod.GET)
    public void caslogin() throws IOException {
        HttpSession session = request.getSession();
        Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
        if (assertion != null) {
            //获取登录用户名
            String username = assertion.getPrincipal().getName();
            System.out.println("user ---------> " + username);
            User temp = userService.findByUsername(username);
            System.out.println("TEMP user ---------> " + (temp.getUsername()));
            if (temp != null) {
                session.setAttribute(WebSecurityConfig.SESSION_LOGIN, temp);
                // 跳转到前端
                response.sendRedirect("http://172.16.67.228:8000”);
            }
        }
    }

接着我们重启服务,发现登录成功。这是因为前端后端在同一域,这里是同一个ip地址下面,前后端分离接入是没啥问题。

接下来我们进行改造,在hosts配置中添加如下:

127.0.0.1		sso.anumbrella.net
127.0.0.1		client.anumbrella.net
127.0.0.1		front.anumbrella.net

让前后端在不同的域下,现在我们更改前面的路径地址,配置为这里的域名。

if(status === 401){
	window.location.href="https://sso.anumbrella.net:8443/cas/login?service=http://client.anumbrella.net:8080/api/user/caslogin"
}
  // 跳转到前端
  response.sendRedirect("http://front.anumbrella.net:8000”);

发现并不能登录,前端页面反复跳转。这是因为后端client.anumbrella.net第一次认证通过了,但前端发起的请求JSESSIONID不一致,认证没通过,返回给我们401,然后死循环了。

也就是说我们现在需要把后端的session的ID也就是JSESSIONID写入前端cookie中。这里提供两种解决方案:

  • 前端手动写入JSESSIONID。通过重定向URL把session的ID给前端,然后让前端写入JESSIONID。
  • 使用nginx代理,让前后端不跨域。用nginx将前后端反向代理到同一个域下,无论是访问前端界面还是调用后端接口还是后端cas filter中的配置都是用这个代理后的地址。
1、通过URL传递

通过URL传参,也就意味着在caslogin方法中,我们需要获取session的id,然后传递给前端。如下:

    @RequestMapping(value = "/caslogin", method = RequestMethod.GET)
    public void caslogin() throws IOException {
        HttpSession session = request.getSession();
        Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
        if (assertion != null) {
            //获取登录用户名
            String username = assertion.getPrincipal().getName();
            System.out.println("user ---------> " + username);
            User temp = userService.findByUsername(username);
            System.out.println("TEMP user ---------> " + (temp.getUsername()));
            if (temp != null) {
                session.setAttribute(WebSecurityConfig.SESSION_LOGIN, temp);
                String jsessionid = session.getId();
                System.out.println("jsessionid ------> " + jsessionid);
                // 跳转到前端
                response.sendRedirect("http://front.anumbrella.net:8000/home?jsessionid=" + jsessionid);
            }
        }
    }

然后再更改一下前端,使得我们在每次请求前判断是否获取到jsessionid,然后写入cookie。

  const jsessionid = getQueryString('jsessionid');
  if (jsessionid) {
    setCookie('JSESSIONID', jsessionid);
  }

重启项目,然后进行登录我们发现依然失败,无法识别!!!为啥?这里就是前面所说的坑,我们浏览器的cookie和我们后端打印的完全不相同,这是为啥?说明我们写入的cookie无效,我们查看cookie可以发现。
cookie

如果cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击,窃取cookie内容,这样就增加了cookie的安全性。也就是我们更新cookie无效,我们可以验证更改cookie名称,发现是可以写入的。

那怎么办?为啥前台会出现JSESSIONID,查阅资料我们知道当服务端调用request.getSession()时就会生成并传递给客户端,此次响应头会包含设置cookie的信息。

HttpSession s = request.getSession(boolean flag);
HttpSession s = request.getSession( );

包含两种方法:

  • flag = true:先从请求中找找看是否有SID,没有会创建新Session对象,有SID会查找与编号对应的对象,找到匹配的对象则返回,找不到SID对应的对象时则会创建新Session对象。所以,填写true就一定会得到一个Session对象。
  • flag= false:不存在SID以及按照SID找不到Session对象时都会返回null,只有根据SID找到对应的对象时会返回具体的Session对象。所以,填写false只会返回已经存在并且与SID匹配上了的Session对象。

因此当我们进行获取session时,设置默认不创建session。更改配置如下:

    private class SecurityInterceptor extends HandlerInterceptorAdapter {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
            HttpSession session = request.getSession(false);
            if (session != null) {
                System.out.println("requst path " + request.getServletPath());

                Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);

                if (assertion != null) {
                    System.out.println("cas user ---------> " + assertion.getPrincipal().getName());
                }
                User value = (User) session.getAttribute(SESSION_LOGIN);
                if (value != null) {
                    return true;
                }
            }
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }

重启服务,登录发现成功!!并且获取到用户信息。

success

2、通过Nginx代理

通过前面的分析我们知道原因所在就好解决问题了。主要是要前端后端的session一致即可。所以我们通过Nginx代理,直接把当前域下的赋值给另一个域,即可实现跨域完成CAS登录。

首先我们在host下配置新域名:

127.0.0.1	  nginx.anumbrella.net

现在我们让前端访问、后端访问以及重定向全部跳转到nginx.anumbrella.net域名下。

我本地nginx配置端口为81,配置前端请求走nginx代理,如下:

  proxy: {
    '/api/user': {
      target: 'http://nginx.anumbrella.net:81',
      changeOrigin: true,
      // pathRewrite: { '^/server': '' },
    },
  },

然后我们更改前端请求401处理逻辑如下:

window.location.href = "https://sso.anumbrella.net:8443/cas/login?service=http://nginx.anumbrella.net:81/api/user/caslogin"

直接跳转到nginx代理,在代理中我们在跳转到http://client.anumbrella.net:8080/api/user/caslogin,但是我们启动登录后在验证票据时会失败,因为这里默认将客户端更改为http://nginx.anumbrella.net:81/api/user/caslogin了,所以在client-demo中,我们需要更改配置,服务名为:

# 使用nginx代理配置地址
spring.cas.server-name=http://nginx.anumbrella.net:81

然后配置nginx.conf文件,完成代理设置,如下:

  server {
        listen       81; 
        # server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            # root   html;
            index  index.html index.htm;
            proxy_pass http://front.anumbrella.net:8000;
            proxy_cookie_domain front.anumbrella.net:8000 nginx.anumbrella.net:81; 
            proxy_pass_header Set-Cookie;
        }

        location /api/user {
            # root   html;
            index  index.html index.htm;
            proxy_set_header Host $http_host;
            proxy_pass http://client.anumbrella.net:8080;
            proxy_cookie_domain client.anumbrella.net:8080 nginx.anumbrella.net:81;
            proxy_pass_header Set-Cookie;
        }

        location /api/user/caslogin {
            # root   html;
            index  index.html index.htm;
            proxy_set_header Host $http_host;
            proxy_pass http://client.anumbrella.net:8080;
            proxy_cookie_domain client.anumbrella.net:8080 nginx.anumbrella.net:81;
            proxy_pass_header Set-Cookie;
        }

     ......
     }

重启nginx和相应服务,输入http://nginx.anumbrella.net:81进行登录,然后可以发现登录成功!! 并有相应cookie值。

nginx

除了以上两种方式,我查阅资料还有让前端去主导CAS票据认证的解决方案,可以参考——前后端分离与CAS单点登录的结合。这个方案还没验证过,后面有空时间测试一下。

如果读者有更优的解决方案,欢迎告知一起学习!!

代码实例:Chapter12

参考

  • 23
    点赞
  • 73
    收藏
    觉得还不错? 一键收藏
  • 13
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值