- Published on
前端登录知多少II
- Authors

- Name
- McDaddy(戣蓦)
前端登录知多少II
当我们日常访问网页或者网站应用时,可以发现,除了日常的账号密码登录之外,往往还提供一种扫码登录的入口,例如淘宝、阿里云、飞书等等。那么本文就从原理层面来解析如何实现一个扫码登录的全流程


扫码登录的概念
- 目的:免去用户去输入账号密码的流程,简化登录过程
- 前提:扫码的手机端必须是已经登录的状态,否则手机端还是需要再登录一遍
- 原理:通过已经登录的手机端,来授权未登录的PC页面端直接登录
场景
首先我们来分析一下扫码登录有哪些必要条件和必要步骤
二维码:登录页上的二维码,其背后的信息其实就是一个跳转的链接,让用户在手机扫码后可以自动跳转到一个登录确认页面
一般扫码动作都是发生在Web端产品对应的手机app上的,一般情况下不会出现用一个手机浏览器打开扫码连接,这样做有两个原因
- 确保扫码的这一端是已经登录的,这样就免去了重新登录的步骤
- 为了推广自己产品的手机客户端
Web页面在被扫码之后,一般都会提示目前操作的状态,比如
已扫码/待确认等状态信息,类似下图
在手机端扫码后,会跳转到一个确认登录页,类似下图

点击确认登录之后,PC端页面就会跳转到登录成功的页面
此时PC端的登录态应该是持久化的,即使刷新页面也不会丢失这个登录状态
流程图
经过上面的分析,我们可以把主要的流程总结为下图

开发实现
生成二维码
- 每次刷新二维码,都通过一个UUID来作为生成的key,并把它缓存在一个map中
- 二维码中的信息,就是一个指向手机端确认页的链接,同时附带了uuid
const map = new Map<string, QrCodeInfo>();
@Get('qrcode/generate')
async generate(@Req() request: Request) {
const uuid = randomUUID();
const dataUrl = await qrcode.toDataURL(
`${request.protocol}://${request.headers.host}/pages/confirm.html?id=${uuid}`,
);
map.set(`qrcode_${uuid}`, {
status: 'noscan',
});
return {
qrcode_id: uuid,
img: dataUrl,
};
}
它的返回就是一个base64字符串可以用来显示图片以及uuid

二维码展示
写一个简单的页面,用来做PC端的登录页
- 打开页面就请求
/qrcode/generate接口,用来生成二维码 - 得到接口返回渲染出二维码图片
- 如果图片是未被扫码或扫码后还未确认的状态(即非结束状态),就需要定时轮询二维码当前的状态,并实时反馈到页面
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>扫码登录</title>
<script src="https://unpkg.com/axios@1.5.0/dist/axios.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="login" class="m-6 flex flex-col items-center gap-4">
<div class="text-lg">阿外云扫码登录</div>
<img id="img" src="" alt="" />
<div id="info"></div>
</div>
<div id="content" class="flex items-center p-8 gap-4 hidden">
<div class="text-lg">
你好,
<span id="name"></span>
,欢迎来到阿外云
</div>
<button id="logout" class="text-blue-500">退出</button>
<a href="https://www.kuimo.top/blog/2023/fe-login-II" target="_blank" class="text-blue-500">配套文档</a>
</div>
<script>
document.getElementById('logout').addEventListener('click', function () {
localStorage.removeItem('xx-jwt-token');
location.reload();
});
if (localStorage.getItem('xx-jwt-token')) {
document.getElementById('login').style.display = 'none';
axios
.get('/userInfo', {
headers: { 'xx-jwt-token': localStorage.getItem('xx-jwt-token') },
})
.then((res) => {
if (res.data.username) {
document.getElementById('content').style.display = 'flex';
document.getElementById('name').innerHTML = res.data.username;
}
});
} else {
axios.get('/qrcode/generate').then((res) => {
document.getElementById('img').src = res.data.img;
queryStatus(res.data.qrcode_id);
});
}
function queryStatus(id) {
axios.get('/qrcode/check?id=' + id).then((res) => {
const status = res.data.status;
let content = '';
switch (status) {
case 'noscan':
content = '未扫码';
break;
case 'scan-wait-confirm':
content = '已扫码,等待确认';
break;
case 'scan-confirm':
content = '已确认,即将跳转';
break;
case 'scan-cancel':
content = '已取消';
break;
}
document.getElementById('info').textContent = content;
if (status === 'noscan' || status === 'scan-wait-confirm') {
setTimeout(() => queryStatus(id), 1000);
}
if (status === 'scan-confirm') {
window.localStorage.setItem('xx-jwt-token', res.data.token);
setTimeout(() => {
document.getElementById('login').style.display = 'none';
document.getElementById('content').style.display = 'flex';
document.getElementById('name').innerHTML = res.data.username;
}, 2000);
}
});
}
</script>
</body>
</html>
样子差不多就是这样

