实现一个支持第三方平台接入的通用登录组件

1,193 阅读8分钟

我正在参加「掘金·启航计划」

在开发基座平台的时候,有一个常见的需求是提供独立的登录组件。第三方平台可以直接集成这个组件到自己页面里来实现登录流程。

本文就介绍一下如何封装一个这样的登录组件,以及在实际开发过程中需要注意的地方和细节。

登录组件的作用分析

首先来分析一下这个通用登录组件的作用,作为组件,首要任务就是减轻接入方的开发工作量,作为接入方,最希望的就是我调用一个函数就能把登录相关的活都干了,登录完后就触发对应的回调把结果返还给我。这样我就完全不需要关心登录相关的细节和流程了。

而作为提供方,提供登录组件不但可以避免登录接口的细节暴露,通过封装前后端的登录流程,也可以减少与第三方技术人员相关的沟通次数,节省很多工作量。特别是在你的基座平台后续可能还会陆续接入很多业务平台、或者支持很多种不同的登录渠道时,封装一个登录组件可以使系统在安全性和对接效率上都有所提升。

并且这个登录组件还不应有技术栈限制,也就是说无论对方是用什么开发的,都应该可以用这个登录组件,尽量减少调用侧的改动。

实现方案

结合上面的分析,我们可以设计出一个简单的方案来实现这个功能,大致流程如下:

  • 基座项目前端里创建一个登录组件页面,内容只包含登录框和登录接口的调用。
  • 提供一个静态的 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,访问后效果如图:

login.gif

升级登录组件

现在的登录页面还不支持其他平台接入,现在我们升级一下,核心就是实现下面两点:

  • 如果发现自己被其他项目集成了,就通过 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,不然就成自己朝自己发消息了。

效果如下,可以发现页面的主动行为都改成了消息发送了:

login2.gif

封装 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 启起来,注意托管到其他端口,这样可以验证跨域情况下能不能正常使用登录组件。最终效果如下:

login3.gif

可以看到已经可以正常的完成登录流程了,并且第三方平台总共就写了三四行代码,非常方便。

这里我们再看一下上面提到的 “登录组件在被集成时要阻止所有主动行为”,如果我们不阻止的话会是什么效果:

login4.gif

可以看到登录成功之后依旧停留在第三方平台的登录页面,只不过 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>

效果如下,可以看到默认的弹窗、跳转逻辑都被覆盖掉了,后续行为完全由接入方控制:

login5.gif

关于跳转目标(nexturl)的一些细节

有些同学可能注意到了,第三方平台在调用时传入了一个 nexturl,然后 SDK 把这个带给了登录页面,登录页面完成登录后又把这个 nexturl 带回来了。那为什么要绕这么一圈呢?SDK 只负责传递 token,第三方平台自己处理 nexturl 的跳转不更简单么?

在刚才创建的 DEMO 里,确实是这样。但是实际场景会更复杂一点,要绕这么一圈的主要原因是:

nexturl 只是最终要跳转到的地址,而不是下一步要跳转到的地址

什么意思呢?这个 nexturl 可能是首页、或者是某个因为登录超时而跳转过来的业务页。在登录成功后页面最后要跳转到 nexturl 是毋庸置疑的。而下一步可能会先访问其他的地址,比如 第三方后台的 SSO 单点接口,然后这个接口再通过 302 重定向到其他接口继续登录流程:

image.png

看到了么,虽然站在前端角度看,页面只是从登录页跳到了目标页面,但是实际上在登录中可能会经历多次重定向,token 会随着这些重定向被传递到多个位置,最终实现完整的单点登录流程。

总结

至此,整个通用登录组件就算实现完成了,不过这只是打通了最基础的登录流程,你还可以继续完善 SDK 和登录组件页面,比如传递一些 style 来指定登录组件内部的样式,或者要求提供一些参数来明确登录方的身份,比如 appId。

并且后续也可以在自己的平台里提供一个纯 html 的在线接入 demo,这样直接把 demo 丢给要对接的第三方平台就可以了,自己也可以通过这个 demo 验证登录功能是否正常。