【vue + springboot】前后端分离项目接入cas单点登录系统

一、环境

cas服务端:4.0.0(这是一个比较老的maven版本,也可以选择新的gradle版本)
tomcat:8.5.99
前端:vue
后端:springboot(cas客户端)

二、cas服务端搭建

  1. tomcat下载:https://dlcdn.apache.org/tomcat/tomcat-8/v8.5.99/bin/apache-tomcat-8.5.99-windows-x64.zip

  2. cas下载:https://github.com/apereo/cas/releases/tag/v4.0.0在这里插入图片描述

  3. 解压 cas-server-4.0.0-release.zip,在 cas-server-4.0.0\modules 目录下找到 cas-server-webapp-4.0.0.war

  4. 为方便访问,重命名 cas-server-webapp-4.0.0.war 为 cas.war

  5. 将 cas.war 放到 tomcat 的 webapps 目录下,启动tomcat

  6. 访问登录页,http://localhost:8080/cas/login 默认用户名:casuser 默认密码:Mellon

  7. 修改端口(可选)

    • 修改tomcat端口,apache-tomcat-8.5.99\conf\server.xml
      在这里插入图片描述
    • 修改cas配置文件,apache-tomcat-8.5.99\webapps\cas\WEB-INF\cas.properties
      在这里插入图片描述
  8. 去除https认证(可选)
    CAS默认使用的是https协议,如果使用https协议需要SSL安全证书(需向特定的机构申请和购买)

    • 修改cas的 WEB-INF/deployerConfigContext.xml
      增加配置:p:requireSecure=“false”
      在这里插入图片描述
    • 修改cas的/WEB-INF/spring-configuration/ticketGrantingTicketCookieGenerator.xml
      修改配置:p:cookieSecure=“false”
      在这里插入图片描述
  9. 新增或修改默认用户
    初始用户、密码配置在 cas\WEB-INF\deployerConfigContext.xml
    在这里插入图片描述

  10. 配置mysql,可以参考 https://blog.csdn.net/zh350229319/article/details/50510209

三、跨域问题解决思路

  1. 后端重写鉴权失败重定向策略,返回特定状态码给前端,由前端跳转登录页
  2. 前端跳转登录页,路由后面拼接 service 和 url
  3. service为登录成功回调客户端接口,由后端提供
  4. url为登录成功后跳转前端页面路由,由前端提供
  5. 登录成功,后端在回调接口中,重定向到前端url并拼接jsessionid
  6. 重定向到前端页面,前端判断地址栏中如果包含jessionid,就写入cookie