实现轮询
这里需要定义好二维码的几个状态
noscan 未扫描
scan-wait-confirm -已扫描,等待用户确认
scan-confirm 已扫描,用户同意授权
scan-cancel 已扫描,用户取消授权
expired 已过期
轮询仅在未扫描和已扫描,等待用户确认两个状态下发生,因为其他三个状态都是状态不可逆的状态,不需要再轮询
此接口接受一个uuid作为参数,返回它的当前状态
@Get('qrcode/check') // 面向pc
async check(@Query('id') id: string) {
const info = map.get(`qrcode_${id}`);
if (info.status === 'scan-confirm') {
return {
token: await this.jwtService.sign({
username: info.userInfo.username,
}),
...info,
username: info.userInfo.username,
};
}
return info;
}
登录确认页
当用户扫了上面的二维码之后,手机端就会跳转到一个确认登录的手机页。
它的地址是http://localhost/pages/confirm.html?id={uuid}
这个页面主要就是为了展示一个确认登录的按钮(假设目前是一个手机端已经登录的状态,就不去校验手机端是否登录了)
- 一旦打开这个页面,第一件事就是通知服务端,这个码已经被扫了,从而改变二维码的显示状态
- 如果点击了确认登录按钮,就通知服务端,这个码的登录已经被确认。此时手机端把自己的
jwt token返回给服务端,即等于告诉服务端,是哪个登录的用户扫了码 - 如果点击了取消按钮,就通知服务端,这个扫码已取消
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>扫码登录确认</title>
<script src="https://unpkg.com/axios@1.5.0/dist/axios.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div
id="login"
class="flex flex-col justify-center w-full h-[100vh] bg-gray-100"
>
<div id="form">
<div class="flex flex-col gap-2 px-8 mb-6">
<div class="text-base font-bold text-lg">你好,</div>
<div class="text-base font-bold text-lg">阿外云扫码登录确认</div>
<div class="text-gray-500">为保障账户安全,请确认是本人操作</div>
<div>请输入你的用户名(昵称默认为admin)</div>
<input id="input" />
</div>
<div class="flex flex-col gap-4 w-full">
<button
id="confirm"
class="border rounded mx-4 leading-8 text-white bg-blue-400"
>
确认登录
</button>
<button id="cancel" class="border rounded mx-4 leading-8">
取消登录
</button>
</div>
</div>
<div id="success" class="text-xl flex items-center justify-center hidden">
登录成功!
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search.slice(1));
const idInput = (document.getElementById('input').value = 'admin');
const id = params.get('id');
axios.get('/qrcode/scan?id=' + id).catch((e) => {
alert('二维码已过期');
});
document.getElementById('confirm').addEventListener('click', async () => {
const username = document.getElementById('input').value ?? 'admin';
const res = await axios.get(`/login?username=${username}`);
await axios
.get('/qrcode/confirm?id=' + id, {
headers: { 'xx-jwt-token': res.data.token },
})
.catch((e) => {
alert('二维码已过期');
});
document.getElementById('success').style.display = 'flex';
document.getElementById('form').style.display = 'none';
});
document.getElementById('cancel').addEventListener('click', () => {
axios.get('/qrcode/cancel?id=' + id).catch((e) => {
alert('二维码已过期');
});
});
</script>
</body>
</html>
扫码动作
当手机端打开确认页自动调用,把二维码的状态置为待确认
@Get('qrcode/scan')
async scan(@Query('id') id: string) {
const info = map.get(`qrcode_${id}`);
if (!info) {
throw new BadRequestException('二维码已过期');
}
info.status = 'scan-wait-confirm';
return 'success';
}
此时因为PC端会轮询,所以会变化状态

手机端登录
简化条件,不需要密码即可登录
@Get('login')
async login(@Query('username') username: string) {
return {
token: await this.jwtService.sign({
username: username,
}),
};
}
确认动作
这里假设会在点击确认时,带上手机端的jwt token,即等于告诉服务端这个uuid对应的二维码被xx确认了,当然实际实现除了传jwt token肯定还有别的方式
- 这里会先验证下jwt token的有效性
- 更新二维码状态为
已确认 - 把这个二维码的userInfo设置为当前登录用户
@Get('qrcode/confirm') // 面向手机端
async confirm(
@Query('id') id: string,
@Headers('xx-jwt-token') auth: string,
) {
let user;
try {
const info = await this.jwtService.verify(auth);
user = { username: info.username };
} catch (e) {
throw new UnauthorizedException('token 过期,请重新登录');
}
const info = map.get(`qrcode_${id}`);
if (!info) {
throw new BadRequestException('二维码已过期');
}
info.status = 'scan-confirm';
info.userInfo = { username: user.username };
return 'success';
}
客户端跳转
这里就需要涉及到刚才轮询接口的状态判断功能,如果当前二维码已确认,就从map中把二维码对应的user信息拿出来,同时重新签名一个新的token,返回给PC端
if (info.status === 'scan-confirm') {
return {
token: await this.jwtService.sign({
userId: info.userInfo.userId,
}),
...info,
};
}
此时的PC端,会在收到的轮询返回中得到一个额外的token字段,然后把它存到localstorage里面去,同时完成页面的跳转
// index.html
if (status === 'scan-confirm') {
window.localStorage.setItem('xx-jwt-token', res.data.token);
// show app content
}
状态保持
此时再去刷新PC端页面时,因为本地已经存了token,此时会调用一个校验接口来校验jwt token是否有效,如果有效就正常显示页面。所以此时只有在本地删除这个token或者等待它过期,否则登录状态将一直保持!
if (localStorage.getItem('xx-jwt-token')) {
axios.get('/userInfo', {
headers: { 'xx-jwt-token': localStorage.getItem('xx-jwt-token') },
}).then(res => {
if (res.data.username) {
// show page
}
});
}
退出登录
登出就很简单了,只需清除本地存储的token即可
document.getElementById('logout').addEventListener('click', function () {
localStorage.removeItem('xx-jwt-token');
location.reload();
});
总结
经过上面的梳理,实现扫码登录的流程应该就非常清晰了,实际具体实现的细节可能会有出入,但总体的流程基本就是如此。