如何用 SSE 实现扫码登录

温馨提示:本文使用 ChatGPT 润色

参考链接:

服务器端事件发送(SSE):https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events

Nest.js:https://docs.nestjs.cn/9/introduction

uni-app:https://uniapp.dcloud.net.cn/api/system/barcode.html

扫码登录

最近想给自己的网站添加一个扫码登录功能,这样就不用再输入密码登录了,于是就去研究了下如何实现扫码登录。

扫码登录的基本介绍

扫码登录是一种快速、便捷的登录方式,用户只需用手机扫描二维码即可完成登录。

相较于相对于传统的账号密码登录,扫码登录具有以下优势:

  • 方便快捷:用户只需使用手机扫描二维码即可完成登录,避免了输入复杂的账号和密码,提升了用户体验;

  • 安全可靠:扫码登录不需要用户输入账号和密码,避免了密码泄露的风险;

  • 适用范围广:扫码登录可以应用于多种场景,比如企业 OA 系统、电商网站、社交软件等。

而对于网站运营方而言,扫码登录也有以下好处:

  • 提升用户体验:传统的账号密码登录方式需要用户输入复杂的账号和密码,容易让用户感到繁琐和不便。而扫码登录只需要用户使用手机扫描二维码即可完成登录,简单、方便,可以提升用户体验。

  • 提高用户留存率:账号密码登录需要用户输入账号和密码,对于新用户来说,可能会因为忘记密码、输入错误等原因而放弃注册,而扫码登录可以避免这种情况的发生,提高用户注册和留存率。

另外,对我个人而言,实现扫码登录也是一次技术性的试验,对了解如何实现扫码登录也大有好处。

扫码登录的基本流程

  • 用户在电脑端打开需要登录的网站或应用,选择扫码登录选项;
  • 系统生成二维码,并在电脑端显示;
  • 用户使用手机扫描电脑端的二维码;
  • 手机端确认登录,将登录信息发送到服务端;
  • 服务端验证登录信息,验证通过后完成登录。】

技术选型

很显然的是,在扫码登录的过程中,需要从服务端向浏览器端推送消息,所以需要一项可以实现该功能的技术。

经过一番搜索,有以下几个常见技术方案:

  • WebSocket:WebSocket 是一种新型的双向通信协议,可以在客户端和服务端之间建立持久性的连接,支持实时双向通信。WebSocket 可以直接在浏览器和服务端之间建立连接,实现服务端向浏览器端推送消息。

  • Server-Sent Events(SSE):SSE 是一种基于 HTTP 的单向推送技术,它允许服务端向客户端发送事件流(Event Stream),客户端通过 EventSource API 进行监听并处理。SSE 可以实现服务端向浏览器端的单向消息推送。

  • Long Polling:Long Polling 是一种基于 HTTP 的轮询技术,客户端向服务端发送请求,服务端在没有消息的情况下将请求挂起,当有消息时再响应请求。客户端接收到响应后立即再次发送请求,从而实现不间断的消息推送。

  • WebRTC:WebRTC 是一种实时通信技术,可以在浏览器之间进行直接的点对点通信,无需通过服务器中转。WebRTC 可以实现浏览器端之间的实时双向通信,也可以实现服务端向浏览器端的消息推送。

经过一番比对后,我认为 WebSocket 虽然可以实现这个需求,但扫码登录只需要服务端向浏览器端的单向推送,无需双向推送,所以选 WebSocket 的话技术上有些重了。

而 Long Polling (长轮询)则较为消耗服务端性能,对服务端而言无法主动控制推送和断开,也有些不足。

WebRTC 虽然也可以实现双向推送,但实现起来略微复杂,也有些过重了。

故综合考虑,我认为使用 SSE 来实现扫描登录是比较合理的。

Server-Sent Events(SSE)

SSE 的基本概念

SSE 是一种基于 HTTP 的单向推送技术,它允许服务端向客户端发送事件流(Event Stream),客户端通过 EventSource API 进行监听并处理。SSE 可以实现服务端向浏览器端的单向消息推送。

在后端,Nest.js 中实现一个 SSE 只需要如下代码:

@Sse('sse')
sse(): Observable<MessageEvent> {
  return interval(1000).pipe(map((_) => ({ data: { hello: 'world' } })));
}