四、cas客户端接入

  1. pom.xml新增maven依赖
    <dependency>
        <groupId>net.unicon.cas</groupId>
        <artifactId>cas-client-autoconfig-support</artifactId>
        <version>2.3.0-GA</version>
    </dependency>
    
  2. SpringBoot启动类新增@EnableCasClient注解
    import net.unicon.cas.client.configuration.EnableCasClient;
    
    @EnableCasClient
    public class SpringBootApplication {
    }
    
  3. 配置文件中新增cas客户端配置
    # cas客户端配置
    cas:
      server-login-url: http://{cas_server_ip}/cas/login
      server-url-prefix: http://{cas_server_ip}/cas
      client-host-url: http://{cas_client_ip}:${server.port}
      use-session: true
    
    # 单点登录成功回调cas客户端接口
    cas-client-service: http://{cas_client_ip}:${server.port}/user/check_sso
    # 单点登出地址
    cas-server-logout-url: ${cas.server-url-prefix}/logout
    
  4. 新增cas配置类
    @Slf4j
    @Getter
    @Configuration
    public class CasConfig {
    
        @Value("${cas.server-url-prefix}")
        private String serverUrlPrefix;
        @Value("${cas.server-login-url}")
        private String serverLoginUrl;
        @Value("${cas.client-host-url}")
        private String clientHostUrl;
        @Value("${cas-client-service}")
        private String clientService;
        @Value("${cas-server-logout-url}")
        private String serverLogoutUrl;
    
        /**
         * 单点登录认证不通过,返回特定错误码,由前端跳转单点登录页,避免跨域问题
         */
        public Result<Map<String, String>> redirectLoginPage(CommonError commonError) {
            Result<Map<String, String>> result = new Result<>();
            result.setCode(commonError.getCode());
            result.setMsg(commonError.getMsg());
            Map<String, String> map = new HashMap<>();
            map.put("ssoLoginUrl", serverLoginUrl);
            map.put("service", clientService);
            result.setData(map);
            return result;
        }
    
        /**
         * @see IgnoreSSLValidateFilter
         * 作用:自定义filter用于忽略ssl认证
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean ignoreSSLValidateFilter() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new IgnoreSSLValidateFilter());
            registrationBean.addUrlPatterns("/*");
            registrationBean.setName("ignoreSSLValidateFilter");
            return registrationBean;
        }
    
        /**
         * @see SingleSignOutHttpSessionListener
         * 作用:单点登出监听器
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public ServletListenerRegistrationBean singleSignOutHttpSessionListener() {
            ServletListenerRegistrationBean registrationBean = new ServletListenerRegistrationBean();
            registrationBean.setListener(new SingleSignOutHttpSessionListener());
            registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
            return registrationBean;
        }
    
        /**
         * @see Cas30ProxyReceivingTicketValidationFilter
         * 作用:验证ticket
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean casTicket() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
            registrationBean.addUrlPatterns("/*");
            registrationBean.addInitParameter("encodeServiceUrl", "false");
            registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix);
            registrationBean.addInitParameter("serverName", clientHostUrl);
            registrationBean.addInitParameter("useSession", "true");
    
            registrationBean.setOrder(1);
            return registrationBean;
        }
    
        /**
         * @see AuthenticationFilter
         * 作用:鉴权
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean casAuth() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new AuthenticationFilter());
            registrationBean.addUrlPatterns("/*");
            registrationBean.addInitParameter("encodeServiceUrl", "false");
            // 自定义重定向策略,为解决前后端跨域问题
            registrationBean.addInitParameter("authenticationRedirectStrategyClass", CustomAuthRedirectStrategy.class.getName());
            registrationBean.addInitParameter("casServerLoginUrl", serverLoginUrl);
            registrationBean.addInitParameter("serverName", clientHostUrl);
            // 忽略的url,"|"分隔多个url   "/logout/success|/index"
            String ignorePattern =
                    "/swagger-ui.html|" +
                            "/organization/generate_link_id|" +
                            "/user/regist_user|" +
                            "/user/join_org";
            log.info("sso auth ignorePattern: {}", ignorePattern);
            registrationBean.addInitParameter("ignorePattern", ignorePattern);
    
            registrationBean.setOrder(2);
            return registrationBean;
        }
    
        /**
         * @see SingleSignOutFilter
         * 作用:单点登出
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean casLogout() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new SingleSignOutFilter());
            registrationBean.addUrlPatterns("/*");
            registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix);
            registrationBean.setName("CAS Single Sign Out Filter");
    
            registrationBean.setOrder(3);
            return registrationBean;
        }
    
        /**
         * @see HttpServletRequestWrapperFilter
         * 作用:包装HttpServletRequest以达到增强功能
         * 使用场景:例如添加额外的请求参数、修改请求头信息、重写请求方法等
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean httpServletRequestWrapperFilter() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new HttpServletRequestWrapperFilter());
            registrationBean.addUrlPatterns("/*");
    
            registrationBean.setOrder(4);
            return registrationBean;
        }
    
        /**
         * @see AssertionThreadLocalFilter
         * 作用:将Assert信息放入ThreadLocal中
         * 使用场景:支持以下方式获取用户名:AssertionHolder.getAssertion().getPrincipal().getName()
         */
        @Bean
        @SuppressWarnings({"rawtypes", "unchecked"})
        public FilterRegistrationBean assertionThreadLocalFilter() {
            FilterRegistrationBean registrationBean = new FilterRegistrationBean();
            registrationBean.setFilter(new AssertionThreadLocalFilter());
            registrationBean.addUrlPatterns("/*");
    
            registrationBean.setOrder(5);
            return registrationBean;
        }
    }
    
  5. 自定义cas鉴权失败重定向策略
    public class CustomAuthRedirectStrategy implements AuthenticationRedirectStrategy {
    
        @Override
        public void redirect(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, String s) throws IOException {
            httpServletResponse.setCharacterEncoding("utf-8");
            httpServletResponse.setContentType("application/json; charset=utf-8");
    
            CasConfig casConfig = SpringContextUtil.getBean(CasConfig.class);
            Result<Map<String, String>> result = casConfig.redirectLoginPage(CommonError.SSO_AUTH_FAILURE);
    
            httpServletResponse.getWriter().write(JsonUtils.toJson(result));
        }
    }
    
  6. 自定义filter忽略ssl认证(可选)
    @Slf4j
    public class IgnoreSSLValidateFilter implements Filter {
    
        static {
            // 忽略ssl认证
            try {
                TrustManager[] trustAllCerts = {new X509TrustManager() {
                    public X509Certificate[] getAcceptedIssuers() {
                        return null;
                    }
    
                    public void checkClientTrusted(X509Certificate[] arg0, String arg1) {
                    }
    
                    public void checkServerTrusted(X509Certificate[] arg0, String arg1) {
                    }
                }};
                SSLContext sc = SSLContext.getInstance("SSL");
                sc.init(null, trustAllCerts, new SecureRandom());
                HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
    
                HostnameVerifier allHostsValid = (hostname, session) -> true;
                HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid);
            } catch (NoSuchAlgorithmException | KeyManagementException e) {
                log.error("ignore ssl auth error", e);
            }
        }
    
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            chain.doFilter(request, response);
        }
    
        public void destroy() {
        }
    
        public void init(FilterConfig config) throws ServletException {
        }
    }
    
  7. 单点登录成功回调cas客户端接口
    @Controller
    @RequestMapping("/user")
    @Api(value = "用户管理", tags = "用户管理")
    @Slf4j
    public class UserController {
    
        @Resource
        private CasConfig casConfig;
    
        @WebLog
        @ApiOperation("检查登录状态")
        @GetMapping(value = "/check_sso")
        public Object ssoCheck(HttpServletRequest request, @RequestParam String url) {
            HttpSession session = request.getSession();
            Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
            if (assertion == null) {
                log.info("sso validate failed");
                throw new ServiceException(CommonError.SSO_AUTH_FAILURE);
            }
            String username = assertion.getPrincipal().getName();
            if (StringUtils.isBlank(username)) {
                log.info("sso validate failed");
                throw new ServiceException(CommonError.SSO_AUTH_FAILURE);
            }
    
            String sessionId = session.getId();
            log.info("sso validate successful, username: {}, sssionid: {}", username, sessionId);
            if (url.contains("jsessionid")) {
            	// 如果url中已经存在jsessionid,则去掉
                return new RedirectView(url.substring(0, url.indexOf("jsessionid") - 1));
            }
            return new RedirectView(url + "?jsessionid=" + sessionId);
        }
    
        @WebLog
        @ResponseBody
        @ApiOperation("登录")
        @PostMapping("/auto_login")
        public Result<?> autoLogin(HttpServletRequest request) {
            HttpSession session = request.getSession();
            Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
            if (assertion == null || StringUtils.isBlank(assertion.getPrincipal().getName())) {
                throw new ServiceException(CommonError.SSO_AUTH_FAILURE);
            }
            return Result.ok(userService.autoLogin(assertion.getPrincipal().getName()));
        }
    
        @WebLog
        @ResponseBody
        @ApiOperation("退出登录")
        @PostMapping("/logout")
        public Result<?> logout(HttpSession session) {
            session.invalidate();
            Map<String, String> map = new HashMap<>();
            map.put("logout", casConfig.getServerLogoutUrl());
            return Result.ok(map);
        }
    }
    
  8. 前端核心代码:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

