feat(sso): 补全最新版 SSO NoSdk 模式实现

This commit is contained in:
click33
2026-04-01 05:25:48 +08:00
parent 47dff7059d
commit c0f781a9fb
6 changed files with 270 additions and 218 deletions

View File

@@ -47,6 +47,18 @@ sa-token:
push-url: http://sa-sso-client1.com:9003/sso/pushC
# 接口调用秘钥 (如果不配置则使用全局默认秘钥)
secret-key: SSO-C3-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
# 应用 sso-client3-nosdk采用 NoSdk 模式对接 (不依赖 Sa-Token 客户端 SDK手动实现协议)
sso-client3-nosdk:
# 应用名称
client: sso-client3-nosdk
# 允许授权地址
allow-url: "*"
# 是否接收消息推送
is-push: true
# 消息推送地址
push-url: http://sa-sso-client1.com:9004/sso/pushC
# 接口调用秘钥 (如果不配置则使用全局默认秘钥)
secret-key: SSO-C3-NoSdk-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
# 应用 sso-client3-resdk采用 ReSdk 模式对接
sso-client3-resdk:
# 应用名称

View File

@@ -16,9 +16,6 @@ public class SaSsoClientNoSdkApplication {
System.out.println("测试访问应用端三: http://sa-sso-client3.com:9004");
System.out.println("测试前需要根据官网文档修改hosts文件测试账号密码sa / 123456");
System.out.println();
System.err.println("自 v1.43.0 版本起Sa-Token SSO 不再维护 NoSdk 示例,此项目仅做留档");
System.err.println("如您需要非 Sa-Token 技术栈项目接入 SSO-Server 认证中心,请参考 ReSdk 版本示例");
}
}

View File

