一、背景
内部系统需要实现,在钉钉给用户发消息,然后用户点击消息免登跳转到第三方系统
二、方案
大致方案可以分为这几步
1)通过钉钉接口给用户发送工作通知或待办,发送参数是要跳转的第三方地址url
2)在aws等平台放置静态页面,此页面作用为,根据corpId【钉钉企业标识】调用引入的钉钉依赖包生成code【后续代码中根据code去钉钉换取用户身份】,然后调用内部系统单点登录接口【此单点登录的入参有code,要免登跳转的第三方页面url,先根据code换取钉钉用户信息,然后去第三方登录redis中判断该用户是否已经登录,登录了则取出redis中的token,否则在redis中放置新token,然后返回一个RedirectView对象,这个对象内容是第三方url拼接token】
3)此时html页面调用钉钉侧边打开页面方法dd.biz.util.openSlidePanel() 在侧边打开第三方url拼接token,此时页面就免登打开了第三方页面,需要注意该页面初始化过程中需要将token设置在Cookie中,这样浏览器中也可以直接打开第三方页面不用再登录
三、代码实现
附html页面代码和后端单点登录接口
html代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dd sso relay</title>
<style type="text/css">
.main-container {
margin-top: 20px;
text-align: center;
}
.inline-item {
display: inline-block;
margin: 10px;
font-size: 13px;
}
.button-item {
cursor: pointer;
padding: 8px 16px;
border-radius: 2px;
display: inline-block;
line-height: 1;
white-space: nowrap;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
text-align: center;
box-sizing: border-box;
outline: 0;
margin: 0;
transition: .1s;
font-weight: 500;
font-size: 12px;
}
.button-item:hover {
border-color: #409eff;
color: #409eff;
}
.button-item:active {
border-color: #409eff;
background: #409eff;
color: #fff;
}
.error-container {
color: #F56C6C;
font-size: 13px;
line-height: 20px;
}
.loading {
display: inline-block;
width: 38px;
}
loading:before {
content: '';
display: block;
}
.circular {
animation: rotate 2s linear infinite;
width: 38px;
height: 38px;
transform-origin: center center;
margin: auto;
}
.path {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
@keyframes color {
0%, 100% {
stroke: #409eff;
}
}
</style>
</head>
<body>
<div class="main-container">
<div id="loading-container">
<div class="loading">
<svg class="circular" viewBox="25 25 50 50">
<circle class="path" cx="50" cy="50" r="20" fill="none" stroke-width="2" stroke-miterlimit="10"/>
</svg>
</div>
</div>
<div style="display: none;" id="normal-container">
<div>
<div class="inline-item">页面跳转中,请稍后...</div>
</div>
<div>
<div class="inline-item">
<button class="button-item" onclick="refreshAndJump()">刷新</button>
</div>
</div>
</div>
<p style="display: none;" id="error-container" class="error-container">
</p>
</div>
<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.25/dingtalk.open.js"></script>
<script>
function isMobile() {
const sUserAgent = navigator.userAgent.toLowerCase();
const bIsIpad = sUserAgent.match(/ipad/i);
const bIsIphoneOs = sUserAgent.match(/iphone os/i);
const bIsMidp = sUserAgent.match(/midp/i);
const bIsUc7 = sUserAgent.match(/rv:1.2.3.4/i);
const bIsUc = sUserAgent.match(/ucweb/i);
const bIsAndroid = sUserAgent.match(/android/i);
const bIsCE = sUserAgent.match(/windows ce/i);
const bIsWM = sUserAgent.match(/windows mobile/i);
return bIsIpad || bIsIphoneOs || bIsMidp || bIsUc7 || bIsUc || bIsAndroid || bIsCE || bIsWM;
}
function setErrorMessage(message) {
setPageStatus(false);
const errorDiv = document.getElementById('error-container');
errorDiv.innerText = '页面跳转异常,异常信息: \n' + message;
}
function setPageStatus(status) {
const loadingDiv = document.getElementById('loading-container');
const normalDiv = document.getElementById('normal-container');
const errorDiv = document.getElementById('error-container');
loadingDiv.style.display = 'none';
if(status) {
errorDiv.style.display = 'none';
normalDiv.style.display = 'block';
}else {
normalDiv.style.display = 'none';
errorDiv.style.display = 'block';
}
}
function refreshAndJump() {
if(!window._ddSsoConfig) {
return;
}
const config = window._ddSsoConfig;
dd.runtime.permission.requestAuthCode({
corpId: config.corpId,
onSuccess: function(resp) {
if(!resp || !resp.code) {
setErrorMessage('授权码获取异常,响应信息[' + JSON.stringify(resp) + ']');
return;
}
let url = config.url + '?' + 'code=' + resp.code + '&redirectUrl=' + encodeURIComponent(config.redirectUrl);
setPageStatus(true);
if(isMobile()) {
dd.biz.navigation.replace({
url: url
});
}else {
// 下面这种跳转windos打不开
// window.location.href = 'dingtalk://dingtalkclient/page/link?pc_slide=true&url=' + encodeURIComponent(url);
var agent = navigator.userAgent.toLowerCase();
var isMac = agent.indexOf('mac') >= 0;
if(isMac){
//根据是否是mac做适配,如果是mac首次弹框退出,如果是windows会自动退,但是下面的命令windows执行后页面不会正常跳转
dd.biz.navigation.quit();
}
dd.biz.util.openSlidePanel({
url: url, //打开侧边栏的url
title: '工作流审批', //侧边栏顶部标题
onSuccess : function(result) {
dd.biz.navigation.quit();
/*
调用biz.navigation.quit接口进入onSuccess, result为调用biz.navigation.quit传入的数值
*/
},
onFail : function() {
dd.biz.navigation.quit();
/*
tips:点击右上角上角关闭按钮会进入onFail
*/
}
})
}
},
onFail : function(error) {
setErrorMessage(JSON.stringify(error));
}
});
}
const uri = window.location.pathname;
let urlPrefix = '';
if(uri.indexOf('/') !== -1) {
urlPrefix = uri.substring(0, uri.lastIndexOf('/'))
}
const request = new XMLHttpRequest();
request.open('get', urlPrefix + '/config/ddSsoConfig.json');
request.send(null);
request.onload = function () {
if (request.status == 200) {
let config = {};
try {
config = JSON.parse(request.responseText);
}catch (e) {
setErrorMessage('JSON解析失败');
return;
}
const params = new URLSearchParams(window.location.search);
const env = params.get('env') || '';
const url = config[env];
if(!url) {
setErrorMessage('env[' + env + ']跳转地址未找到');
return;
}
window._ddSsoConfig = {
corpId: params.get('corpId') || {},
url: url,
redirectUrl: params.get('redirectUrl') || ''
};
dd.ready(function () {
refreshAndJump();
});
}else {
setErrorMessage('配置信息加载异常');
}
}
</script>
</body>
</html>
后端单点登录接口
@GetMapping("login/{externSystemCode}")
public RedirectView login(HttpServletRequest request, @PathVariable("externSystemCode") String externSystemCode, SsoRequest.Login params) {
ISsoLoginService iSsoLoginService;
//安全性校验
if(!isContainUrl(params.getRedirectUrl())){
return null;
}
try {
iSsoLoginService = ApplicationContextHolder.getBean(externSystemCode + "SsoLoginService");
}catch (Exception e) {
log.warn("fail to get sso login service", e);
throw new BizException(SystemCodeEnum.SSO_LOGIN_SERVICE_ERROR);
}
if(StringUtil.isNotEmpty(params.getExt())) {
params.setExtMap(JSON.parseObject(params.getExt(), Map.class));
}
SsoLoginResult ssoLoginResult = iSsoLoginService.login(request, params);
if(Objects.isNull(ssoLoginResult) || StringUtil.isEmpty(ssoLoginResult.getToken())) {
// log.warn("ssoLoginResult is empty or token is empty, ssoResult[{}]", ssoLoginResult);
throw new BizException(SystemCodeEnum.SSO_LOGIN_ERROR);
}
userHelper.updateUserToken(ssoLoginResult.getShareId(), ssoLoginResult.getToken());
Map<String, Object> redirectParams = new HashMap<>(8);
redirectParams.put("token", ssoLoginResult.getToken());
if(CollectionUtil.isNotEmpty(params.getExtMap())) {
redirectParams.putAll(params.getExtMap());
}
return new RedirectView(iSsoService.getPageUrl(params.getRedirectUrl(), redirectParams));
}
public boolean isContainUrl(String url) {
if(!StringUtils.isEmpty(safeUrl)){
String[] split = safeUrl.split(",");
for(String s : split){
if (url.contains(s)){
return true;
}
}
}
return false;
}
public SsoLoginResult login(HttpServletRequest request, SsoRequest.Login params) {
String code = request.getParameter("code");
if(StringUtil.isEmpty(code)) {
log.warn("fail to ding ding sso login, code is empty, redirectUrl[{}]", params.getRedirectUrl());
throw new BizException(SystemCodeEnum.SSO_LOGIN_SERVICE_ERROR);
}
DdUserInfo ddUserInfo = ddFeignAdapterService.getUserInfo(code);
if(Objects.isNull(ddUserInfo) || StringUtil.isEmpty(ddUserInfo.getUserid())) {
throw new BizException(SystemCodeEnum.SSO_DD_USER_NOT_FOUND);
}
List<EmdsUserInfo> emdsUserInfos = emdsFeignAdapterService.getUserInfoByDdUserId(Arrays.asList(ddUserInfo.getUserid()));
if(CollectionUtil.isEmpty(emdsUserInfos) || StringUtil.isEmpty(emdsUserInfos.get(0).getShareId())) {
throw new BizException(SystemCodeEnum.SSO_EMDS_USER_NOT_FOUND);
}
SysAdminUserDO user = iSysAdminUserService.getUserByUserName(emdsUserInfos.get(0).getShareId());
if(Objects.isNull(user)) {
throw new BizException(SystemCodeEnum.USER_NOT_EXIST);
}
String oldToken,token;
//如果该用户在OMC已经登录,则直接取OMC登录的token,否则重新登陆一遍
oldToken = redisUtils.getValue(PREFIX + REDIS_SESSION + emdsUserInfos.get(0).getShareId());
if(StringUtils.isBlank(oldToken)){
Subject subject = SecurityUtils.getSubject();
subject.login(new TwoFactorAuthenticationToken(
emdsUserInfos.get(0).getShareId(), "-", null, null).setSso(true));
String encToken = subject.getSession().getId().toString();
token = EncryptUtil.decrypt(encToken, aesKey);
}else{
String newToken = oldToken.replace(REDIS_SESSION,"");
token = EncryptUtil.decrypt(newToken, aesKey);
}
iSysAdminLoginLogService.addLoginLog(new SysAdminLoginLogDO()
.setLoginUserName(user.getUsername())
.setRealname(user.getRealname())
.setIsUsing2fa(user.getIsBinding2fa())
.setType(LoginTypeEnum.SSO_LOG_IN.getCode())
.setLoginTime(new Timestamp(System.currentTimeMillis()))
.setIpAddress(request.getRemoteAddr()));
return new SsoLoginResult().setToken(token).setShareId(user.getUsername());
}