然后在浏览器端执行以下代码:

const eventSource = new EventSource('/sse');
eventSource.onmessage = ({ data }) => {
  console.log('New message', JSON.parse(data));
};

就可以在控制台看到每秒打印一次的日志了。

SSE 的优势和局限性

SSE 的优势有:

  1. 建立简单:SSE 基于 HTTP 协议,无需像 WebSocket 那样进行握手协议,建立连接比较简单。
  2. 兼容性好:SSE 对浏览器的支持性很好,大部分浏览器都能支持 SSE。
  3. 对服务器资源要求低:SSE 建立的是单向连接,推送服务端只需向客户端发送数据流,相比 WebSocket 而言,对服务器资源的要求较低。
  4. 实时性好:SSE 可以在服务端有新数据时立即将数据推送到客户端,实时性较好。

而 SSE 的局限性包括:

  1. 单向通信:SSE 是一种单向通信协议,只能由服务端向客户端推送数据,无法实现客户端到服务端的双向通信。
  2. 无法处理大量数据:SSE 的数据传输方式是基于文本的,对于大量数据传输效率较低,容易造成延迟和卡顿。
  3. 无法处理复杂的业务场景:SSE 的使用场景比较简单,只适用于一些简单的数据推送和通知场景,无法处理复杂的业务场景。
  4. 对浏览器的支持有限:虽然大部分浏览器都支持 SSE,但是有些浏览器对 SSE 的支持还不是很好,需要开发者进行额外的兼容性处理。

总的来说,SSE 适合于一些简单的实时通知和消息推送场景,对于一些复杂的业务场景和大量数据传输场景,SSE 的性能和功能都比较有限。

由于扫描登录只需要从服务端向客户端推送数据,所以采用 SSE 来实现扫码登录是可以的。

用 SSE 实现扫码登录 – 后端部分

既然选定了 SSE 作为技术方案,那么就要开始具体的实现了。

经过一番思考,我认为扫码登录需要实现以下几个接口:

  • 获取/生成二维码的接口
  • 提交扫码二维码结果的接口
  • 从服务端向浏览器端推送扫码结果的接口

获取/生成二维码的接口

该部分的技术栈为:Nest.js

生成二维码其实是比较简单的,本质上还是要由服务端随机生成一段 code,然后发送给前端,前端渲染为二维码即可。

在生成 token 这一段,我采用了uuid来实现,在足够随机的情况下,撞uuid的概率还是很小的。

参考代码如下:

@Get('getQrCode')
@ApiOperation({ summary: '获取 登录二维码' })
async getQrCode(@Ip() ip: string) {
    const qrCode = uuid()
    const key = `login-by-qr-code:${qrCode}`
    const data = {
        status: "notUsed",
        uid: null,
    }
    await client.hmset(key, data) // client 为 ioredis 实例
    await client.expire(key, 5 * 60)
    return {
        qrCode,
        expiryTime: Date.now() + await client.pttl(key),
    }
}

在生成 code 之后,显然是需要存到数据库的,这里自然选择了使用 redis 作为数据库,无他,只是 redis 特别快而已。

我选择了在 redis 中存一个 hash,其中 status 字段为当前二维码的状态,而 uid 则用于存扫码的用户 id。

有关 redis 的使用请参考:https://github.com/luin/ioredis

在生成 code 之后,就要在前端渲染为二维码了,这一部分稍后在网页端部分详细说明。

提交扫码二维码结果的接口

在手机端扫描二维码之后,会得到之前生成的 code,再提交到服务器就可以告诉服务器扫码成功了。

参考代码如下:

export class QrCodeData {
    @IsNotEmpty({ message: 'qrCode不能为空' })
    @ApiProperty({ description: '二维码 id', example: uuid() })
    qrCode: string

    @IsNotEmpty({ message: 'action不能为空' })
    @ApiProperty({ description: '操作:扫码scan/批准approve/取消cancel', example: 'scan' })
    action: QrCodeAction
}

