扫码登录

原理概述
  • PC端生成二维码,然后通过不断轮询,查询当前二维码状态
  • App里扫码拿到qrcode_id,根据用户操作调用不同的修改二维码状态接口
  • 通过轮询接口的返回值进行登录等
nest服务
  • npm install -g @nestjs/cli
  • nest new qrcode-demo
  • npm install qrcode @types/qrcode
  • npm install @nestjs/jwt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// pp.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { JwtModule } from '@nestjs/jwt';

@Module({
imports: [
JwtModule.register({
secret: 'cy'
})
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

// app.controller.ts
import { BadRequestException, Controller, Get, Headers, Inject, Query, UnauthorizedException } from '@nestjs/common';
import { AppService } from './app.service';
import { randomUUID } from 'crypto';
import * as qrcode from 'qrcode';
import { JwtService } from '@nestjs/jwt';

const map = new Map<string, QrCodeInfo>();

interface QrCodeInfo {
status: 'noscan' | 'scan-wait-confirm' | 'scan-confirm' | 'scan-cancel' | 'expired',
userInfo?: {
userId: number;
}
}
// noscan 未扫描
// scan-wait-confirm -已扫描,等待用户确认
// scan-confirm 已扫描,用户同意授权
// scan-cancel 已扫描,用户取消授权
// expired 已过期

@Controller()
export class AppController {
constructor(private readonly appService: AppService) { }

@Get()
getHello(): string {
return this.appService.getHello();
}

@Get('qrcode/generate')
async generate() {
const uuid = randomUUID();
const dataUrl = await qrcode.toDataURL(`http://192.168.2.110:3000/pages/confirm.html?id=${uuid}`);

map.set(`qrcode_${uuid}`, {
status: 'noscan'
});

return {
qrcode_id: uuid,
img: dataUrl
}
}

@Get('qrcode/check')
async check(@Query('id') id: string) {
const info = map.get(`qrcode_${id}`);
if (info.status === 'scan-confirm') {
return {
token: await this.jwtService.sign({
userId: info.userInfo.userId
}),
...info
}
}
return info;
}

@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';
}

@Get('qrcode/confirm')
async confirm(@Query('id') id: string, @Headers('Authorization') auth: string) {
let user;
try {
const [, token] = auth.split(' ');
const info = await this.jwtService.verify(token);

user = this.users.find(item => item.id == info.userId);
} catch (e) {
throw new UnauthorizedException('token 过期,请重新登录');
}

const info = map.get(`qrcode_${id}`);
if (!info) {
throw new BadRequestException('二维码已过期');
}
info.status = 'scan-confirm';
info.userInfo = user;
return 'success';
}

@Get('qrcode/cancel')
async cancel(@Query('id') id: string) {
const info = map.get(`qrcode_${id}`);
if (!info) {
throw new BadRequestException('二维码已过期');
}
info.status = 'scan-cancel';
return 'success';
}

@Inject(JwtService)
private jwtService: JwtService;

private users = [
{ id: 1, username: 'cy', password: '123456' },
{ id: 2, username: 'cny', password: '123456' },
];

@Get('login')
async login(@Query('username') username: string, @Query('password') password: string) {

const user = this.users.find(item => item.username === username);

if (!user) {
throw new UnauthorizedException('用户不存在');
}
if (user.password !== password) {
throw new UnauthorizedException('密码错误');
}

return {
token: await this.jwtService.sign({
userId: user.id
})
}
}

@Get('userInfo')
async userInfo(@Headers('Authorization') auth: string) {
try {
const [, token] = auth.split(' ');
const info = await this.jwtService.verify(token);

const user = this.users.find(item => item.id == info.userId);
return user;
} catch (e) {
throw new UnauthorizedException('token 过期,请重新登录');
}
}
}
vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Login.vue
// 二维码
const qrCodeBase64 = ref('')
// 扫码状态
const scanStatus = ref('')
let timer;

// 开启轮询
axios.get('local/qrcode/generate').then(res => {
qrCodeBase64.value = res.data.img
queryStatus(res.data.qrcode_id);
})

function queryStatus(id) {
axios.get('local/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 = '已确认,当前登录用户:' + res.data.userInfo.username;
break;
}
case 'scan-cancel': content = '已取消'; break;
}
scanStatus.value = status
console.log('content: ', content,status);

if (status === 'noscan' || status === 'scan-cancel' || status === 'scan-wait-confirm') {
timer = setTimeout(() => queryStatus(id), 1000);
}
})
}
qrCodeCom
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<script setup lang="ts">
import Motion from '../utils/motion'
import SuccessSvg from '@/assets/svg/success.svg?component'
import RefreshOffIcon from '@iconify-icons/material-symbols/refresh-rounded'

const props = defineProps({
qrCodeBase64: {
type: String,
default: ''
},
scanStatus: {
type: String,
default: 'noscan'
}
})

// 扫码状态
enum SCANSTATUS {
NOSCAN = 'noscan', // 未扫描
SCANWAITCONFIRM = 'scan-wait-confirm', // 已扫描,等待用户确认
SCANCONFIRM = 'scan-confirm', // 已扫描,用户同意授权
SCANCANCEL = 'scan-cancel', // 已扫描,用户取消授权
EXPIRED = 'expired', // 已过期
}
</script>

<template>
<Motion>
<div class="qrcode flex-c mb-5">
<div class="qrcode-qr">
<img :src="qrCodeBase64" width="180">
</div>
<div class="qrcode-mask" :class="[ scanStatus === SCANSTATUS.EXPIRED ? 'expired' : '', (scanStatus !== SCANSTATUS.NOSCAN && scanStatus !== SCANSTATUS.SCANCANCEL) ? 'bg-[#fff]' : '']">
<div v-if="scanStatus === SCANSTATUS.SCANWAITCONFIRM" class="qrcode-result">
<SuccessSvg />
扫码成功
<div>请用手机授权登录</div>
</div>
<div v-if="scanStatus === SCANSTATUS.SCANCONFIRM" class="qrcode-result">
<SuccessSvg />
登录成功
</div>

<div v-if="scanStatus === SCANSTATUS.EXPIRED" class="qrcode-result cursor-pointer text-[#fff]">
二维码已过期
<p class="flex-c">
<IconifyIconOffline :icon="RefreshOffIcon" class="mr-1" />
点击刷新
</p>
</div>
</div>
</div>
<div class="flex justify-center text-[18px] text-[#fff]">
<img class="mr-2"
src="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/e0ff12435b30910520c9a3aac9b90487.svg"
width="20" />
使用微信扫一扫登录
</div>
</Motion>
</template>

<style lang="scss">
.qrcode {
position: relative;

&-qr {
width: 180px;
height: 180px;
}

&-mask {
position: absolute;
width: 180px;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: .9;
color: #181818;
font-size: 16px;
line-height: 22px;
text-align: center;
}

&-result {
user-select: none;

&>svg {
display: inline-block;
vertical-align: middle;
}
}
}

.expired {
background: rgba(0, 0, 0, 0.5);
}
</style>