refactor: 略微优化 sso 相关 demo

This commit is contained in:
click33
2025-05-03 06:46:31 +08:00
parent 62e5c9b19d
commit ad4e8408fe
15 changed files with 321 additions and 165 deletions

View File

@@ -201,7 +201,7 @@ public interface SaRequest {
* @return /
*/
default boolean isAjax() {
return getHeader("X-Requested-With") != null;
return "XMLHttpRequest".equalsIgnoreCase(getHeader("X-Requested-With")) || isParam("_ajax", "true");
}
/**

View File

@@ -41,7 +41,9 @@ public class SaResult extends LinkedHashMap<String, Object> implements Serializa
// 预定的状态码
public static final int CODE_SUCCESS = 200;
public static final int CODE_ERROR = 500;
public static final int CODE_ERROR = 500;
public static final int CODE_NOT_PERMISSION = 403;
public static final int CODE_NOT_LOGIN = 401;
/**
* 构建
@@ -213,6 +215,18 @@ public class SaResult extends LinkedHashMap<String, Object> implements Serializa
return new SaResult(CODE_ERROR, msg, null);
}
// 构建未登录
public static SaResult notLogin() {
return new SaResult(CODE_NOT_LOGIN, "not login", null);
}
// 构建无权限
public static SaResult notPermission() {
return new SaResult(CODE_NOT_PERMISSION, "not permission", null);
}
// 构建指定状态码
public static SaResult get(int code, String msg, Object data) {
return new SaResult(code, msg, data);

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2020-2099 sa-token.cc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cn.dev33.satoken.util;
import cn.dev33.satoken.fun.SaFunction;
import java.util.function.Supplier;
/**
* 代码语法糖封装
*
* @author click33
* @since 1.43.0
*/
public class SaSugar {
/**
* 执行一个 Lambda 表达式,返回这个 Lambda 表达式的结果值,
* <br> 方便组织代码,例如:
* <pre>
int value = Sugar.get(() -> {
int a = 1;
int b = 2;
return a + b;
});
</pre>
* @param <R> 返回值类型
* @param lambda lambda 表达式
* @return lambda 的执行结果
*/
public static <R> R get(Supplier<R> lambda) {
return lambda.get();
}
/**
* 执行一个 Lambda 表达式
* <br> 方便组织代码,例如:
* <pre>
Sugar.exe(() -> {
int a = 1;
int b = 2;
return a + b;
});
</pre>
* @param lambda lambda 表达式
*/
public static void exe(SaFunction lambda) {
lambda.run();
}
}

View File

@@ -0,0 +1,59 @@
// 服务器接口主机地址
var baseUrl = "http://sa-sso-client1.com:9003";
// 封装一下Ajax
function ajax(path, data, successFn, errorFn) {
console.log('发起请求:', baseUrl + path, JSON.stringify(data));
fetch(baseUrl + path, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'satoken': localStorage.getItem('satoken')
},
body: serializeToQueryString(data),
})
.then(response => response.json())
.then(res => {
console.log('返回数据:', res);
if(res.code === 500) {
return alert(res.msg);
}
successFn(res);
})
.catch(error => {
console.error('请求失败:', error);
return alert("异常:" + JSON.stringify(error));
});
}
// ------------ 工具方法 ---------------
// 从url中查询到指定名称的参数值
function getParam(name, defaultValue) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("=");
if (pair[0] == name) {
return pair[1];
}
}
return (defaultValue == undefined ? null : defaultValue);
}
// 将 json 对象序列化为kv字符串形如name=Joh&age=30&active=true
function serializeToQueryString(obj) {
return Object.entries(obj)
.filter(([_, value]) => value != null) // 过滤 null 和 undefined
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
// 向指定标签里 set 内容
function setHtml(select, html) {
const dom = document.querySelector('.is-login');
if(dom) {
dom.innerHTML = html;
}
}

View File

@@ -8,31 +8,18 @@
<h2>Sa-Token SSO-Client 应用端(前后端分离版-原生h5</h2>
<p>当前是否登录:<b class="is-login"></b></p>
<p>
<a href="javascript:location.href='sso-login.html?back=' + encodeURIComponent(location.href);">登录</a>
<a href="javascript:location.href=baseUrl + '/sso/logout?satoken=' + localStorage.satoken + '&back=' + encodeURIComponent(location.href);">注销</a>
<a class="login-btn">登录</a>
<a class="logout-btn">注销</a>
</p>
<script src="https://unpkg.zhimg.com/jquery@3.4.1/dist/jquery.min.js"></script>
<script src="common.js"></script>
<script type="text/javascript">
// 后端接口地址
var baseUrl = "http://sa-sso-client1.com:9002";
// 查询当前会话是否登录
$.ajax({
url: baseUrl + '/sso/isLogin',
type: "post",
dataType: 'json',
headers: {
"X-Requested-With": "XMLHttpRequest",
"satoken": localStorage.getItem("satoken")
},
success: function(res){
$('.is-login').html(res.data + '');
},
error: function(xhr, type, errorThrown){
return alert("异常:" + JSON.stringify(xhr));
}
});
document.querySelector('.login-btn').href = 'sso-login.html?back=' + encodeURIComponent(location.href);
document.querySelector('.logout-btn').href = baseUrl + '/sso/logout?satoken=' + localStorage.satoken + '&back=' + encodeURIComponent(location.href);
ajax('/sso/isLogin', {}, function(res){
setHtml('.is-login', res.data);
})
</script>
</body>

View File

@@ -11,83 +11,35 @@
<div class="login-box">
</div>
<script src="https://unpkg.zhimg.com/jquery@3.4.1/dist/jquery.min.js"></script>
<script>window.jQuery || alert('当前页面CDN服务商已宕机请将所有js包更换为本地依赖')</script>
<script src="common.js"></script>
<script type="text/javascript">
// 后端接口地址
var baseUrl = "http://sa-sso-client1.com:9002";
var back = getParam('back', '/');
var ticket = getParam('ticket');
$(function() {
window.onload = function(){
if(ticket) {
doLoginByTicket(ticket);
} else {
goSsoAuthUrl();
}
})
}
// 重定向至认证中心
function goSsoAuthUrl() {
sa.ajax('/sso/getSsoAuthUrl', {clientLoginUrl: location.href}, function(res) {
console.log(res);
ajax('/sso/getSsoAuthUrl', {clientLoginUrl: location.href}, function(res) {
location.href = res.data;
})
}
// 根据ticket值登录
function doLoginByTicket(ticket) {
sa.ajax('/sso/doLoginByTicket', {ticket: ticket}, function(res) {
console.log(res);
if(res.code == 200) {
localStorage.setItem('satoken', res.data);
location.href = decodeURIComponent(back);
} else {
alert(res.msg);
}
ajax('/sso/doLoginByTicket', {ticket: ticket}, function(res) {
localStorage.setItem('satoken', res.data);
location.href = decodeURIComponent(back);
})
}
// 从url中查询到指定名称的参数值
function getParam(name, defaultValue){
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i=0;i<vars.length;i++) {
var pair = vars[i].split("=");
if(pair[0] == name){return pair[1];}
}
return(defaultValue == undefined ? null : defaultValue);
}
</script>
<script type="text/javascript">
var sa = {};
// 封装一下Ajax
sa.ajax = function(url, data, successFn) {
// sa.loading("正在努力加载...");
$.ajax({
url: baseUrl + url,
type: "post",
data: data,
dataType: 'json',
headers: {
satoken: localStorage.getItem('satoken')
},
success: function(res){
console.log('返回数据:', res);
successFn(res);
},
error: function(xhr, type, errorThrown){
if(xhr.status == 0){
return alert('无法连接到服务器,请检查网络');
}
return alert("异常:" + JSON.stringify(xhr));
}
});
}
</script>
</body>
</html>

View File

@@ -4,6 +4,7 @@ import cn.dev33.satoken.sso.model.SaCheckTicketResult;
import cn.dev33.satoken.sso.processor.SaSsoClientProcessor;
import cn.dev33.satoken.sso.template.SaSsoUtil;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -35,7 +36,10 @@ public class H5Controller {
@RequestMapping("/sso/doLoginByTicket")
public SaResult doLoginByTicket(String ticket) {
SaCheckTicketResult ctr = SaSsoClientProcessor.instance.checkTicket(ticket, "/sso/doLoginByTicket");
StpUtil.login(ctr.loginId, ctr.remainSessionTimeout);
StpUtil.login(ctr.loginId, new SaLoginParameter()
.setTimeout(ctr.remainTokenTimeout)
.setDeviceId(ctr.deviceId)
);
return SaResult.data(StpUtil.getTokenValue());
}

View File

@@ -1,13 +1,10 @@
package com.pj.h5;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* [Sa-Token 权限认证] 配置类 (解决跨域问题)
@@ -15,50 +12,29 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
* @author click33
*/
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
public class SaTokenConfigure {
/**
* 注册 [Sa-Token 全局过滤器]
* CORS 跨域处理策略
*/
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
public SaCorsHandleFunction corsHandle() {
return (req, res, sto) -> {
res.
// 允许指定域访问跨域资源
setHeader("Access-Control-Allow-Origin", "*")
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*");
// 指定 [拦截路由] 与 [放行路由]
.addInclude("/**").addExclude("/favicon.ico")
// 认证函数: 每次请求执行
.setAuth(obj -> {
// SaManager.getLog().debug("----- 请求path={} 提交token={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue());
// ...
})
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError(e -> {
return SaResult.error(e.getMessage());
})
// 前置函数:在每次认证函数之前执行
.setBeforeAuth(obj -> {
SaHolder.getResponse()
// ---------- 设置跨域响应头 ----------
// 允许指定域访问跨域资源
.setHeader("Access-Control-Allow-Origin", "*")
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "*")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600")
;
// 如果是预检请求,则立即返回到前端
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求不做处理"))
.back();
})
;
// 如果是预检请求,则立即返回到前端
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求不做处理"))
.back();
};
}
}

View File

@@ -0,0 +1,53 @@
package com.pj.h5;
import cn.dev33.satoken.sso.model.SaCheckTicketResult;
import cn.dev33.satoken.sso.processor.SaSsoClientProcessor;
import cn.dev33.satoken.sso.template.SaSsoUtil;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 前后台分离架构下集成SSO所需的代码 SSO-Client端
* <p>如果不需要前后端分离架构下集成SSO可删除此包下所有代码</p>
* @author click33
*
*/
@RestController
public class H5Controller {
// 当前是否登录
@RequestMapping("/sso/isLogin")
public Object isLogin() {
return SaResult.data(StpUtil.isLogin());
}
// 返回SSO认证中心登录地址
@RequestMapping("/sso/getSsoAuthUrl")
public SaResult getSsoAuthUrl(String clientLoginUrl) {
String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(clientLoginUrl, "");
return SaResult.data(serverAuthUrl);
}
// 根据ticket进行登录
@RequestMapping("/sso/doLoginByTicket")
public SaResult doLoginByTicket(String ticket) {
SaCheckTicketResult ctr = SaSsoClientProcessor.instance.checkTicket(ticket, "/sso/doLoginByTicket");
StpUtil.login(ctr.loginId, new SaLoginParameter()
.setTimeout(ctr.remainTokenTimeout)
.setDeviceId(ctr.deviceId)
);
return SaResult.data(StpUtil.getTokenValue());
}
// 全局异常拦截
@ExceptionHandler
public SaResult handlerException(Exception e) {
e.printStackTrace();
return SaResult.error(e.getMessage());
}
}

View File

@@ -0,0 +1,40 @@
package com.pj.h5;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* [Sa-Token 权限认证] 配置类 (解决跨域问题)
*
* @author click33
*/
@Configuration
public class SaTokenConfigure {
/**
* CORS 跨域处理策略
*/
@Bean
public SaCorsHandleFunction corsHandle() {
return (req, res, sto) -> {
res.
// 允许指定域访问跨域资源
setHeader("Access-Control-Allow-Origin", "*")
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*");
// 如果是预检请求,则立即返回到前端
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求不做处理"))
.back();
};
}
}

View File

@@ -47,6 +47,9 @@ public class ApiName {
/** SSO-Client端单点注销地址 */
public String ssoLogout = "/sso/logout";
/** SSO-Client端判断当前是否登录地址 */
public String ssoIsLogin = "/sso/isLogin";
/** SSO-Client端单点注销的回调 */
public String ssoLogoutCall = "/sso/logoutCall";
@@ -67,6 +70,7 @@ public class ApiName {
this.ssoSignout = prefix + this.ssoSignout;
this.ssoLogin = prefix + this.ssoLogin;
this.ssoLogout = prefix + this.ssoLogout;
this.ssoIsLogin = prefix + this.ssoIsLogin;
this.ssoPushC = prefix + this.ssoPushC;
this.ssoLogoutCall = prefix + this.ssoLogoutCall;
return this;
@@ -87,6 +91,7 @@ public class ApiName {
this.ssoSignout = this.ssoSignout.replaceFirst(oldPrefix, prefix);
this.ssoLogin = this.ssoLogin.replaceFirst(oldPrefix, prefix);
this.ssoLogout = this.ssoLogout.replaceFirst(oldPrefix, prefix);
this.ssoIsLogin = this.ssoIsLogin.replaceFirst(oldPrefix, prefix);
this.ssoPushC = this.ssoPushC.replaceFirst(oldPrefix, prefix);
this.ssoLogoutCall = this.ssoLogoutCall.replaceFirst(oldPrefix, prefix);
return this;

View File

@@ -41,6 +41,9 @@ public class ParamName {
/** client参数名称 */
public String client = "client";
/** tokenName 参数 */
public String tokenName = "tokenName";
/** tokenValue 参数 */
public String tokenValue = "tokenValue";
@@ -72,4 +75,10 @@ public class ParamName {
/** singleDeviceIdLogout 参数 */
public String singleDeviceIdLogout = "singleDeviceIdLogout";
public String isLogin = "isLogin";
public String authUrl = "authUrl";
public String redirectUrl = "redirectUrl";
public String currSsoLoginUrl = "currSsoLoginUrl";
}

View File

@@ -111,16 +111,17 @@ public class SaSsoClientProcessor {
String back = req.getParam(paramName.back, "/");
String ticket = req.getParam(paramName.ticket);
// 如果当前Client端已经登录则无需访问SSO认证中心可以直接返回
if(stpLogic.isLogin()) {
return res.redirect(back);
}
/*
* 此时有两种情况:
* 情况1ticket无值说明此请求是Client端访问需要重定向至SSO认证中心
* 情况2ticket有值说明此请求从SSO认证中心重定向而来需要根据ticket进行登录
*/
if(ticket == null) {
// 如果当前Client端已经登录则无需访问SSO认证中心可以直接返回
if(stpLogic.isLogin()) {
return res.redirect(back);
}
// 获取当前项目的 sso 登录地址
// 全局配置了就是用全局的,否则使用当前请求的地址
String currSsoLoginUrl;

View File

@@ -31,6 +31,7 @@ import cn.dev33.satoken.stp.StpLogic;
import cn.dev33.satoken.stp.parameter.SaLogoutParameter;
import cn.dev33.satoken.util.SaFoxUtil;
import cn.dev33.satoken.util.SaResult;
import cn.dev33.satoken.util.SaSugar;
import java.util.Map;
@@ -113,43 +114,34 @@ public class SaSsoServerProcessor {
String redirect = req.getParam(paramName.redirect);
String client = req.getParam(paramName.client);
// 方式1直接重定向回Client端 (mode=simple)
if(mode.equals(SaSsoConsts.MODE_SIMPLE)) {
// 若 redirect 为空,则选择 homeRoute,若 homeRoute 也为空,则抛出异常
if(SaFoxUtil.isEmpty(redirect)) {
if(SaFoxUtil.isEmpty(cfg.getHomeRoute())) {
throw new SaSsoException("未指定 redirect 参数,也未配置 homeRoute 路由,无法完成重定向操作").setCode(SaSsoErrorCode.CODE_30014);
}
return res.redirect(cfg.getHomeRoute());
// 若 redirect 为空,则选择 homeRoute若 homeRoute 也为空,则抛出异常
if(SaFoxUtil.isEmpty(redirect)) {
if(SaFoxUtil.isEmpty(cfg.getHomeRoute())) {
throw new SaSsoException("未指定 redirect 参数,也未配置 homeRoute 路由,无法完成重定向操作").setCode(SaSsoErrorCode.CODE_30014);
}
ssoServerTemplate.checkRedirectUrl(client, redirect);
return res.redirect(redirect);
} else {
// 方式2带着 ticket 参数重定向回Client端 (mode=ticket)
// 校验提供的client是否为非法字符
// if(SaSsoConsts.CLIENT_WILDCARD.equals(client)) {
// throw new SaSsoException("无效 client 标识:" + client).setCode(SaSsoErrorCode.CODE_30013);
// }
// 若 redirect 为空,则选择 homeRoute若 homeRoute 也为空,则抛出异常
if(SaFoxUtil.isEmpty(redirect)) {
if(SaFoxUtil.isEmpty(cfg.getHomeRoute())) {
throw new SaSsoException("未指定 redirect 参数,也未配置 homeRoute 路由,无法完成重定向操作").setCode(SaSsoErrorCode.CODE_30014);
}
return res.redirect(cfg.getHomeRoute());
}
// 构建并跳转
String redirectUrl = ssoServerTemplate.buildRedirectUrl(client, redirect, stpLogic.getLoginId(), stpLogic.getTokenValue());
// 构建成功,说明 redirect 地址合法此时需要更新一下该账号的Session有效期
if(cfg.getAutoRenewTimeout()) {
stpLogic.renewTimeout(stpLogic.getConfigOrGlobal().getTimeout());
}
// 跳转
return res.redirect(redirectUrl);
return res.redirect(cfg.getHomeRoute());
}
String redirectUrl = SaSugar.get(() -> {
// 方式1直接重定向回Client端 (mode=simple)
if(mode.equals(SaSsoConsts.MODE_SIMPLE)) {
ssoServerTemplate.checkRedirectUrl(client, redirect);
return redirect;
} else {
// 方式2带着 ticket 参数重定向回Client端 (mode=ticket)
// 构建并跳转
String _redirectUrl = ssoServerTemplate.buildRedirectUrl(client, redirect, stpLogic.getLoginId(), stpLogic.getTokenValue());
// 构建成功,说明 redirect 地址合法此时需要更新一下该账号的Session有效期
if(cfg.getAutoRenewTimeout()) {
stpLogic.renewTimeout(stpLogic.getConfigOrGlobal().getTimeout());
}
return _redirectUrl;
}
});
// 跳转
return res.redirect(redirectUrl);
}
/**

View File

@@ -99,7 +99,7 @@ public class SaSsoClientTemplate extends SaSsoTemplate {
* 部分 Servlet 版本 request.getRequestURL() 返回的 url 带有 query 参数形如http://domain.com?id=1
* 如果不加判断会造成最终生成的 serverAuthUrl 带有双 back 参数 ,这个 if 判断正是为了解决此问题
*/
if( ! clientLoginUrl.contains(paramName.back + "=" + back) ) {
if( ! clientLoginUrl.contains(paramName.back + "=") ) {
clientLoginUrl = SaFoxUtil.joinParam(clientLoginUrl, paramName.back, back);
}