@Post('scanQrCode')
@UseJwt()
@ApiOperation({ summary: '扫描/授权/取消 二维码' })
async scanQrCode(@Body() body: QrCodeData, @CurrentUser() user: UserDocument) {
    const { qrCode, action = 'scan' } = body
        const key = `login-by-qr-code:${qrCode}`
        const result = await client.hgetall(key)
        if (!result) {
            new HttpError(400, '扫码失败!该二维码已过期,请刷新网页后重新扫码!')
        }
        if (action === 'cancel') {
            await client.del(key)
            return new ResponseDto({
                message: '取消扫码成功!',
            })
        }
        let data = {}
        if (action === 'scan') {
            data = {
                status: "scanned",
                uid: String(user._id),
            }
        } else if (action === 'approve') {
            data = {
                status: "used",
                uid: String(user._id),
            }
        }
        await client.hmset(key, data)
        await client.expire(key, 5 * 60)

        return new ResponseDto({
            message: '扫码成功!',
        })
    }
}

在这里我定义了手机端的三个操作:扫码、批准和取消。

一般来讲,在用户扫码之后就应该在浏览器端有所表现,可以提示扫码成功等,但用户实际登录要等到点击确定授权登录之后,所以这里还需要有一个批准登录的操作。

从服务端向浏览器端推送扫码结果的接口

最后就是向浏览器端推送结果了。

在 Nest.js 中使用 SSE, 需要返回一个Observable流,例如:

export class HandleQrCodeData {
    @IsNotEmpty({ message: 'qrCode不能为空' })
    @ApiProperty({ description: '二维码 id', example: uuid() })
    qrCode: string
}

export interface MessageEvent {
  data: string | object;
  id?: string;
  type?: string;
  retry?: number;
}


@Sse('handleQrCode')
@ApiOperation({ summary: '服务器推送扫描二维码的结果' })
handleQrCode(@Query() query: HandleQrCodeData): Observable<MessageEvent> {
        const { qrCode } = query
        const key = `login-by-qr-code:${qrCode}`
        const start = Date.now()
        return interval(1000)
            .pipe(
                map(async () => {
                    const data = await client.hgetall(key)
                    if (!data) {
                        throw new HttpError(400, '扫码失败!该二维码已过期,请刷新网页后重新扫码!')
                    }
                    if (data?.status === "used" && isMongoId(data?.uid)) {
                        await client.del(key)
                        const { token } = this.getAuthToken(data?.uid)
                        return {
                            data: {
                                token,
                                status: "used"
                            },
                        }
                    }
                    if (data?.status === "scanned") {
                        return {
                            data: {
                                // token: null,
                                status: "scanned"
                            },
                        }
                    }
                    return {
                        data: {
                            // token: null,
                        },
                    }
                }),
                mergeAll(),  // 在这里需要用 mergeAll 操作符来获取到 Promise 的 resolve 值,原因是在 map 中返回了一个 Promise。更详细的内容可参考:https://blog.cmyr.ltd/archives/84a41459.html
            )
    }

这样一来就实现了一个每秒查询一次扫码结果并向浏览器推送的 SSE。

注意,这里的实现有以下几个问题:

  • 轮询 redis 对 redis 的性能损耗较大,更合理的方案是使用 redis 的事件订阅。这里为了简单起见就采用了轮询。
  • 由于查询 redis 是一个异步操作,会返回一个 Promise,需要用 RxJS 的 mergeAll 操作符才能获取到 Promise 的 resolve 值。

用 SSE 实现扫码登录 – 浏览器端部分

该部分的技术栈为:TypeScript(纯 JavaScript 也可实现)

在浏览器端要做的事情比较简单,一共以下几步:

  • 获取登录二维码的 code
  • 根据 code 渲染二维码
  • 监听扫码结果

获取登录二维码的 code

这一步实际上比较简单,发起一个 ajax 请求即可

export interface IQrCode {
    /**
     * 二维码 的 uuid
     */
    qrCode: string
    /**
     * 过期时间
     */
    expiryTime: number
}

/**
 * 获取 登录二维码
 */
export async function getQrCode() {
    return ajax<IQrCode>({
        url: '/auth/getQrCode',
    })
}

根据 code 渲染二维码

这一步需要用到一些第三方包来实现了,这里我采用了qrcode这个包来生成二维码。

import QRCode from 'qrcode'
/**
 * 生成二维码
 */
