一、简介
经过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
问:为什么要把 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 受限资源时,跳转到登陆界面
出现上面原因是因为我的证书是自己生成的
先给授权下,然后再刷新就好了
跳转,用了笨方法做了一个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