Cas 5.2.x版本使用 —— 客户端使用iframe嵌套方式实现SSO(十三)

一、简介

经过Cas 5.2.x版本使用 —— 实现SSO单点登录(九) 和 Cas 5.2.x版本使用 —— 自定义登录界面 / 自定义主题风格(十二)文章介绍,我们的登录跳转都是302到了cas-server端的界面。

现在有这么一个需求,假设我现在有6个子系统 A、B、C、D、E、F 需要接入CAS的单点登录。这几个系统相互之间没有太多联系,在没有接入单点登录之前,各自都有自己风格的登录界面。

那么,以往的做法我们需要在CAS-Server中创建6个不同的主题来实现SSO。(所以cas提供了自定义风格方案)

相比之下,我更倾向于授权时依然使用子系统原有的登录界面。稍作改动实现单点需求。

这样好处就是,

  • cas-server服务端不需要过多更改,不懂webflow语法的朋友不至于陷入困境。

  • 客户端随意更改不牵连cas服务端,比如A系统修改背景图片,修改字体颜色。改完直接上线即可。cas服务端并不干预。因为cas服务一旦重启肯定会牵连其他子系统的登录认证;

备注:这几篇文章根据自身理解,并不一定正确,所以如果您有好的CAS前后端分离的单点登录架构,请在底部留言或给我联系。互相学习互相成长,感激不尽。

二、项目介绍

这篇文章介绍的简单的前后端分离sso实现,很简单,2个app1前端,用于浏览器访问,2个Restful Api后端,用于提供接口数据,一个cas-server服务器

前端就是html页面,所有数据来自后端api接口提供,只是把cas-client设置在了前端而已,由于自己对nodejs掌握不够深入,暂且是用这种方式前后端分离吧。

在观察淘宝、天猫、阿里巴巴的登录界面时发现,其实他们也是用到了Iframe嵌套方式实现SSO,嵌套的URL中,根据参数来区分不同的风格

个人根据这个启发做了这篇文章的尝试。仅供学习参考。至于线上使用,慎重考虑。

这种方式有点讨巧,是吧CAS的TGC和ST验证,在Iframe中进行了,可自行通过浏览器debug查看整个过程。

主要难点,iframe 中登录完成之后,如何跳转回来。

WX20180420-121544@2x.png

比较推荐做法是,先观察理解下淘宝和京东的SSO实现。当然,底部已经提供了相关文章,自行打开阅读。

项目名称 访问地址 功能
cas-app1 http://app1.com:8181/fire 客户端1(浏览器访问)[cas-client在此]
cas-app2 http://app2.com:8282/water 客户端2(浏览器访问)[cas-client在此]
cas-client1 http://client1.com:8888/ 后端接口1
cas-client2 http://client2.com:8889/ 后端接口2
cas-server-rest http://cas.server.com:8484/cas CAS-Server
  • app1 和 app2 内容一致

  • client1 和 client2 内容一致

本地hosts文件配置如下

127.0.0.1    cas.server.com
127.0.0.1    app1.com 
127.0.0.1    app2.com 
127.0.0.1    client1.com 
127.0.0.1    client2.com

问:为什么要把 cas-client 放在了 app 前端,而不是 client 后端?

答:后端 api 接口功能,只生产数据即可,不用关注太多业务上的逻辑(实际上,CAS也是建议把cas-client设置在你所访问请求的一方)。

三、实战

cas 服务端

1、application.properties 配置

客户端自定义登录的 login.html 页面,然后使用iframe方式嵌套了cas-server 的自定义登录界面时,默认是不允许的,会出现X-Frame-Options错误

Refused to display 'https://cas.server.com:8443/cas/login?service=http://app1.com:8181/api/hello' in a frame because it set 'X-Frame-Options' to 'deny'.

默认CAS中,将X-Frame-Options设置为了deny

解决方案:在application.properties文件中,加入下面配置

##
# CAS Authentication Credentials
#
cas.authn.accept.users=tingfeng::tingfeng

#取消x-frame-options为deny限制,允许外部项目使用iframe嵌入cas-server登录页面
cas.httpWebRequest.header.xframe=false

2、casLoginView.html 登录界面

在JavaScript中增加来源,用于登录之后跳转

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <title th:text="${#themes.code('pageTitle')}"></title>
    <link rel="stylesheet" th:href="@{${#themes.code('css.file')}}" />
</head>

