假设有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
未经允许请勿转载:程序喵 » Cas 5.2.x版本使用 —— Restful API 方式实现SSO(十八)
程序喵