export async function createQRCode(text: string | QRCode.QRCodeSegment[]) {
    try {
        return await QRCode.toDataURL(text, { errorCorrectionLevel: 'Q' })
    } catch (err) {
        console.error(err)
        return ''
    }
}

const qrCodeUrl = = await createQRCode(qrCode)

这里会生成一个 base64 格式的图片,在 html 中用img标签渲染即可。

<img
    class="qr-code-img"
    :src="qrCodeUrl"
    width="250"
    height="250"
/>

监听扫码结果

然后则是要监听扫码结果了,因为使用了 SSE,这里自然也是用 SSE 相关接口了。

const e = new EventSource(`/handleQrCode?qrCode=${qrCode.value}`)

e.onmessage = async ({ data }) => {
    try {
        data = JSON.parse(data) // 这里的 data 是 string 类型的,所以需要 JSON.parse
        // console.log('data', data)
        const token = data?.token
        status.value = data?.status || ''
        if (token) {
            // 登录成功后操作……
            return
        }
        // 无事发生,继续监听
    } catch (error) {
        // 处理错误
        console.error(error)
    }

}
e.onerror = async (event) => {
    console.error(event)
    Message.error('扫码登录出现错误,请稍后刷新网页重试。')
}

用 SSE 实现扫码登录 – 手机端部分

该部分的技术栈为:uni-app

最后就到了手机端部分的实现了。

实际上这最后一步也是最好实现的,原因在于扫码模块直接调用第三方包即可,有成熟的第三方包可以使用。

手机端部分主要有以下几个步骤:

  • 扫描二维码,获取二维码结果
  • 用户授权登录
  • 通知服务端用户授权登录

注意:

  • 扫码成功之后应该还要通知服务端二维码扫描成功。但这里为了简单起见,就不实现了通知扫码成功的部分了。

  • 在等待用户授权登录的时候,用户也可以取消授权。但这里为了简单起见,就不实现取消登录的部分了。

扫描二维码,获取二维码结果

在 uni-app 中,使用uni.scanCode可以非常轻松的在除 H5 外的环境下实现扫码登录。

参考代码如下:

uni.scanCode({
    scanType: ['qrCode'],
    // autoZoom: false, // 禁用自动放大。自动放大有时候会帮倒忙,如果不太爽的话可以手动关闭
    success: function (res) {
        console.log('条码类型:' + res.scanType);
        console.log('条码内容:' + res.result);
    }
});

其中的res.result就是二维码中的 code。

用户授权登录

然后需要等待用户授权,这里弹一个模态框出来

uni.showModal({
    title: '提示',
    content: '确定授权登录?',
    success: function (res) {
        if (res.confirm) {
            console.log('用户点击确定');
            
        } else if (res.cancel) {
            console.log('用户点击取消');
        }
    }
});

通知服务端用户授权登录

用户授权登录之后发一次 ajax 请求即可。

interface QrCodeData {
    qrCode: string
    action: 'scan' | 'approve' | 'cancel'
}

export async function scanQrCode(data: QrCodeData) {
    return ajax({
        url: '/scanQrCode',
        method: 'POST',
        data,
    })
}

scanQrCode({ qrCode, action: 'approve' })

前后端联调

在实现了以上几个部分之后,还需要最终调试才能确定逻辑是否正确。

由于同时涉及到电脑端和手机端,因此需要两者在同一局域网下才能联调。

手机端要通过局域网访问电脑的话还需要电脑开放对应的端口。

如果想省点事的话可以直接部署到公网联调。

总结

本文旨在介绍如何使用 Server-Sent Events(SSE)技术实现扫码登录,并提供了完整的技术选型和流程。文章分为后端、浏览器端和手机端三个部分,介绍了各自的技术栈和实现方法。其中,后端部分使用 Nest.js 技术实现获取/生成二维码的接口、提交扫码二维码结果的接口、从服务端向浏览器端推送扫码结果的接口;浏览器端使用 TypeScript 技术获取登录二维码的 code、根据 code 渲染二维码、监听扫码结果;手机端使用 uni-app 技术扫描二维码、获取二维码结果、用户授权登录,并通知服务端用户授权登录。最后,文章介绍了前后端联调过程。

【总结由 ChatGPT 生成】