-
Notifications
You must be signed in to change notification settings - Fork 145
客户端使用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 中登录完成之后,如何跳转回来。
比较推荐做法是,先观察理解下淘宝和京东的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
答:后端 api 接口功能,只生产数据即可,不用关注太多业务上的逻辑(实际上,CAS也是建议把cas-client设置在你所访问请求的一方)。
客户端自定义登录的 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
在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>
➜ 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
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>
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);
}
}
<!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>
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 受限资源时,跳转到登陆界面
出现上面原因是因为我的证书是自己生成的
先给授权下,然后再刷新就好了
跳转,用了笨方法做了一个setTimeout函数
https://github.com/X-rapido/CAS_SSO_Record/tree/master/iframe-sso
http://v.qq.com/x/page/p0614wjt2gy.html
https://apereo.github.io/cas/5.2.x/installation/Configuration-Properties.html#http-web-requests
择善人而交,择善书而读;择善言而听,择善行而从。
- 首页
- SpringBoot内部运行(一)
- 外部Tomcat运行(二)
- 修改默认的用户名和密码(三)
- 通过yml的方式配置(四)
- 单点登录JDBC认证(五)
- 自定义密码验证(六)
- 单点登录自定义REST认证(七)
- Debug调试源码(八)
- 实现SSO单点登录(九)
- 退出登录后跳转到指定页面(十)
- Service配置介绍(十一)
- 自定义登录界面 / 自定义主题风格(十二)
- 客户端使用iframe嵌套方式实现SSO(十三)
- 代理认证实现SSO(十四)
- 代理认证拓展理解(十五)
- 存储Ticket到redis(十六)
- Restful API 方式验证Ticket(十七)
- Restful API 方式实现SSO(十八)
- 配置 Swagger API 集成(十九)
- 通过邮箱重置用户密码(二十)