温馨提示:本文使用 ChatGPT 润色。
参考链接:
服务器端事件发送(
SSE
):https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_eventsNest.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 的优势有:
- 建立简单:SSE 基于 HTTP 协议,无需像 WebSocket 那样进行握手协议,建立连接比较简单。
- 兼容性好:SSE 对浏览器的支持性很好,大部分浏览器都能支持 SSE。
- 对服务器资源要求低:SSE 建立的是单向连接,推送服务端只需向客户端发送数据流,相比 WebSocket 而言,对服务器资源的要求较低。
- 实时性好:SSE 可以在服务端有新数据时立即将数据推送到客户端,实时性较好。
而 SSE 的局限性包括:
- 单向通信:SSE 是一种单向通信协议,只能由服务端向客户端推送数据,无法实现客户端到服务端的双向通信。
- 无法处理大量数据:SSE 的数据传输方式是基于文本的,对于大量数据传输效率较低,容易造成延迟和卡顿。
- 无法处理复杂的业务场景:SSE 的使用场景比较简单,只适用于一些简单的数据推送和通知场景,无法处理复杂的业务场景。
- 对浏览器的支持有限:虽然大部分浏览器都支持 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 生成】
- 本文链接: https://wp.cmyr.ltd/archives/how-to-use-se-to-implement-qr-code-login
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
欢迎关注我的其它发布渠道
发表回复
要发表评论,您必须先登录。