<body>
<h3 th:text="${#themes.code('pageTitle')}"></h3>
<div>
    <form id="loginForm" method="post" th:object="${credential}">
        <div th:if="${#fields.hasErrors('*')}"><span th:each="err : ${#fields.errors('*')}" th:utext="${err}" />
        </div>
        <h4 th:utext="#{screen.welcome.instructions}"></h4>
        <section class="row">
            <label for="username" th:utext="#{screen.welcome.label.netid}" />
            <div th:unless="${openIdLocalId}">
                <input class="required" id="username" size="25" tabindex="1" type="text" th:disabled="${guaEnabled}" th:field="*{username}" th:accesskey="#{screen.welcome.label.netid.accesskey}" autocomplete="off" th:value="casuser" />
            </div>
        </section>
        <section class="row">
            <label for="password" th:utext="#{screen.welcome.label.password}" />
            <div>
                <input class="required" type="password" id="password" size="25" tabindex="2" th:accesskey="#{screen.welcome.label.password.accesskey}" th:field="*{password}" autocomplete="off" th:value="Mellon" />
            </div>
        </section>

        <section>
            <input type="hidden" name="execution" th:value="${flowExecutionKey}"/>
            <input type="hidden" name="_eventId" value="submit"/>
            <input type="hidden" name="geolocation"/>
            <input class="btn btn-submit btn-block"
                   accesskey="l"
                   th:value="#{screen.welcome.button.login}"
                   tabindex="6"
                   type="button" onclick="login()"/>
        </section>
    </form>
</div>
<script>
    var targetUrl = '';
    window.onload = function () {
        targetUrl = window.location.search.split('=')[1];
        console.log('来自父窗口:', targetUrl);
    };

    function login() {
        document.getElementById('loginForm').submit();
        parent.postMessage(JSON.stringify({target: targetUrl}), '*');
    }

</script>
</body>
</html>

app 客户端

项目结构

➜  src tree
.
└── main
    ├── java
    │   └── com
    │       └── tingfeng
    │           ├── AppRun.java (程序入口)
    │           ├── cas
    │           │   ├── auth
    │           │   │   └── SimpleUrlPatternMatcherStrategy.java (不拦截过滤)
    │           │   └── config
    │           │       └── CasConfig.java (常用配置)
    │           ├── controller
    │           │   └── HelloController.java (api接口地址)
    │           ├── domain
    │           │   └── User.java (普通用户Bean)
    │           └── utils
    │               └── HttpClientProxyUtil.java (http请求工具)
    ├── resources
    │   └── application.yml
    └── webapp
        ├── assets
        │   └── js
        │       └── common.js
        ├── books.html (拦截)
        ├── hello.html (不拦截)
        ├── index.html (不拦截)
        ├── login.html (不拦截)
        ├── users.html (拦截)
        └── world.html (不拦截)

14 directories, 14 files

login.html 登录界面

JavaScript中接受用于登录后的跳转

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录界面</title>
</head>

<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script src="assets/js/common.js"></script>
<script>

 $(document).ready(function () {

        var cas_loginUrl = "https://cas.server.com:8443/cas/login";
 var service = GetQueryString("service");

 if (service == null) {
            $('#myIframe').attr('src', cas_loginUrl);
 } else {
            cas_loginUrl = cas_loginUrl + "?service=" + service;
 $('#myIframe').attr('src', cas_loginUrl);
 }
    });

</script>
<body>

<h2>Iframe方式嵌入Cas Server自定义登录页</h2>
<iframe id="myIframe" src="" width="1000px" style="height: 800px;">

</iframe>
<script>
 //接收子窗口消息
 window.addEventListener("message", function (e) {
        console.info('来自子窗口:', e);
 setTimeout(function () {
            window.location.replace(decodeURIComponent(JSON.parse(e.data).target));// 必须decodeURIComponent页面才刷新,否则有问题
 }, 1500)
    }, false);
</script>
</body>
</html>

AppRun.java

SpringBoot程序入口,包含cas-client.jar的过滤器配置信息(前后端分离,只拦截了 xxx.html 的文件,并不是拦截所有或api接口url)

package com.tingfeng;

import com.tingfeng.cas.config.CasConfig;
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.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
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.core.Ordered;
import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

import java.util.EventListener;
import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
public class AppRun {
    private static final String ENCODING = "UTF-8";

    /*************************************   SSO配置-开始   ************************************************/

    /**
     * SingleSignOutHttpSessionListener 添加监听器
     * 用于单点退出,该过滤器用于实现单点登出功能,可选配置
     *
     * @return
     */
    @Bean
    public ServletListenerRegistrationBean<EventListener> singleSignOutListenerRegistration() {
        ServletListenerRegistrationBean<EventListener> registrationBean = new ServletListenerRegistrationBean<EventListener>();
        registrationBean.setListener(new SingleSignOutHttpSessionListener());
        registrationBean.setOrder(1);
        return registrationBean;
    }