五、问题记录

  1. cas客户端报https认证失败
    报错信息:

    logback- 2024-03-11 16:21:06:816 traceId:NONE [http-nio-8080-exec-2] 
    org.jasig.cas.client.util.CommonUtils:442[getResponseFromServer] ERROR: SSL error getting response from host: 10.5.216.10 : Error Message: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
    javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider
    .certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
       at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131)
       at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:376)
       at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:319)
       at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:314)
       at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:654)
       at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:473)
       at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:369)
       at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:392)
    

    报错原因:cas服务端开启了https认证
    解决方案:自定义filter忽略ssl认证

  2. 前端跨域问题
    报错信息:
    在这里插入图片描述
    报错原因:和cas过滤器顺序有关系
    解决方案:保证下面这三个cas过滤器的顺序
    Cas30ProxyReceivingTicketValidationFilter、AuthenticationFilter、SingleSignOutFilter

  3. service不匹配问题
    报错信息:

    org.jasig.cas.client.validation.TicketValidationException: 
      Ticket 'ST-48-7f5W5TbK6UxvgM27ThUx-cas01.example.org' does not match supplied service. 
      The original service was 'http://10.1.16.252:8080/user/check_sso?redirectUrl=http://10.1.16.158:8080' 
      and the supplied service was 'http://10.1.16.252:8080/user/check_sso?redirectUrl=http%3A%2F%2F10.1.16.158%3A8080'.
       
       at org.jasig.cas.client.validation.Cas20ServiceTicketValidator.parseResponseFromServer(Cas20ServiceTicketValidator.java:84)
       at org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator.validate(AbstractUrlBasedTicketValidator.java:198)
       at org.jasig.cas.client.validation.AbstractTicketValidationFilter.doFilter(AbstractTicketValidationFilter.java:204)
       at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
       at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
       at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
       at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
       at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
       at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
       ....
    

    报错原因:

    cas服务端记录的service是…/user/check_sso?redirectUrl=http://10.1.16.158:8080,
    验证ticket时传给cas服务端的service是…/user/check_sso?redirectUrl=http%3A%2F%2F10.1.16.158%3A8080,
    明显是客户端在ticket验证时对 service 进行了url-encoding,导致ticket验证不通过。

    解决方案:Cas30ProxyReceivingTicketValidationFilter 设置 encodeServiceUrl 为 false

