我正在参加「掘金·启航计划」
在开发基座平台的时候,有一个常见的需求是提供独立的登录组件。第三方平台可以直接集成这个组件到自己页面里来实现登录流程。
本文就介绍一下如何封装一个这样的登录组件,以及在实际开发过程中需要注意的地方和细节。
登录组件的作用分析
首先来分析一下这个通用登录组件的作用,作为组件,首要任务就是减轻接入方的开发工作量,作为接入方,最希望的就是我调用一个函数就能把登录相关的活都干了,登录完后就触发对应的回调把结果返还给我。这样我就完全不需要关心登录相关的细节和流程了。
而作为提供方,提供登录组件不但可以避免登录接口的细节暴露,通过封装前后端的登录流程,也可以减少与第三方技术人员相关的沟通次数,节省很多工作量。特别是在你的基座平台后续可能还会陆续接入很多业务平台、或者支持很多种不同的登录渠道时,封装一个登录组件可以使系统在安全性和对接效率上都有所提升。
并且这个登录组件还不应有技术栈限制,也就是说无论对方是用什么开发的,都应该可以用这个登录组件,尽量减少调用侧的改动。
实现方案
结合上面的分析,我们可以设计出一个简单的方案来实现这个功能,大致流程如下:
- 基座项目前端里创建一个登录组件页面,内容只包含登录框和登录接口的调用。
- 提供一个静态的 js 文件,这个文件暴露一个方法,调用方法可以在指定位置创建 iframe,里边就是登录组件页面。
- js sdk 和登录组件里通过 postMessage 实现数据的交互。
- iframe 里边的登录完后把信息传递给外边的 sdk,sdk 再通过回调或者其他方式通知第三方前端。
DEMO 准备
首先咱们来实现一个简单的登录 demo:
a-loginpage.html:登录页面,用户名 admin、密码 123456,登录成功时就带着 token 跳转到登录成功页,否则弹窗提示用户名或密码错误。
<!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>登录组件</title>
</head>
<style>
body {
background-color: antiquewhite;
}
label {
display: block;
}
input {
display: block;
width: 100%;
margin: 8px 0px;
box-sizing: border-box;
}
</style>
<body>
<label for="username">用户名</label>
<input type="text" name="username" id="username">
<label for="password">密码</label>
<input type="password" name="password" id="password">
<br />
<input type="button" id="loginBtn" value="登录">
</body>
embed
<script>
function login() {
const search = new URLSearchParams(location.search);
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
if (username !== "admin" || password !== "123456") {
alert("用户名或密码错误");
return;
}
const nextSearch = new URLSearchParams({ token: '94A08DA1FECBB6E8B46990538C7B50B2' });
window.location.href = `${search.get("nexturl")}?${nextSearch}`;
}
const button = document.getElementById("loginBtn");
button.onclick = login;
</script>
</html>
a-loginsuccess.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>默认登录成功页</title>
</head>
<body>
<h1>基座平台登录成功</h1>
</body>
</html>
最后通过 serve 把这俩静态页面托管到 localhost:3000
,访问后效果如图:
升级登录组件
现在的登录页面还不支持其他平台接入,现在我们升级一下,核心就是实现下面两点:
- 如果发现自己被其他项目集成了,就通过
postMessage
把登录成功 / 失败的信息传递出去。 - 被集成时阻止一切主动行为。
如果你对 postMessage 不是很了解的话,这里简单介绍下,postMessage 位于 window 对象上。是当下跨域(跨页面)通信的常用方法。用法也很简单,接受两个参数,第一个是要发送的数据,第二个是对方的域名。用于验证身份,如果目标 window 的域名不是你指定的话,这个信息就不会被发送了。
而由于我们可能会被很多第三方平台接入,所以第二个参数可以直接指定为 "*"
。无论对方是谁都会发送消息。
而 “被集成时阻止一切主动行为” 这个可能比较难懂,但是很重要,因为集成是通过 iframe 实现的,所以被集成之后,无论是登录失败时的弹窗、或者是登录成功后的跳转,都应该由 iframe 外的前端代码负责。不然会出现弹窗位置不对,或者页面没动,iframe 里边却跳转了的问题出现。
修改之后的代码如下:
<script>
function forwardOutside (type, payload) {
if (!window.parent || !window.parent.postMessage) {
console.error('转发失败,找不到 postMessage 方法');
return;
}
window.parent.postMessage({ type, payload }, "*");
console.log('🚀 ~ 对外传递消息:', { type, payload });
}
function login() {
const search = new URLSearchParams(location.search);
const isEmbed = search.get('embed') === '1';
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
if (username !== "admin" || password !== "123456") {
const message = '用户名或密码错误';
if (isEmbed) forwardOutside('loginFail', { message });
else alert(message);
return;
}
const token = '94A08DA1FECBB6E8B46990538C7B50B2';
const nextSearch = new URLSearchParams({ token });
if (!isEmbed) {
window.location.href = `${search.get("nexturl")}?${nextSearch}`;
return;
}
forwardOutside('loginSuccess', { nexturl: search.get("nexturl"), token });
}
const button = document.getElementById("loginBtn");
button.onclick = login;
</script>
通过检查路由参数里有没有 embed
的方式来了解自己是不是被集成到其他页面里了。如果是的话就改用封装的 forwardOutside
方法把信息发送出去。
注意 forwardOutside 里是调用 window.parent
上的 postMessage,也就是朝自己所在 iframe 的 创建者 发送消息,不要直接用 window.postMessage
,不然就成自己朝自己发消息了。
效果如下,可以发现页面的主动行为都改成了消息发送了:
封装 js SDK
登录组件升级好之后我们就可以来实现 SDK 了,这部分就是一个静态 js,用于提供创建 iframe 并与之交互的功能。我们先放代码再分析:
a-login-comp-v1.js
(function(window, document, undefined) {
/**
* 创建登录组件
* @param {string} props.id 【必填】二维码容器 id
* @param {string} props.nexturl 【必填】登录成功后跳转的地址
* @param {function} props.callback 登录完成后回调函数,不填则默认重定向页面到 nexturl(填写本项后将不会自动跳转)
*/
function createLoginComp(props) {
var host = 'localhost:3000'
var frame = document.createElement("iframe");
var url = `http://${host}/a-loginpage?nexturl=${props.nexturl}&embed=1`
frame.src = url;
frame.frameBorder = "0";
frame.allowTransparency="true";
frame.scrolling = "no";
frame.style.minHeight = "100%";
frame.style.minWidth = "100%";
var el = document.getElementById(props.id);
if (!el) {
console.error('找不到id为' + props.id + '的元素,登录组件创建失败');
return;
}
el.innerHTML = "";
el.appendChild(frame);
frame.onload = function() {
if (!frame.contentWindow.postMessage || !window.addEventListener) {
console.error('浏览器不支持 postMessage,登录组件创建失败');
return;
}
window.addEventListener("message", event => {
if (event.origin.indexOf(host) === -1 || !event.data) return;
if (props.callback) {
props.callback(event.data);
return;
};
if (event.data.type === 'loginFail') {
alert('登录失败', event.data.payload.message);
return;
}
window.location.href = event.data.payload.nexturl + '?token=' + event.data.payload.token;
});
};
}
window.createLoginComp = createLoginComp;
})(window, document);
有点长,但不算复杂。大致可以分为两部分,先通过调用时传入的参数找到 dom 容器,生成一个 iframe 填进去。
然后在 iframe(也就是我们的登录组件页面)加载完成后注册 message 回调接受登录页面传回来的信息并处理。
处理时先看一下 调用方有没有传入回调,没有的话就执行默认的操作,比如页面跳转或者弹窗。如果传入回调了,说明调用方想接管后续逻辑,比如用自己的弹窗或者做一些前置操作。这时候把收到的数据转发出去就可以了。
注意在接受到 message 的时候要先通过 event.origin
(消息发送方域名)检查一下,因为第三方平台的前端可能也绑定了自己的跨域通讯,所以为了避免其他信息误触发咱们的回调,就要先检查一下这个域名,只处理自己登录组件的回调就行了,其他信息直接丢弃。
接入方调用 SDK
登录组件到这里就已经完成了,接下来看一下第三方平台的前端如何使用咱们提供的登录组件。首先也是创建两个 html,一个是第三方平台的登录页面,另一个是第三方平台的登录成功页面:
b-loginpage.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>第三方平台登录页面</title>
<script src="http://localhost:3000/a-login-comp-v1.js"></script>
</head>
<style>
h1 {
text-align: center;
}
#loginBox {
width: 100%;
height: 250px;
}
</style>
<body>
<h1>第三方登录页面</h1>
<div id="loginBox"></div>
</body>
<script>
createLoginComp({
id: 'loginBox',
nexturl: 'b-loginSuccess',
});
</script>
</html>
b-loginsuccess.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>默认登录成功页</title>
</head>
<body>
<h1>第三方平台登录成功</h1>
</body>
</html>
可以看到,第三方平台集成登录组件的方法非常简单,首先通过 <script src="http://localhost:3000/a-login-comp-v1.js" />
引入咱们的提供的 js SDK,然后执行一下 window 上注入进来的 createLoginComp
方法就可以了。
把这俩页面也通过 serve 启起来,注意托管到其他端口,这样可以验证跨域情况下能不能正常使用登录组件。最终效果如下:
可以看到已经可以正常的完成登录流程了,并且第三方平台总共就写了三四行代码,非常方便。
这里我们再看一下上面提到的 “登录组件在被集成时要阻止所有主动行为”,如果我们不阻止的话会是什么效果:
可以看到登录成功之后依旧停留在第三方平台的登录页面,只不过 iframe 里边跳转到了目标页面。
允许第三方平台自定义后续行为
我们刚才提到的 SDK 要支持调用方通过传入 callback 的方式自定义后续的逻辑,这个很有必要,因为第三方平台可能会要进行一些其他工作,例如登录失败时上传到埋点系统,或者登录成功时调用自己的后台接口传递一些信息等。
下面这个例子就是通过 callback 把登录完成后的信息回显到页面上:
<body>
<h1>第三方登录页面</h1>
<div id="loginBox"></div>
<div id="infoBox"></div>
</body>
<script>
createLoginComp({
id: 'loginBox',
nexturl: 'b-loginSuccess',
callback: e => {
const infoBox = document.getElementById('infoBox');
infoBox.innerHTML = infoBox.innerHTML + '<br/>' + JSON.stringify(e);
}
});
</script>
效果如下,可以看到默认的弹窗、跳转逻辑都被覆盖掉了,后续行为完全由接入方控制:
关于跳转目标(nexturl)的一些细节
有些同学可能注意到了,第三方平台在调用时传入了一个 nexturl,然后 SDK 把这个带给了登录页面,登录页面完成登录后又把这个 nexturl 带回来了。那为什么要绕这么一圈呢?SDK 只负责传递 token,第三方平台自己处理 nexturl 的跳转不更简单么?
在刚才创建的 DEMO 里,确实是这样。但是实际场景会更复杂一点,要绕这么一圈的主要原因是:
nexturl 只是最终要跳转到的地址,而不是下一步要跳转到的地址
什么意思呢?这个 nexturl 可能是首页、或者是某个因为登录超时而跳转过来的业务页。在登录成功后页面最后要跳转到 nexturl 是毋庸置疑的。而下一步可能会先访问其他的地址,比如 第三方后台的 SSO 单点接口,然后这个接口再通过 302 重定向到其他接口继续登录流程:
看到了么,虽然站在前端角度看,页面只是从登录页跳到了目标页面,但是实际上在登录中可能会经历多次重定向,token 会随着这些重定向被传递到多个位置,最终实现完整的单点登录流程。
总结
至此,整个通用登录组件就算实现完成了,不过这只是打通了最基础的登录流程,你还可以继续完善 SDK 和登录组件页面,比如传递一些 style 来指定登录组件内部的样式,或者要求提供一些参数来明确登录方的身份,比如 appId。
并且后续也可以在自己的平台里提供一个纯 html 的在线接入 demo,这样直接把 demo 丢给要对接的第三方平台就可以了,自己也可以通过这个 demo 验证登录功能是否正常。