@@ -1,22 +1,22 @@
package com.pj.sso;
import java.io.IOException;
import java.util.Objects;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.pj.sso.util.AjaxJson;
import com.pj.sso.util.MyHttpSessionHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.pj.sso.util.AjaxJson;
import com.pj.sso.util.MyHttpSessionHolder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* SSO Client端 Controller
* SSO Client端 Controller
* @author click33
*/
@RestController
@@ -25,162 +25,166 @@ public class SsoClientController {
// SSO-Client端首页
@RequestMapping("/")
public String index(HttpSession session) {
String str = "<h2>Sa-Token SSO-Client 应用端</h2>" +
"<p>当前会话登录账号:" + session.getAttribute("userId") + "</p>" +
"<p><a href=\"javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);\">登录</a>" +
" <a href='/sso/logout?back=' + + encodeURIComponent(location.href);>注销</a>" +
Object userId = session.getAttribute("userId");
String str = "<h2>Sa-Token SSO-Client 应用端 (模式三-NoSdk)</h2>" +
"<p>当前会话是否登录:" + (userId != null) + " (" + userId + ")</p>" +
"<p><a href=\"javascript:location.href='/sso/login?back=' + encodeURIComponent(location.href);\">登录</a>" +
" <a href='/sso/logout?back=' + + encodeURIComponent(location.href);>注销</a>" +
" <a href='/sso/myInfo' target=\"_blank\">获取资料</a></p>";
return str;
}
// SSO-Client端单点登录地址
// SSO-Client端单点登录地址
@RequestMapping("/sso/login")
public Object ssoLogin(String ticket, @RequestParam(defaultValue = "/") String back,
public Object ssoLogin(String ticket, @RequestParam(defaultValue = "/") String back,
HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
// 如果已经登录,则直接返回
if(session.getAttribute("userId") != null) {
// 如果已经登录,则直接返回
if (session.getAttribute("userId") != null) {
response.sendRedirect(back);
return null;
}
/*
* 此时有两种情况:
* 情况1ticket无值说明此请求是Client端访问需要重定向至SSO认证中心
* 情况2ticket有值说明此请求从SSO认证中心重定向而来需要根据ticket进行登录
* 此时有两种情况:
* 情况1ticket无值说明此请求是Client端访问需要重定向至SSO认证中心
* 情况2ticket有值说明此请求从SSO认证中心重定向而来需要根据ticket进行登录
*/
if(ticket == null) {
String currUrl = request.getRequestURL().toString();
String clientLoginUrl = currUrl + "?back=" + SsoRequestUtil.encodeUrl(back);
String serverAuthUrl = SsoRequestUtil.authUrl + "?redirect=" + clientLoginUrl;
if (ticket == null) {
// ------- 情况 1
// 当前 url形如http://sso-client.com/sso/login?back=xxx
String clientLoginUrl = request.getRequestURL().toString() + "?back=" + SsoRequestUtil.encodeUrl(back);
// 最终授权地址形如http://sso-server.com/sso/auth?client=xxx&redirect=http://sso-client.com/sso/login?back=xxx
String serverAuthUrl = SsoRequestUtil.authUrl
+ "?client=" + SsoRequestUtil.clientId
+ "&redirect=" + clientLoginUrl;
response.sendRedirect(serverAuthUrl);
return null;
} else {
// 获取当前 client 端的单点注销回调地址
String ssoLogoutCall = "";
if(SsoRequestUtil.isSlo) {
ssoLogoutCall = request.getRequestURL().toString().replace("/sso/login", "/sso/logoutCall");
}
// 校验 ticket
String timestamp = String.valueOf(System.currentTimeMillis()); // 时间戳
String nonce = SsoRequestUtil.getRandomString(20); // 随机字符串
String sign = SsoRequestUtil.getSignByTicket(ticket, ssoLogoutCall, timestamp, nonce); // 参数签名
String checkUrl = SsoRequestUtil.checkTicketUrl +
"?timestamp=" + timestamp +
"&nonce=" + nonce +
"&sign=" + sign +
"&ticket=" + ticket +
"&ssoLogoutCall=" + ssoLogoutCall;
AjaxJson result = SsoRequestUtil.request(checkUrl);
// 200 代表校验成功
if(result.getCode() == 200 && SsoRequestUtil.isEmpty(result.getData()) == false) {
// 登录上
Object loginId = result.getData();
session.setAttribute("userId", loginId);
// ------- 情况 2
// 构建 checkTicket 请求参数,以 ticket 查询 userId
Map<String, String> params = new LinkedHashMap<>();
params.put("msgType", "checkTicket");
params.put("client", SsoRequestUtil.clientId);
params.put("ticket", ticket);
SsoSignUtil.addSignParams(params);
String pushUrl = SsoRequestUtil.buildUrl(SsoRequestUtil.pushSUrl, params);
AjaxJson result = SsoRequestUtil.request(pushUrl);
// 200 代表校验成功
if (result.getCode() == 200 && !SsoRequestUtil.isEmpty(result.getData())) {
// 登录上
session.setAttribute("userId", result.getData());
// 返回 back 地址
response.sendRedirect(back);
return null;
} else {
// 将 sso-server 回应的消息作为异常抛出
// 将 sso-server 回应的消息作为异常抛出
throw new RuntimeException(result.getMsg());
}
}
}
// SSO-Client端单点注销地址
@RequestMapping("/sso/logout")
public Object ssoLogout(@RequestParam(defaultValue = "/") String back,
public Object ssoLogout(@RequestParam(defaultValue = "/") String back,
HttpServletResponse response, HttpSession session) throws IOException {
// 如果未登录,则无需注销
if(session.getAttribute("userId") == null) {
// 如果未登录,则无需注销
if (session.getAttribute("userId") == null) {
response.sendRedirect(back);
return null;
}
// 调用 sso-server 认证中心单点注销API
Object loginId = session.getAttribute("userId"); // 账号id
String timestamp = String.valueOf(System.currentTimeMillis()); // 时间戳
String nonce = SsoRequestUtil.getRandomString(20); // 随机字符串
String sign = SsoRequestUtil.getSign(loginId, timestamp, nonce); // 参数签名
String url = SsoRequestUtil.sloUrl +
"?loginId=" + loginId +
"&timestamp=" + timestamp +
"&nonce=" + nonce +
"&sign=" + sign;
AjaxJson result = SsoRequestUtil.request(url);
// 校验响应状态码200 代表成功
if(result.getCode() == 200) {
// 极端场景下sso-server 中心的单点注销可能并不会通知到此 client 端,所以这里需要再补一刀
}
// 调用 sso-server 认证中心单点注销 API
Object loginId = session.getAttribute("userId");
Map<String, String> params = new LinkedHashMap<>();
params.put("msgType", "signout");
params.put("client", SsoRequestUtil.clientId);
params.put("loginId", String.valueOf(loginId));
SsoSignUtil.addSignParams(params);
String pushUrl = SsoRequestUtil.buildUrl(SsoRequestUtil.pushSUrl, params);
AjaxJson result = SsoRequestUtil.request(pushUrl);
// 校验响应状态码200 代表成功
if (result.getCode() == 200) {
// 极端场景下sso-server 中心的单点注销可能并不会通知到此 client 端,所以这里需要再补一刀
session.removeAttribute("userId");
// 返回 back 地址
response.sendRedirect(back);
return null;
} else {
// 将 sso-server 回应的消息作为异常抛出
// 将 sso-server 回应的消息作为异常抛出
throw new RuntimeException(result.getMsg());
}
}
// SSO-Client端单点注销回调地址
@RequestMapping("/sso/logoutCall")
public Object ssoLogoutCall(String loginId, String autoLogout, String timestamp, String nonce, String sign) {
// 校验签名
String calcSign = SsoRequestUtil.getSignByLogoutCall(loginId, autoLogout, timestamp, nonce);
if(calcSign.equals(sign) == false) {
System.out.println("无效签名,拒绝应答:" + sign);
return AjaxJson.getError("无效签名,拒绝应答" + sign);
// SSO-Server 端消息推送接收地址(单点注销回调等)
@RequestMapping("/sso/pushC")
public Object ssoPushC(HttpServletRequest request) {
// 将请求参数收集为 Map<String, String>
Map<String, String> params = new LinkedHashMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
params.put(entry.getKey(), entry.getValue()[0]);
}
// 注销这个账号id
for (HttpSession session: MyHttpSessionHolder.sessionList) {
Object userId = session.getAttribute("userId");
if(Objects.equals(String.valueOf(userId), loginId)) {
session.removeAttribute("userId");
// 校验签名
if (!SsoSignUtil.verifySign(params)) {
return AjaxJson.getError("无效签名,拒绝应答");
}
// 按 msgType 分发处理
String msgType = params.get("msgType");
// 单点注销回调
if ("logoutCall".equals(msgType)) {
// 注销这个账号 id 在本 client 端的所有会话
String loginId = params.get("loginId");
for (HttpSession session : MyHttpSessionHolder.sessionList) {
Object userId = session.getAttribute("userId");
if (Objects.equals(String.valueOf(userId), loginId)) {
session.removeAttribute("userId");
}
}
return AjaxJson.getSuccess("账号id=" + loginId + " 注销成功");
}
return AjaxJson.getSuccess("账号id=" + loginId + " 注销成功");
// 其它消息类型
// if("xxx".equals(msgType)) {
// // 处理 xxx 消息
// }
return AjaxJson.getError("未知消息类型:" + msgType);
}
// 查询我的账号信息 (调用此接口的前提是 sso-server 端开放了 /sso/userinfo 路由)
// 查询我的账号信息(调用 sso-server 端 userinfo 消息处理器)
@RequestMapping("/sso/myInfo")
public Object myInfo(HttpSession session) {
// 如果尚未登录
if(session.getAttribute("userId") == null) {
// 如果尚未登录
if (session.getAttribute("userId") == null) {
return "尚未登录,无法获取";
}
// 组织 url 参数
Object loginId = session.getAttribute("userId"); // 账号id
String timestamp = String.valueOf(System.currentTimeMillis()); // 时间戳
String nonce = SsoRequestUtil.getRandomString(20); // 随机字符串
String sign = SsoRequestUtil.getSign(loginId, timestamp, nonce); // 参数签名
String url = SsoRequestUtil.getDataUrl +
"?loginId=" + loginId +
"&timestamp=" + timestamp +
"&nonce=" + nonce +
"&sign=" + sign;
AjaxJson result = SsoRequestUtil.request(url);
// 返回给前端
Object loginId = session.getAttribute("userId");
Map<String, String> params = new LinkedHashMap<>();
params.put("msgType", "userinfo");
params.put("client", SsoRequestUtil.clientId);
params.put("loginId", String.valueOf(loginId));
SsoSignUtil.addSignParams(params);
String pushUrl = SsoRequestUtil.buildUrl(SsoRequestUtil.pushSUrl, params);
AjaxJson result = SsoRequestUtil.request(pushUrl);
// 返回给前端
return result;
}
// 全局异常拦截
// 全局异常拦截
@ExceptionHandler
public AjaxJson handlerException(Exception e) {
e.printStackTrace();
e.printStackTrace();
return AjaxJson.getError(e.getMessage());
}
}

View File

@@ -5,61 +5,47 @@ import com.pj.sso.util.AjaxJson;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.util.Map;
import java.util.Random;
/**
* 封装一些 sso 共用方法
*
* 封装一些 sso 共用方法
*
* @author click33
* @since 2022-4-30
*/
public class SsoRequestUtil {
/**
* SSO-Server端主机地址
* SSO-Server 端主机地址
*/
public static String serverUrl = "http://sa-sso-server.com:9000";
/**
* SSO-Server端 统一认证地址
* SSO-Server 端统一认证地址
*/
public static String authUrl = serverUrl + "/sso/auth";
/**
* SSO-Server端 ticket校验地址
* SSO-Server 端统一消息推送地址ticket校验、单点注销、获取用户信息等均通过此入口
*/
public static String checkTicketUrl = serverUrl + "/sso/checkTicket";
public static String pushSUrl = serverUrl + "/sso/pushS";
/**
* 单点注销地址
* 当前应用的客户端标识(需与 sso-server 端 clients 配置一致)
*/
public static String sloUrl = serverUrl + "/sso/signout";
public static String clientId = "sso-client3-nosdk";
/**
* SSO-Server端 查询userinfo地址
* 接口调用秘钥(需与 sso-server 端对应 client 配置一致)
*/
public static String getDataUrl = serverUrl + "/sso/getData";
public static String secretKey = "SSO-C3-NoSdk-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor";
// -------------------------- 工具方法
/**
* 打开单点注销功能
*/
public static boolean isSlo = true;
/**
* 接口调用秘钥
*/
public static String secretKey = "kQwIOrYvnXmSDkwEiFngrKidMcdrgKor";
// -------------------------- 工具方法
/**
* 发出请求,并返回 SaResult 结果
* @param url 请求地址
* @return 返回的结果
* 发出请求,并返回 AjaxJson 结果
* @param url 请求地址(含查询参数)
* @return 返回的结果
*/
public static AjaxJson request(String url) {
Map<String, Object> map = Forest.post(url).executeAsMap();
@@ -67,75 +53,28 @@ public class SsoRequestUtil {
}
/**
* 根据参数计算签名
* @param loginId 账号id
* @param timestamp 当前时间戳13位
* @param nonce 随机字符串
* @return 签名
* 将参数 Map 拼接到 baseUrl 后面(值进行 URL 编码),返回完整 URL
* @param baseUrl 基础 URL
* @param params 请求参数
* @return 拼接后的完整 URL
*/
public static String getSign(Object loginId, String timestamp, String nonce) {
return md5("loginId=" + loginId + "&nonce=" + nonce + "&timestamp=" + timestamp + "&key=" + secretKey);
}
// 单点注销回调时构建签名
public static String getSignByLogoutCall(Object loginId, String autoLogout, String timestamp, String nonce) {
return md5("autoLogout=" + autoLogout + "&loginId=" + loginId + "&nonce=" + nonce + "&timestamp=" + timestamp + "&key=" + secretKey);
}
// 校验ticket 时构建签名
public static String getSignByTicket(String ticket, String ssoLogoutCall, String timestamp, String nonce) {
return md5("nonce=" + nonce + "&ssoLogoutCall=" + ssoLogoutCall + "&ticket=" + ticket + "&timestamp=" + timestamp + "&key=" + secretKey);
public static String buildUrl(String baseUrl, Map<String, String> params) {
StringBuilder sb = new StringBuilder(baseUrl).append("?");
for (Map.Entry<String, String> entry : params.entrySet()) {
sb.append(entry.getKey()).append("=").append(encodeUrl(entry.getValue())).append("&");
}
sb.deleteCharAt(sb.length() - 1);
return sb.toString();
}
/**
* 指定元素是否为null或者空字符串
* @param str 指定元素
* @param str 指定元素
* @return 是否为null或者空字符串
*/
public static boolean isEmpty(Object str) {
return str == null || "".equals(str);
}
/**
* md5加密
* @param str 指定字符串
* @return 加密后的字符串
*/
public static String md5(String str) {
str = (str == null ? "" : str);
char[] hexDigits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
try {
byte[] btInput = str.getBytes();
MessageDigest mdInst = MessageDigest.getInstance("MD5");
mdInst.update(btInput);
byte[] md = mdInst.digest();
int j = md.length;
char[] strA = new char[j * 2];
int k = 0;
for (byte byte0 : md) {
strA[k++] = hexDigits[byte0 >>> 4 & 0xf];
strA[k++] = hexDigits[byte0 & 0xf];
}
return new String(strA);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 生成指定长度的随机字符串
*
* @param length 字符串的长度
* @return 一个随机字符串
*/
public static String getRandomString(int length) {
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < length; i++) {
int number = random.nextInt(62);
sb.append(str.charAt(number));
}
return sb.toString();
}
/**
* URL编码

View File

@@ -0,0 +1,102 @@
package com.pj.sso;
import java.security.MessageDigest;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
/**
* SSO 签名工具类:签名生成、注入与校验
*
* @author click33
*/
public class SsoSignUtil {
// -------------------------- 签名方法
/**
* 计算签名:将 params排除 sign 字段)按 key 字典序升序排列,
* 拼接为 k=v&k=v 后追加 &key={secretKey},整体 MD5
* @param params 请求参数(不含 sign
* @return 签名值
*/
public static String computeSign(Map<String, String> params) {
TreeMap<String, String> sorted = new TreeMap<>(params);
sorted.remove("sign");
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sorted.entrySet()) {
if (sb.length() > 0) sb.append("&");
sb.append(entry.getKey()).append("=").append(entry.getValue());
}
sb.append("&key=").append(SsoRequestUtil.secretKey);
return md5(sb.toString());
}
/**
* 向参数 Map 中注入 timestamp、nonce、sign 三个签名参数
* @param params 请求参数(已填好业务参数,此方法自动追加签名参数)
*/
public static void addSignParams(Map<String, String> params) {
params.put("timestamp", String.valueOf(System.currentTimeMillis()));
params.put("nonce", getRandomString(20));
params.put("sign", computeSign(params));
}
/**
* 校验请求中的 sign 参数是否合法
* @param params 包含 sign 的请求参数
* @return 签名是否合法
*/
public static boolean verifySign(Map<String, String> params) {
String sign = params.get("sign");
if (sign == null) return false;
return sign.equals(computeSign(params));
}
// -------------------------- 基础工具
/**
* MD5 加密
* @param str 指定字符串
* @return 加密后的字符串
*/
public static String md5(String str) {
str = (str == null ? "" : str);
char[] hexDigits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
try {
byte[] btInput = str.getBytes();
MessageDigest mdInst = MessageDigest.getInstance("MD5");
mdInst.update(btInput);
byte[] md = mdInst.digest();
int j = md.length;
char[] strA = new char[j * 2];
int k = 0;
for (byte byte0 : md) {
strA[k++] = hexDigits[byte0 >>> 4 & 0xf];
strA[k++] = hexDigits[byte0 & 0xf];
}
return new String(strA);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 生成指定长度的随机字符串
* @param length 字符串的长度
* @return 一个随机字符串
*/
public static String getRandomString(int length) {
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(str.charAt(random.nextInt(62)));
}
return sb.toString();
}
}

View File

@@ -12,14 +12,13 @@ NoSdk 模式不使用SDK通过 http 工具类调用接口的方式来
参考 demo[sa-token-demo-sso3-client-nosdk](https://gitee.com/dromara/sa-token/tree/master/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso3-client-nosdk)
该 demo 假设应用端没有使用任何“权限认证框架”,使用最基础的 ServletAPI 进行会话管理,模拟了 `/sso/login``/sso/logout``/sso/logoutCall` 三个接口的处理逻辑。
该 demo 假设应用端没有使用任何“权限认证框架”,使用最基础的 ServletAPI 进行会话管理,模拟了 `/sso/login``/sso/logout``/sso/pushC` 三个接口的处理逻辑。
> [!INFO| label:NoSdk 模式优缺点]
> - 1、支持客户端使用任意技术栈。
> - 2、代码简单易懂流程直观清晰。
> - 3、用 http 工具类模拟 Sa-Token SSO 内部实现,样版代码较多,略显冗余。
> [!WARNING| label:NoSdk 示例不再主维护]
> 基于以下原因:
> - 1、NoSdk demo 相当于通过 http 工具类再次重写了一遍 Sa-Token SSO 模块代码,繁琐且冗余。
> - 2、重写的代码无法拥有 Sa-Token SSO 模块全部能力,仅能完成基本对接,算是一个简化版 SDK。
>
> 自 v1.43.0 版本起,不再主维护 NoSdk 模式,仓库示例仅做留档参考,大家可以转为 ReSdk 模式。
### ReSdk 模式
@@ -28,11 +27,10 @@ ReSdk 模式重写SDK部分方法通过重写框架关键步骤点
参考 demo[sa-token-demo-sso3-client-resdk](https://gitee.com/dromara/sa-token/tree/master/sa-token-demo/sa-token-demo-sso/sa-token-demo-sso3-client-resdk)
> [!INFO| label:ReSdk 模式优点]
> - 1、依然支持客户端使用任意技术栈。
> [!INFO| label:ReSdk 模式优点]
> - 1、支持客户端使用任意技术栈。
> - 2、仅重写少量部分关键代码即可完成对接。几乎可以得到 Sa-Token SSO 模块全量能力。
建议新项目首选 ReSdk 模式作为参考。
> - 3、此模式需要对 Sa-Token SSO 内部实现较为熟悉,才可以驾驭。