六、原理分析

  1. 核心票据

    • TGC(Ticket Granting Cookie):

      授权的票据证明,由 CAS Server 通过 SSL 方式发送给终端用户,存放用户身份认证凭证的Cookie,在浏览器和CAS Server间通讯时使用,并且只能基于安全通道传输(Https),是CAS Server用来明确用户身份的凭证。

    • TGT(Ticket Granting Ticket):

      TGT是CAS为用户签发的登录票据,拥有了TGT,用户就可以证明自己在CAS成功登录过。TGT封装了Cookie值以及此Cookie值对应的用户信息。用户在CAS认证成功后,CAS生成Cookie(叫TGC),写入浏览器,同时生成一个TGT对象,放入自己的缓存,TGT对象的ID就是Cookie的值。当HTTP再次请求到来时,如果传过来的有CAS生成的Cookie,则CAS以此Cookie值为key查询缓存中有无TGT ,如果有的话,则说明用户之前登录过,如果没有,则用户需要重新登录。

    • ST(Service Ticket):

      ST是CAS为用户签发的访问某一service的票据。用户访问service时,service发现用户没有ST,则要求用户去CAS获取ST。用户向CAS发出获取ST的请求,如果用户的请求中包含Cookie,则CAS会以此Cookie值为key查询缓存中有无TGT,如果存在TGT,则用此TGT签发一个ST,返回给用户。用户凭借ST去访问service,service拿ST去CAS验证,验证通过后,允许用户访问资源。

  2. 单点登录过程

    • 打开浏览器,访问client1:http://10.5.216.121/cc/index.php,未登录状态自动跳转登录页。
      在这里插入图片描述

    • 输入用户名、密码登录后,cas-server回调client service接口,响应头中携带TGC、ST存入浏览器。
      在这里插入图片描述
      在这里插入图片描述

    • 接着访问client2:http://10.5.218.101
      在这里插入图片描述
      client2携带浏览器TGC请求cas-server,cas-server根据TGC(SessionID)去查找是否有对应的TGT(Session),
      如果有,用户就不需要再登录(SSO的体现),而cas-server会根据TGT签发新的ST,并重定向到client2
      在这里插入图片描述
      携带JsessionId重定向到目标页面:
      在这里插入图片描述
      在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值