    /**
     * SingleSignOutFilter 登出过滤器
     * 该过滤器用于实现单点登出功能,可选配置
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean filterSingleRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new SingleSignOutFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        Map<String, String> initParameters = new HashMap();
        initParameters.put("casServerUrlPrefix", CasConfig.CAS_SERVER_LOGIN_PATH);
        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }


    /**
     * AuthenticationFilter 授权过滤器
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean filterAuthenticationRegistration() {

        FilterRegistrationBean registration = new FilterRegistrationBean();
        Map<String, String> initParameters = new HashMap();

        registration.setFilter(new AuthenticationFilter());
        registration.addUrlPatterns("*.html");
        initParameters.put("casServerLoginUrl", CasConfig.APP_LOGIN_PAGE);
        initParameters.put("serverName", CasConfig.SERVER_NAME);

        // 不拦截的请求
        initParameters.put("ignorePattern", "^.*[.](js|css|gif|png|zip)$");

        // 表示过滤所有
        initParameters.put("ignoreUrlPatternType", "com.tingfeng.cas.auth.SimpleUrlPatternMatcherStrategy");

        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }


    /**
     * Cas30ProxyReceivingTicketValidationFilter 验证过滤器
     * 该过滤器负责对Ticket的校验工作,必须启用它
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean filterValidationRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("*.html");
        Map<String, String> initParameters = new HashMap();
        initParameters.put("casServerUrlPrefix", CasConfig.CAS_SERVER_PATH);
        initParameters.put("serverName", CasConfig.SERVER_NAME);
        initParameters.put("useSession", "true");
        initParameters.put("redirectAfterValidation", "true");
        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * HttpServletRequestWrapperFilter wraper过滤器
     * 该过滤器负责实现HttpServletRequest请求的包裹,
     * 比如允许开发者通过HttpServletRequest的getRemoteUser()方法获得SSO登录用户的登录名,可选配置。
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean filterWrapperRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new HttpServletRequestWrapperFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }


    /*************************************   SSO配置-结束   ************************************************/


    /**
     * CharacterEncodingFilter 编码过滤器
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean filterEncodeRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new CharacterEncodingFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        Map<String, String> initParameters = new HashMap();
        initParameters.put("encoding", ENCODING);
        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * 设定首页
     */
    @Configuration
    public class DefaultView extends WebMvcConfigurerAdapter {

        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            //设定首页为index
            registry.addViewController("/").setViewName("forward:/index.html");

            //设定匹配的优先级
            registry.setOrder(Ordered.HIGHEST_PRECEDENCE);

            //添加视图控制类
            super.addViewControllers(registry);
        }
    }

    public static void main(String[] args) {
        SpringApplication.run(AppRun.class, args);
    }
}

books.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>图书列表数据</title>
</head>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script src="assets/js/common.js"></script>
<script>
    function load() {
        $.ajax({
            type: "GET",
            async: false,
            cache: false,
            url: getRootPath() + "/api/books",
            success: function (msg) {
                console.info("请求成功");
                console.info(msg);
                $.each(msg, function (i, item) {
                    $("#msg").append(JSON.stringify(item)).append('<br/>');
                });
            },
            error: function (msg) {
                console.info("请求Error");
                console.info(msg);
            }
        });
    }
</script>
<body onload="load()">
访问 books.html
<h1>图书列表</h1>
<div id="msg"></div>
</body>
</html>

HelloController.java

package com.tingfeng.controller;

import com.google.gson.Gson;
import com.tingfeng.domain.User;
import com.tingfeng.utils.HttpClientProxyUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api")
public class HelloController {

    private static String API_BASE_URL = "http://client1.com:8888/api";

    @GetMapping("/hello")
    public String hello() {
        return "前端 Hello 接口响应";
    }

    @GetMapping("/world")
    public String world() {

        String result = HttpClientProxyUtil.sendGet(API_BASE_URL + "/world", "");
        System.out.println("Client1 接口响应结果:" + result);

        return result;
    }

    @GetMapping("/users")
    public List<User> users() {
        String result = HttpClientProxyUtil.sendGet(API_BASE_URL + "/users", "");
        System.out.println("Client1 接口响应结果:" + result);

        if (null != result && !result.equals("")) {
            Gson gson = new Gson();
            List<User> userList = gson.fromJson(result, List.class);
            return userList;
        }

        return null;
    }

    @GetMapping("/books")
    public List<String> books() {

        String result = HttpClientProxyUtil.sendGet(API_BASE_URL + "/books", "");
        System.out.println("Client1 接口响应结果:" + result);

        if (null != result && !result.equals("")) {
            Gson gson = new Gson();
            List<String> nameList = gson.fromJson(result, List.class);
            return nameList;
        }

        return null;
    }

}

测试演示

访问到books.html 受限资源时,跳转到登陆界面

WX20180420-143935@2x.png

出现上面原因是因为我的证书是自己生成的

WX20180420-144036@2x.png

先给授权下,然后再刷新就好了

WX20180420-143541@2x.png

跳转,用了笨方法做了一个setTimeout函数

Untitled.gif

我的源码

https://github.com/X-rapido/CAS_SSO_Record/tree/master/iframe-sso

视频完整演示

http://v.qq.com/x/page/p0614wjt2gy.html

参考文档

阿里(淘宝、天猫、一淘)、京东SSO分析

https://apereo.github.io/cas/5.2.x/installation/Configuration-Properties.html#http-web-requests

未经允许请勿转载:程序喵 » Cas 5.2.x版本使用 —— 客户端使用iframe嵌套方式实现SSO(十三)

点  赞 (4) 打  赏
分享到: