【升级】嵌入模式完成登录交互,可以将本系统嵌入到三方系统内

This commit is contained in:
俞宝山
2026-02-11 18:18:01 +08:00
parent 3d6743d9a8
commit 7ccbeb8cf6
9 changed files with 238 additions and 4 deletions

View File

@@ -61,5 +61,9 @@ export default {
// B端判断是否登录
isLogin(data) {
return request('isLogin', data, 'get')
},
// B端第三方Token交换登录iframe嵌入免登
doLoginByThirdToken(data) {
return request('doLoginByThirdToken', data, 'post', false)
}
}

View File

@@ -24,6 +24,8 @@ import { useMenuStore } from '@/store/menu'
import { useUserStore } from '@/store/user'
import { useDictStore } from '@/store/dict'
import { pathToRegexp } from 'path-to-regexp'
import loginApi from '@/api/auth/loginApi'
import { afterLogin } from '@/views/auth/login/util'
// 进度条配置
NProgress.configure({ showSpinner: false, speed: 500 })
@@ -57,7 +59,7 @@ const exportWhiteListFromRouter = (router) => {
const { regexp } = pathToRegexp(item.path)
res.push({
path: item.path,
regex: regexp // 使用解构后的正则表达式
regex: regexp // 使用解构后的正则表达式
})
}
return res
@@ -74,11 +76,35 @@ router.beforeEach(async (to, from, next) => {
: `${sysBaseConfig.SNOWY_SYS_NAME}`
// 过滤白名单
if (whiteList.some(currentRoute => currentRoute.regex.test(to.path))) {
if (whiteList.some((currentRoute) => currentRoute.regex.test(to.path))) {
next()
// NProgress.done()
return false
}
// ========== iframe嵌入免登检测URL中的第三方accessToken ==========
const thirdAccessToken = to.query.accessToken
if (thirdAccessToken && !tool.data.get('TOKEN')) {
// 调用第三方Token交换登录接口
loginApi
.doLoginByThirdToken({
accessToken: thirdAccessToken
})
.then(async (loginToken) => {
// 复用登录后流程,传入目标路径
await afterLogin(loginToken, to.path)
})
return false
}
// 如果已有TOKEN且URL带accessToken直接放行避免重复登录
if (thirdAccessToken && tool.data.get('TOKEN')) {
// 去掉URL中的accessToken参数保持路由干净
const query = { ...to.query }
delete query.accessToken
next({ path: to.path, query, replace: true })
return false
}
// C端检验逻辑
if (to.path.includes('/front/client/')) {
return validateClientAccess(to.path).valid ? next() : next({ path: validateClientAccess(to.path).redirectPath })

View File

@@ -8,7 +8,7 @@ import { useMenuStore } from '@/store/menu'
import { useUserStore } from '@/store/user'
import { globalStore } from '@/store'
export const afterLogin = async (loginToken) => {
export const afterLogin = async (loginToken, targetPath) => {
const route = router.currentRoute.value
const menuStore = useMenuStore()
const userStore = useUserStore()
@@ -51,6 +51,12 @@ export const afterLogin = async (loginToken) => {
tool.data.set('DICT_TYPE_TREE_DATA', data)
})
// 第三方Token登录直接跳转到指定目标路径
if (targetPath) {
router.replace({ path: targetPath }).then(() => {})
return
}
// 此处判断是否存在跳转页面,如存在则跳转,否则走原来逻辑
if (route.query.redirect_uri) {
// 跳转到回调页

View File

@@ -182,13 +182,26 @@ public class AuthController {
return CommonResult.data(authService.doLoginByOtp(authOtpLoginParam, SaClientTypeEnum.B.getValue()));
}
/**
* B端第三方Token交换登录用于iframe嵌入免登
*
* @author yubaoshan
* @date 2026/2/11
**/
@ApiOperationSupport(order = 11)
@Operation(summary = "B端第三方Token交换登录iframe嵌入免登")
@PostMapping("/auth/b/doLoginByThirdToken")
public CommonResult<String> doLoginByThirdToken(@RequestBody @Valid AuthThirdTokenLoginParam authThirdTokenLoginParam) {
return CommonResult.data(authService.doLoginByThirdToken(authThirdTokenLoginParam));
}
/**
* B端判断是否登录
*
* @author xuyuxiang
* @date 2021/10/15 13:12
**/
@ApiOperationSupport(order = 11)
@ApiOperationSupport(order = 12)
@Operation(summary = "B端判断是否登录")
@GetMapping("/auth/b/isLogin")
public CommonResult<Boolean> isLogin() {

View File

@@ -0,0 +1,34 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点需要更多功能代码获取Snowy商业授权许可请在官网购买授权地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.auth.modular.login.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
/**
* 第三方Token交换登录参数用于iframe嵌入免登
*
* @author yubaoshan
* @date 2026/2/11
**/
@Getter
@Setter
public class AuthThirdTokenLoginParam {
/** 第三方系统的accessToken */
@Schema(description = "第三方系统的accessToken", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "accessToken不能为空")
private String accessToken;
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点需要更多功能代码获取Snowy商业授权许可请在官网购买授权地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.auth.modular.login.prop;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 第三方系统Token交换配置属性用于iframe嵌入免登
*
* 对接规范甲方提供一个用户信息接口GET请求Header传 Authorization: Bearer {accessToken}
* 必须返回如下JSON格式
* {
* "code": 200,
* "data": {
* "account": "zhangsan",
* "email": "zhangsan@xxx.com",
* "phone": "13800138000"
* }
* }
*
* @author yubaoshan
* @date 2026/2/11
**/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "snowy.third-auth")
public class AuthThirdClientProperties {
/** 第三方用户信息接口地址(甲方提供),配置了即代表开启,不配置即关闭 */
private String userInfoUrl;
}

View File

@@ -145,6 +145,16 @@ public interface AuthService {
**/
void validValidCode(String phoneOrEmail, String validCode, String validCodeReqNo);
/**
* 通过第三方Token交换登录用于iframe嵌入免登
*
* @param authThirdTokenLoginParam 第三方Token登录参数
* @return 本系统Token
* @author yubaoshan
* @date 2026/2/11
*/
String doLoginByThirdToken(AuthThirdTokenLoginParam authThirdTokenLoginParam);
/**
* 获取B端验证码是否开启
*

View File

@@ -22,6 +22,8 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.PhoneUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -54,6 +56,7 @@ import vip.xiaonuo.dev.api.DevConfigApi;
import vip.xiaonuo.dev.api.DevEmailApi;
import vip.xiaonuo.dev.api.DevSmsApi;
import vip.xiaonuo.sys.api.SysUserApi;
import vip.xiaonuo.auth.modular.login.prop.AuthThirdClientProperties;
import java.util.List;
import java.util.stream.Collectors;
@@ -175,6 +178,9 @@ public class AuthServiceImpl implements AuthService {
@Resource
private ClientUserApi clientUserApi;
@Resource
private AuthThirdClientProperties authThirdClientProperties;
@Override
public AuthPicValidCodeResult getPicCaptcha(String type) {
// 生成验证码随机4位字符
@@ -1178,6 +1184,92 @@ public class AuthServiceImpl implements AuthService {
}
}
@Override
public String doLoginByThirdToken(AuthThirdTokenLoginParam authThirdTokenLoginParam) {
// 校验是否配置了第三方用户信息接口地址(配置了即开启)
String userInfoUrl = authThirdClientProperties.getUserInfoUrl();
if(ObjectUtil.isEmpty(userInfoUrl)) {
throw new CommonException("未配置第三方用户信息接口地址无法使用Token交换登录");
}
// 用accessToken调用第三方用户信息接口
JSONObject thirdUserInfo = requestThirdUserInfo(userInfoUrl, authThirdTokenLoginParam.getAccessToken());
// 从第三方返回中解析用户信息固定字段account、email、phone
String thirdAccount = thirdUserInfo.getStr("account");
String thirdEmail = thirdUserInfo.getStr("email");
String thirdPhone = thirdUserInfo.getStr("phone");
// 三级降级匹配本地用户account → email → phone
SaBaseLoginUser saBaseLoginUser = matchLocalUser(thirdAccount, thirdEmail, thirdPhone);
// 执行B端登录
return execLoginB(saBaseLoginUser, AuthDeviceTypeEnum.PC.getValue());
}
/**
* 调用第三方用户信息接口
* 规范GET请求Header传Authorization: Bearer xxx
* 返回格式:{code:200, data:{account,email,phone}}
*/
private JSONObject requestThirdUserInfo(String userInfoUrl, String accessToken) {
try {
HttpResponse response = HttpRequest.get(userInfoUrl)
.header("Authorization", "Bearer " + accessToken)
.timeout(10000)
.execute();
if(!response.isOk()) {
throw new CommonException("调用第三方用户信息接口失败HTTP状态码{}", response.getStatus());
}
String body = response.body();
if(ObjectUtil.isEmpty(body)) {
throw new CommonException("第三方用户信息接口返回为空");
}
JSONObject resultJson = JSONUtil.parseObj(body);
// 校验code
Integer code = resultJson.getInt("code");
if(code == null || code != 200) {
throw new CommonException("第三方用户信息接口返回业务异常code={}", code);
}
// 提取data
JSONObject dataJson = resultJson.getJSONObject("data");
if(ObjectUtil.isEmpty(dataJson)) {
throw new CommonException("第三方用户信息接口返回数据中未找到data字段");
}
return dataJson;
} catch (CommonException e) {
throw e;
} catch (Exception e) {
throw new CommonException("调用第三方用户信息接口异常:{}", e.getMessage());
}
}
/**
* 三级降级匹配本地用户account → email → phone
*/
private SaBaseLoginUser matchLocalUser(String thirdAccount, String thirdEmail, String thirdPhone) {
// 优先通过account查找
if(StrUtil.isNotBlank(thirdAccount)) {
SaBaseLoginUser user = loginUserApi.getUserByAccount(thirdAccount);
if(ObjectUtil.isNotEmpty(user)) {
return user;
}
}
// 其次通过email查找
if(StrUtil.isNotBlank(thirdEmail)) {
SaBaseLoginUser user = loginUserApi.getUserByEmail(thirdEmail);
if(ObjectUtil.isNotEmpty(user)) {
return user;
}
}
// 最后通过phone查找
if(StrUtil.isNotBlank(thirdPhone)) {
SaBaseLoginUser user = loginUserApi.getUserByPhone(thirdPhone);
if(ObjectUtil.isNotEmpty(user)) {
return user;
}
}
// 三种方式都找不到
throw new CommonException("第三方用户account={}, email={}, phone={})在本系统中未找到对应用户,请联系管理员同步用户",
thirdAccount, thirdEmail, thirdPhone);
}
/**
* 校验是否开启注册
*

View File

@@ -192,3 +192,7 @@ sa-token.sso-client.auth-url=
sa-token.sso-client.signout-url=
sa-token.sso-client.push-url=
sa-token.sso-client.secret-key=
#########################################
# third-auth configuration iframe login
#########################################
snowy.third-auth.user-info-url=