假设有6个单独的子项目A、B、C、D、E、F,都有各自的客户端登录界面(6个),现在要实现SSO效果,所以加上了一个CAS-Server服务
我想实现的效果是:登陆界面还是在客户端(不是在Server端增加主题登录界面)实现【同域名、不同域名】之间的SSO
举例:
1、当我访问子项目 A 的受保护资源时,跳转到 A 的登录界面。(其他子项目同理)
2、子项目 A 登录界面输入用户名,密码实现 CAS登录成功。
3、当我访问任意子项目(B、C、D、E、F)的受保护资源时,用于 A 已经登录过了,所以可以直接访问
4、当我在任意子项目(B、C、D、E、F)登出时,全局实现登出效果。
一、系统架构
统一使用 SpringBoot+Meven,app1 和 app2 内容一致、client1 和 client2 内容一致
文件名 | 域名 | 功能 |
---|---|---|
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 |
sso-server | http://sso.server.com:8889/sso | 自定义SSO服务,管理单点登录的用户 |
cas-server-rest | http://cas.server.com:8484/cas | CAS-Server |
本地hosts文件配置如下
127.0.0.1 cas.server.com 127.0.0.1 sso.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-server中的TGT管理,单独使用自定义的sso-server进行管理了。
这种方式,个人感觉,比之前写的iframe方式要好很多。与不同建议和方案,请在底部留言 谢谢!。
操作流程
1、用户访问受限资源 http://app1.com:8181/fire/books.html
2、由于未登录,跳转自定义的登录界面 http://app1.com:8181/fire/login.html?service=http%3A%2F%2Fapp1.com%3A8181%2Ffire%2Fbooks.html
3、登录页面首先向 sso-server 发起 jsonp 的登录验证请求(提交sso-server域名下的cookie),根据 Cookie 判断是否登录过
未登录返回:jQuery33109023589333109241_1524470965614({"status":0,"data":"nothing"})
登录过返回:jQuery33109023589333109241_1524470965614({"status":1,"data":"http://app1.com:8181/fire/books.html?ticket=ST值"})
(1)未登录
4、未登录,渲染登录表单,用户进行username、password、service 登录,登录地址提交到 sso-server 的 login 接口
5、login 接口通过账号密码调用 cas-server 服务的 v1/tickets 接口,获取 TGT,然后根据用户名规则,将 TGT 保存到 Cookie 和 Redis 缓存中
6、login 接口通过 TGT 获取 ST,拼接成 http://app1.com:8181/fire/books.html?ticket=ST值,进行redirect 该字符串作为响应
7、http://app1.com:8181/fire/books.html?ticket=ST值 由 app 中的 cas-client 过滤器进行验证 ST 的正确性
8、验证通过,建立成功的SessionId
(2)已登录
4、已登录,接收 jsonp 响应的值 http://app1.com:8181/fire/books.html?ticket=ST值,并进行 JS 的重定向
5、http://app1.com:8181/fire/books.html?ticket=ST值 由 app 中的 cas-client 过滤器进行验证 ST 的正确性
6、验证通过,建立成功的SessionId
二、实战
1、cas-server 配置
pom.xml 配置
如果需要使用rest的请求方式,就需要添加下面的依赖。
<!--开启cas server的rest支持--> <dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-rest</artifactId> <version>${cas.version}</version> </dependency>
2、sso-server 配置
pom.xml配置
一些常规依赖,主要是redis
<!--HttpClient--> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.3</version> </dependency> <!--Gson--> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.8.2</version> </dependency> <!--redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
CasConfig.java
package com.tingfeng.config; public class CasConfig { /** * CAS登录地址的token */ public static String GET_TOKEN_URL = "https://cas.server.com:8443/cas/v1/tickets"; /** * 设置Cookie的有效时长(1小时) */ public static int COOKIE_VALID_TIME = 1 * 60 * 60; /* * 设置Cookie的有效时长(1小时) */ public static String COOKIE_NAME = "UToken"; }
UserController.java
package com.tingfeng.controller; import com.google.gson.Gson; import com.tingfeng.config.CasConfig; import com.tingfeng.server.TgtServer; import com.tingfeng.utils.CasServerUtil; import com.tingfeng.viewmodel.res.UserCheckResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Controller @RequestMapping("/user") public class UserController { @Autowired private TgtServer tgtServer; /** * CAS 登录授权 */ @PostMapping("/login") public Object login(HttpServletRequest request, HttpServletResponse response) throws Exception { String username = request.getParameter("username"); String password = request.getParameter("password"); String service = request.getParameter("service"); System.out.println("username:" + username + ",password:" + password + ",service:" + service); // 1、获取 TGT String tgt = CasServerUtil.getTGT(username, password); System.out.println("TGT:" + tgt); // 2、获取 ST String st = CasServerUtil.getST(tgt, service); System.out.println("ST:" + st); if (tgt == null || st==null){ return new ResponseEntity("用户名或密码错误。", HttpStatus.BAD_REQUEST); } // 3、设置cookie(1小时) Cookie cookie = new Cookie(CasConfig.COOKIE_NAME, username + "@" + tgt); cookie.setMaxAge(CasConfig.COOKIE_VALID_TIME); // Cookie有效时间 cookie.setPath("/"); // Cookie有效路径 cookie.setHttpOnly(true); // 只允许服务器获取cookie response.addCookie(cookie); // 4、将当前用户的TGT信息存储在Redis上 tgtServer.setTGT(username, tgt, CasConfig.COOKIE_VALID_TIME); // 5、302重定向最后授权 String redirectUrl = service + "?ticket=" + st; System.out.println("redirectUrl:" + redirectUrl); return "redirect:" + redirectUrl; } /** * 检查用户是否登录过 */ @RequestMapping("/check") @ResponseBody public String checkLoginUser(HttpServletRequest request) throws Exception { String service = request.getParameter("service"); String callback = request.getParameter("callback"); Cookie[] cookies = request.getCookies(); String username = null; String tgt = null; UserCheckResponse result = new UserCheckResponse(); if (cookies != null) { System.out.println(new Gson().toJson(cookies)); for (Cookie cookie : request.getCookies()) { if (cookie.getName().equals(CasConfig.COOKIE_NAME)) { username = cookie.getValue().split("@")[0]; tgt = cookie.getValue().split("@")[1]; break; } } if (username != null) { // 获取Redis值 String value = tgtServer.getTGT(username); System.out.println("Redis value:" + value); // 匹配Redis中的TGT与Cookie中的TGT是否相等 if (tgt.equals(value)) { // 获取 ST String st = CasServerUtil.getST(tgt, service); System.out.println("ST:" + st); result.setStatus(1); result.setData(service + "?ticket=" + st); } } } System.out.println("callback:" + callback); String tmp = callback + "(" + new Gson().toJson(result) + ")"; System.out.println("result:" + tmp); return tmp; } /** * 因为TGT在SSO服务端维护,并不在CAS-Server,所以只需要想办法把redis中匹配的tgt信息删除即可。 */ @GetMapping("/logout") @ResponseBody public String logout(HttpServletRequest request) { String callback = request.getParameter("callback"); Cookie[] cookies = request.getCookies(); String username = null; String tgt = null; if (cookies != null) { System.out.println(new Gson().toJson(cookies)); for (Cookie cookie : request.getCookies()) { if (cookie.getName().equals(CasConfig.COOKIE_NAME)) { username = cookie.getValue().split("@")[0]; tgt = cookie.getValue().split("@")[1]; break; } } if (username != null) { // 获取Redis值 String value = tgtServer.getTGT(username); System.out.println("Redis value:" + value); // 匹配Redis中的TGT与Cookie中的TGT是否相等 if (tgt.equals(value)) { // 删除TGT tgtServer.delTGT(username); } } } System.out.println("callback:" + callback); String tmp = callback + "({'code':'0','msg':'登出成功'})"; System.out.println("result:" + tmp); return null; } }
TgtServer.java
package com.tingfeng.server; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; /** * 通过 Redis 存储/获取/删除 TGT 数据 */ @Component public class TgtServer { @Autowired private StringRedisTemplate stringRedisTemplate; /** * 设置用户TGT到Redis * * @param username * @param tgt * @param time * @return */ public void setTGT(String username, String tgt, long time) { ValueOperations<String, String> operations = stringRedisTemplate.opsForValue(); String value = operations.get(username); if (StringUtils.isNotBlank(value)) { System.out.println("用户:" + username + " 缓存中旧值:" + value + " 替换为新值:" + tgt); } operations.set(username, tgt, time, TimeUnit.SECONDS); } /** * 获取 TGT * * @param username * @return */ public String getTGT(String username) { ValueOperations<String, String> operations = stringRedisTemplate.opsForValue(); String value = operations.get(username); if (StringUtils.isNotBlank(value)) { return value; } return null; } /** * 删除 TGT * * @param username * @return */ public void delTGT(String username) { stringRedisTemplate.delete(username); } }
CasServerUtil.java
更详细,见github源码
package com.tingfeng.utils; import com.tingfeng.config.CasConfig; import org.apache.http.Header; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; import org.apache.http.client.CookieStore; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.conn.socket.LayeredConnectionSocketFactory; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.impl.client.BasicCookieStore; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.message.BasicNameValuePair; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.List; /** * CAS - Server通信服务 */ public class CasServerUtil { public static void main(String[] args) { try { // String tgt = getTGT("tingfeng", "tingfeng"); // System.out.println("TGT:" + tgt); // // String service = "http://app1.com:8181/fire/users.html"; // String st = getST(tgt, service); // System.out.println("ST:" + st); // // System.out.println(service + "?ticket=" + st); } catch (Exception e) { e.printStackTrace(); } } /** * 获取TGT */ public static String getTGT(String username, String password) { try{ CookieStore httpCookieStore = new BasicCookieStore(); CloseableHttpClient client = HttpClients.createDefault(); HttpPost httpPost = new HttpPost(CasConfig.GET_TOKEN_URL); List<NameValuePair> params = new ArrayList<NameValuePair>(); params.add(new BasicNameValuePair("username", username)); params.add(new BasicNameValuePair("password", password)); httpPost.setEntity(new UrlEncodedFormEntity(params)); HttpResponse response = client.execute(httpPost); Header headerLocation = response.getFirstHeader("Location"); String location = headerLocation == null ? null : headerLocation.getValue(); System.out.println("Location:" + location); if (location != null) { return location.substring(location.lastIndexOf("/") + 1); } }catch (Exception e){ e.printStackTrace(); } return null; } /** * 获取ST */ public static String getST(String TGT, String service){ try { CloseableHttpClient client = HttpClients.createDefault(); HttpPost httpPost = new HttpPost(CasConfig.GET_TOKEN_URL + "/" + TGT); List<NameValuePair> params = new ArrayList<NameValuePair>(); params.add(new BasicNameValuePair("service", service)); httpPost.setEntity(new UrlEncodedFormEntity(params)); HttpResponse response = client.execute(httpPost); String st = readResponse(response); return st == null ? null : (st == "" ? null : st); }catch (Exception e){ e.printStackTrace(); } return null; } /** * 读取 response body 内容为字符串 * * @param response * @return * @throws IOException */ private static String readResponse(HttpResponse response) throws IOException { BufferedReader in = new BufferedReader(new InputStreamReader(response.getEntity().getContent())); String result = new String(); String line; while ((line = in.readLine()) != null) { result += line; } return result; } }
3、cas-app 和 cas-client 配置
cas-app与cas-client配置与之前写的文章大致相同,
cas-app 主要负责前端页面
cas-client 主要提供接口api。
主要改动了一下login.html登录页面。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>App1 登录界面</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 service = GetQueryString("service"); // 如果为空,表示直接进入登录页面 if (service == null) { service = "http://app1.com:8181/fire/index.html"; } console.info("service:" + service); $("#service").val(service); // 受访受限url的地址 // 新进行判断用户是否登录过 $.ajax({ method: "GET", url: "http://sso.server.com:9000/sso/user/check", data: { 'service': service }, xhrFields: { withCredentials: true }, crossDomain: true, dataType: "jsonp", jsonp: "callback", // cache: false, success: function(result) { console.info("请求成功"); console.info(result); if (result.status == 1) { // 设置 302 重定向跳转 window.location.href = result.data; } else { // 显示登录页面 $("#loginDiv").show("slow"); } }, error: function(data) { console.info("请求失败"); $("#loginDiv").show("slow"); } }); }); </script> <body> <h2>App1 用户登录</h2> <div id="loginDiv" style="display: none"> <form action="http://sso.server.com:9000/sso/user/login" method="post"> <table> <tr> <td>用户名:</td> <td><input id="username" name="username" type="text" ></td> </tr> <tr> <td>密 码:</td> <td><input id="password" name="password" type="password"></td> </tr> <tr> <td> <input type="hidden" name="service" id="service" value=""> <input type="submit" value="登录"> </td> <td><input type="reset"></td> </tr> </table> </form> </div> </body> </html>
三、测试效果
访问,跳转自定义登录页面。check首先进行验证。
登录成功,302进行st验证。
验证成功,生成sessionid
并且生成sso-server域名下的cookie缓存
观察redis中是否有存储TGT值
app2登录时,发送jsonp请求,自动携带sso-server域下的cooke,完整校验和重定向操作。
特别说明
比较推荐在生产环境,将存储Redis的规则进行修改,比如根据用户请求的浏览器信息、ip地址、uuid、账号信息等,进行md5加密,生成短密码值当作key,而不是我这里直接使用的username作为key。
从cookie中获取之后再进行解密完成流程即可。
我的源码
https://github.com/X-rapido/CAS_SSO_Record
视频效果演示
视频地址:https://v.qq.com/x/page/w0614c07580.html