跳到主要内容

设计扫码登录系统

问题

如何设计一个安全可靠的扫码登录系统?从二维码生成、状态流转、实时通信到安全防护,请详细说明扫码登录的完整架构与关键技术实现,并扩展到微信/支付宝等第三方 OAuth 扫码登录场景。

答案

扫码登录是一种 跨设备认证 方案——用户在 PC 端看到二维码,用已登录的手机 App 扫码后确认,PC 端即完成登录。其本质是 利用移动端已有的登录态,将身份信息安全地传递到 PC 端

核心思想

扫码登录的安全基础在于:手机端已经完成了用户身份验证(指纹/密码/Face ID 等),通过扫码操作将 "信任关系" 从手机端转移到 PC 端,避免 PC 端输入密码带来的安全风险(键盘记录、钓鱼等)。


一、需求分析

功能需求

模块功能点
二维码生成PC 端请求生成唯一二维码,包含 UUID 与过期时间
扫码识别手机 App 扫码后解析二维码内容,展示登录确认页
登录确认用户在手机端确认或取消登录,支持查看 PC 端设备信息
状态同步PC 端实时感知二维码状态变化(待扫码/已扫码/已确认/已过期)
Token 下发确认后服务端向 PC 端下发认证 Token,完成登录
取消与刷新支持取消登录、二维码过期自动刷新

非功能需求

指标目标
安全性HTTPS、Token 有效期、IP 校验、防 CSRF、防中间人
实时性扫码后 PC 端 < 1s 感知状态变化
可用性二维码过期自动刷新,异常自动重试
兼容性支持自有 App、微信、支付宝等多种扫码源
并发支持百万级同时扫码会话
适用场景

扫码登录广泛应用于:网页版微信/QQ、支付宝 PC 版、淘宝/京东 PC 登录、企业内部系统 SSO 等。本质上所有需要 "免密码 + 跨设备" 登录的场景都适用。


二、整体架构

扫码登录完整时序图


三、核心模块设计

3.1 二维码生成模块

二维码本质上是一个 短时有效的唯一标识(UUID),编码为可扫描的图形。

server/qr-code.service.ts
import { v4 as uuidv4 } from 'uuid';
import QRCode from 'qrcode';
import type { Redis } from 'ioredis';

// 二维码状态枚举
enum QRStatus {
PENDING = 'PENDING', // 待扫码
SCANNED = 'SCANNED', // 已扫码
CONFIRMED = 'CONFIRMED', // 已确认
EXPIRED = 'EXPIRED', // 已过期
CANCELLED = 'CANCELLED', // 已取消
}

interface QRCodeSession {
uuid: string;
status: QRStatus;
createdAt: number;
expireAt: number;
userId?: string; // 扫码用户 ID(扫码后填入)
userAvatar?: string; // 扫码用户头像
pcToken?: string; // PC 端登录 Token
deviceInfo?: string; // PC 端设备信息
ip?: string; // 请求 IP
}

const QR_EXPIRE_SECONDS = 300; // 二维码 5 分钟过期
const QR_PREFIX = 'qr:login:';

class QRCodeService {
constructor(private redis: Redis) {}

/** 生成二维码 */
async generate(ip: string, userAgent: string): Promise<{
uuid: string;
qrUrl: string;
qrDataUrl: string;
expireAt: number;
}> {
const uuid = uuidv4();
const now = Date.now();

const session: QRCodeSession = {
uuid,
status: QRStatus.PENDING,
createdAt: now,
expireAt: now + QR_EXPIRE_SECONDS * 1000,
deviceInfo: userAgent,
ip,
};

// 存入 Redis 并设置过期时间
await this.redis.set(
`${QR_PREFIX}${uuid}`,
JSON.stringify(session),
'EX',
QR_EXPIRE_SECONDS
);

// 二维码内容:包含 UUID 和域名标识
const qrContent = `https://example.com/qr-login?code=${uuid}`;

// 生成 Base64 图片(也可返回 URL 由前端用 qrcode.js 渲染)
const qrDataUrl = await QRCode.toDataURL(qrContent, {
width: 280,
margin: 2,
errorCorrectionLevel: 'M',
});

return {
uuid,
qrUrl: qrContent,
qrDataUrl,
expireAt: session.expireAt,
};
}

/** 获取二维码状态 */
async getSession(uuid: string): Promise<QRCodeSession | null> {
const data = await this.redis.get(`${QR_PREFIX}${uuid}`);
if (!data) return null;
return JSON.parse(data) as QRCodeSession;
}

/** 更新二维码状态 */
async updateStatus(
uuid: string,
status: QRStatus,
extra?: Partial<QRCodeSession>
): Promise<boolean> {
const session = await this.getSession(uuid);
if (!session) return false;

const updated: QRCodeSession = { ...session, status, ...extra };
const ttl = await this.redis.ttl(`${QR_PREFIX}${uuid}`);

if (ttl <= 0) return false;

await this.redis.set(
`${QR_PREFIX}${uuid}`,
JSON.stringify(updated),
'EX',
ttl
);

// 发布状态变更事件,通知 WebSocket 网关
await this.redis.publish(`qr:status:${uuid}`, JSON.stringify(updated));

return true;
}
}
安全要点
  • 二维码 UUID 必须使用 加密级随机数(如 crypto.randomUUID()),不能用自增 ID 或可预测的值
  • 二维码内容不应直接包含敏感信息(如 Token),只包含 UUID 标识符
  • 二维码必须有 过期时间(一般 3-5 分钟),Redis 键的 TTL 作为兜底

3.2 状态机设计

扫码登录的核心是一个 有限状态机(FSM),状态之间只能按规定的路径流转:

server/qr-state-machine.ts
/** 状态流转合法性校验 */
const VALID_TRANSITIONS: Record<QRStatus, QRStatus[]> = {
[QRStatus.PENDING]: [QRStatus.SCANNED, QRStatus.EXPIRED],
[QRStatus.SCANNED]: [QRStatus.CONFIRMED, QRStatus.CANCELLED, QRStatus.EXPIRED],
[QRStatus.CONFIRMED]: [], // 终态
[QRStatus.CANCELLED]: [], // 终态
[QRStatus.EXPIRED]: [], // 终态
};

function canTransition(from: QRStatus, to: QRStatus): boolean {
return VALID_TRANSITIONS[from]?.includes(to) ?? false;
}

/** 带校验的状态更新 */
async function safeUpdateStatus(
redis: Redis,
uuid: string,
newStatus: QRStatus,
extra?: Partial<QRCodeSession>
): Promise<{ success: boolean; error?: string }> {
const session = await getSession(redis, uuid);

if (!session) {
return { success: false, error: 'QR code not found or expired' };
}

if (!canTransition(session.status, newStatus)) {
return {
success: false,
error: `Cannot transition from ${session.status} to ${newStatus}`,
};
}

// 使用 Redis 事务保证原子性
const key = `${QR_PREFIX}${uuid}`;
const ttl = await redis.ttl(key);

if (ttl <= 0) {
return { success: false, error: 'QR code expired' };
}

const updated = { ...session, status: newStatus, ...extra };

await redis
.multi()
.set(key, JSON.stringify(updated), 'EX', ttl)
.publish(`qr:status:${uuid}`, JSON.stringify(updated))
.exec();

return { success: true };
}
状态英文标识说明PC 端展示
待扫码PENDING二维码已生成,等待扫码显示二维码
已扫码SCANNED手机已扫码,等待确认显示用户头像 + "请在手机上确认"
已确认CONFIRMED用户已确认登录跳转到首页
已过期EXPIRED二维码超时显示"已过期,点击刷新"
已取消CANCELLED用户取消登录显示"已取消,点击重新生成"

3.3 实时通信方案对比

PC 端需要实时感知二维码状态变化,有三种主流方案:

client/polling.ts
/** 短轮询方案 */
class QRPolling {
private timer: ReturnType<typeof setInterval> | null = null;

private readonly POLL_INTERVAL = 2000; // 2 秒轮询一次

start(uuid: string, onStatusChange: (status: QRStatus) => void): void {
this.timer = setInterval(async () => {
try {
const res = await fetch(`/api/qr/status?uuid=${uuid}`);
const data: { status: QRStatus; token?: string } = await res.json();

onStatusChange(data.status);

// 终态停止轮询
if (['CONFIRMED', 'EXPIRED', 'CANCELLED'].includes(data.status)) {
this.stop();
}
} catch (error) {
console.error('Polling error:', error);
}
}, this.POLL_INTERVAL);
}

stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}

三种方案对比:

特性短轮询WebSocketSSE
实时性取决于轮询间隔(1-3s)实时(毫秒级)实时(毫秒级)
服务端压力高(大量无效请求)低(长连接)低(长连接)
实现复杂度简单中等(需心跳/重连)简单(内置重连)
双向通信否(服务端单向推送)
浏览器兼容全兼容IE10+IE 不支持
代理/CDN 友好友好需要配置友好
资源消耗带宽浪费大连接资源连接资源
适用场景简单场景、兼容性要求高推荐方案、需双向通信服务端单向推送
最佳实践

推荐使用 WebSocket 作为主方案,短轮询作为降级方案。连接建立后设置心跳检测(30s 一次 ping/pong),异常断开后指数退避重连。SSE 也是不错的选择,因为扫码登录只需要服务端单向推送,SSE 的内置重连机制更省心。

更多实时通信方案的深入分析可参考 WebSocket 与 SSE设计实时通讯系统


四、关键技术实现

4.1 PC 端实现

client/QRLoginPage.tsx
import { useState, useEffect, useCallback, useRef } from 'react';

enum QRStatus {
PENDING = 'PENDING',
SCANNED = 'SCANNED',
CONFIRMED = 'CONFIRMED',
EXPIRED = 'EXPIRED',
CANCELLED = 'CANCELLED',
}

interface QRData {
uuid: string;
qrDataUrl: string;
expireAt: number;
}

interface StatusMessage {
status: QRStatus;
token?: string;
refreshToken?: string;
userAvatar?: string;
}

function useQRLogin() {
const [qrData, setQrData] = useState<QRData | null>(null);
const [status, setStatus] = useState<QRStatus>(QRStatus.PENDING);
const [userAvatar, setUserAvatar] = useState<string>('');
const wsRef = useRef<WebSocket | null>(null);
const expireTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

/** 生成二维码 */
const generateQR = useCallback(async () => {
setStatus(QRStatus.PENDING);
setUserAvatar('');

const res = await fetch('/api/qr/generate', { method: 'POST' });
const data: QRData = await res.json();
setQrData(data);

// 设置过期计时器
const ttl = data.expireAt - Date.now();
expireTimerRef.current = setTimeout(() => {
setStatus(QRStatus.EXPIRED);
}, ttl);

// 建立 WebSocket 连接
connectWebSocket(data.uuid);
}, []);

/** WebSocket 连接 */
const connectWebSocket = (uuid: string) => {
// 关闭旧连接
if (wsRef.current) {
wsRef.current.close();
}

const ws = new WebSocket(`wss://example.com/ws/qr?uuid=${uuid}`);
wsRef.current = ws;

ws.onmessage = (event: MessageEvent) => {
const msg: StatusMessage = JSON.parse(event.data as string);

setStatus(msg.status);

if (msg.status === QRStatus.SCANNED && msg.userAvatar) {
setUserAvatar(msg.userAvatar);
}

if (msg.status === QRStatus.CONFIRMED && msg.token) {
// 登录成功:存储 Token 并跳转
localStorage.setItem('accessToken', msg.token);
if (msg.refreshToken) {
localStorage.setItem('refreshToken', msg.refreshToken);
}
window.location.href = '/dashboard';
}
};
};

useEffect(() => {
generateQR();

return () => {
wsRef.current?.close();
if (expireTimerRef.current) {
clearTimeout(expireTimerRef.current);
}
};
}, [generateQR]);

return { qrData, status, userAvatar, refresh: generateQR };
}

4.2 移动端实现

mobile/scan-login.service.ts
interface ScanResult {
uuid: string;
domain: string;
}

interface ConfirmPayload {
uuid: string;
userToken: string; // 手机端的登录 Token
deviceId: string;
}

class ScanLoginService {
private apiBase: string;

constructor(apiBase: string) {
this.apiBase = apiBase;
}

/** 解析二维码内容 */
parseQRCode(rawContent: string): ScanResult | null {
try {
const url = new URL(rawContent);
const code = url.searchParams.get('code');

// 验证域名合法性,防止钓鱼
const allowedDomains = ['example.com', 'auth.example.com'];
if (!allowedDomains.includes(url.hostname)) {
console.error('Untrusted QR code domain:', url.hostname);
return null;
}

if (!code) return null;

return { uuid: code, domain: url.hostname };
} catch {
return null;
}
}

/** 上报扫码(状态变为 SCANNED) */
async reportScan(
uuid: string,
userToken: string
): Promise<{ success: boolean; deviceInfo?: string }> {
const res = await fetch(`${this.apiBase}/api/qr/scan`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({ uuid }),
});

return res.json();
}

/** 确认登录(状态变为 CONFIRMED) */
async confirmLogin(payload: ConfirmPayload): Promise<{ success: boolean }> {
const res = await fetch(`${this.apiBase}/api/qr/confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${payload.userToken}`,
},
body: JSON.stringify({
uuid: payload.uuid,
deviceId: payload.deviceId,
}),
});

return res.json();
}

/** 取消登录(状态变为 CANCELLED) */
async cancelLogin(
uuid: string,
userToken: string
): Promise<{ success: boolean }> {
const res = await fetch(`${this.apiBase}/api/qr/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({ uuid }),
});

return res.json();
}
}

4.3 服务端 API 实现

server/qr-login.controller.ts
import type { Request, Response } from 'express';
import jwt from 'jsonwebtoken';

class QRLoginController {
constructor(
private qrService: QRCodeService,
private userService: UserService,
private jwtSecret: string
) {}

/** 生成二维码 */
async generate(req: Request, res: Response): Promise<void> {
const ip = req.ip ?? '';
const userAgent = req.headers['user-agent'] ?? '';
const result = await this.qrService.generate(ip, userAgent);
res.json(result);
}

/** 扫码上报 */
async scan(req: Request, res: Response): Promise<void> {
const { uuid } = req.body as { uuid: string };
const userId = req.user?.id; // 从 JWT 中间件解析

if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

const session = await this.qrService.getSession(uuid);
if (!session) {
res.status(404).json({ error: 'QR code expired or not found' });
return;
}

const user = await this.userService.findById(userId);

const result = await safeUpdateStatus(this.qrService.redis, uuid, QRStatus.SCANNED, {
userId,
userAvatar: user?.avatar,
});

if (!result.success) {
res.status(400).json({ error: result.error });
return;
}

// 返回 PC 端设备信息,供用户确认
res.json({
success: true,
deviceInfo: session.deviceInfo,
ip: session.ip,
});
}

/** 确认登录 */
async confirm(req: Request, res: Response): Promise<void> {
const { uuid } = req.body as { uuid: string };
const userId = req.user?.id;

if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

const session = await this.qrService.getSession(uuid);

// 安全校验:确认操作的用户必须和扫码用户一致
if (!session || session.userId !== userId) {
res.status(403).json({ error: 'Forbidden' });
return;
}

// 为 PC 端生成 Token
const accessToken = jwt.sign(
{ userId, type: 'access' },
this.jwtSecret,
{ expiresIn: '2h' }
);

const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
this.jwtSecret,
{ expiresIn: '7d' }
);

const result = await safeUpdateStatus(
this.qrService.redis,
uuid,
QRStatus.CONFIRMED,
{ pcToken: accessToken }
);

if (!result.success) {
res.status(400).json({ error: result.error });
return;
}

res.json({ success: true });
}
}

4.4 WebSocket 网关

server/ws-gateway.ts
import { WebSocketServer, WebSocket } from 'ws';
import type { Redis } from 'ioredis';

class QRWebSocketGateway {
private wss: WebSocketServer;
// UUID -> WebSocket 客户端映射
private clients = new Map<string, Set<WebSocket>>();
private subscriber: Redis;

constructor(port: number, subscriber: Redis) {
this.wss = new WebSocketServer({ port });
this.subscriber = subscriber;

this.setupWebSocket();
this.setupRedisSubscriber();
}

private setupWebSocket(): void {
this.wss.on('connection', (ws: WebSocket, req) => {
const url = new URL(req.url ?? '', 'wss://localhost');
const uuid = url.searchParams.get('uuid');

if (!uuid) {
ws.close(4000, 'Missing uuid');
return;
}

// 注册客户端
if (!this.clients.has(uuid)) {
this.clients.set(uuid, new Set());
}
this.clients.get(uuid)!.add(ws);

// 心跳检测
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);

ws.on('close', () => {
clearInterval(pingInterval);
this.clients.get(uuid)?.delete(ws);
if (this.clients.get(uuid)?.size === 0) {
this.clients.delete(uuid);
}
});
});
}

/** 订阅 Redis 状态变更通知 */
private setupRedisSubscriber(): void {
this.subscriber.psubscribe('qr:status:*');

this.subscriber.on('pmessage', (_pattern, channel, message) => {
// channel 格式: qr:status:{uuid}
const uuid = channel.split(':')[2];
const clients = this.clients.get(uuid);

if (clients) {
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
}
});
}
}

五、安全设计

5.1 安全威胁与防护

威胁描述防护措施
二维码伪造攻击者伪造二维码引导用户扫码域名校验 + HTTPS + 显示设备信息让用户确认
中间人攻击截获/篡改 WebSocket 通信WSS(TLS)加密 + Token 签名
CSRF诱导已登录用户自动确认确认接口要求手动点击 + 二次验证
二维码劫持攻击者替换页面上的二维码CSP 策略 + HTTPS + 子资源完整性
Token 泄露PC 端 Token 被窃取短有效期 + Refresh Token + IP 绑定
重放攻击重复使用已确认的 UUID一次性使用(终态不可逆) + UUID 用后即焚
暴力枚举遍历 UUID 猜测有效会话UUID v4 足够随机(2^122 种组合)+ 频率限制

5.2 安全实现

server/security.middleware.ts
import type { Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';

/** IP 一致性校验 */
function ipConsistencyCheck(
sessionIp: string | undefined,
requestIp: string | undefined
): boolean {
if (!sessionIp || !requestIp) return true; // 无法校验时放行

// 同一 /24 网段视为一致(考虑移动网络 IP 变化)
const sessionSubnet = sessionIp.split('.').slice(0, 3).join('.');
const requestSubnet = requestIp.split('.').slice(0, 3).join('.');
return sessionSubnet === requestSubnet;
}

/** 频率限制 - 防止暴力枚举 */
const qrRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 分钟
max: 10, // 每分钟最多 10 次
message: { error: 'Too many requests, please try again later' },
keyGenerator: (req: Request) => req.ip ?? 'unknown',
});

/** 请求签名验证(移动端) */
function verifyRequestSignature(
req: Request,
_res: Response,
next: NextFunction
): void {
const timestamp = req.headers['x-timestamp'] as string;
const signature = req.headers['x-signature'] as string;
const nonce = req.headers['x-nonce'] as string;

if (!timestamp || !signature || !nonce) {
_res.status(400).json({ error: 'Missing security headers' });
return;
}

// 防重放:时间戳不超过 5 分钟
const timeDiff = Math.abs(Date.now() - Number(timestamp));
if (timeDiff > 5 * 60 * 1000) {
_res.status(400).json({ error: 'Request expired' });
return;
}

// 验证签名(HMAC-SHA256)
// ... 签名验证逻辑

next();
}

/** Token 安全配置 */
const TOKEN_CONFIG = {
accessToken: {
expiresIn: '2h',
algorithm: 'RS256' as const,
},
refreshToken: {
expiresIn: '7d',
algorithm: 'RS256' as const,
},
};
关键安全措施
  1. 全链路 HTTPS/WSS:防止中间人窃听
  2. UUID 不可预测:使用 crypto.randomUUID() 而非自增
  3. 一次性使用:UUID 确认后立即失效
  4. 手动确认:扫码后必须手动点击确认按钮
  5. 设备信息展示:确认页面展示 PC 端的浏览器和 IP,方便用户识别异常
  6. 频率限制:防止枚举攻击

关于更多 Web 安全防护的详细内容,可参考 浏览器安全JWT 认证


六、扩展设计

6.1 第三方扫码登录(微信/支付宝 OAuth)

第三方扫码登录与自有 App 扫码原理类似,区别在于 引入了 OAuth 2.0 授权码流程

server/oauth-wechat.service.ts
interface WeChatConfig {
appId: string;
appSecret: string;
redirectUri: string;
}

interface WeChatTokenResponse {
access_token: string;
expires_in: number;
refresh_token: string;
openid: string;
scope: string;
unionid?: string;
}

interface WeChatUserInfo {
openid: string;
nickname: string;
sex: number;
headimgurl: string;
unionid?: string;
}

class WeChatOAuthService {
constructor(private config: WeChatConfig) {}

/** 生成微信扫码登录 URL */
getAuthUrl(state: string): string {
const params = new URLSearchParams({
appid: this.config.appId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: 'snsapi_login',
state, // 防 CSRF,可用随机字符串或 JWT
});

return `https://open.weixin.qq.com/connect/qrconnect?${params.toString()}#wechat_redirect`;
}

/** 用授权码换取 access_token */
async getAccessToken(code: string): Promise<WeChatTokenResponse> {
const params = new URLSearchParams({
appid: this.config.appId,
secret: this.config.appSecret,
code,
grant_type: 'authorization_code',
});

const res = await fetch(
`https://api.weixin.qq.com/sns/oauth2/access_token?${params.toString()}`
);
return res.json() as Promise<WeChatTokenResponse>;
}

/** 获取微信用户信息 */
async getUserInfo(
accessToken: string,
openid: string
): Promise<WeChatUserInfo> {
const params = new URLSearchParams({
access_token: accessToken,
openid,
lang: 'zh_CN',
});

const res = await fetch(
`https://api.weixin.qq.com/sns/userinfo?${params.toString()}`
);
return res.json() as Promise<WeChatUserInfo>;
}
}

自有扫码 vs 第三方 OAuth 扫码对比:

特性自有 App 扫码微信/支付宝 OAuth
二维码生成自己生成 UUID第三方 OAuth URL
状态管理自建 Redis + WebSocket第三方回调通知
用户认证已登录 App 的 TokenOAuth 授权码 -> access_token
用户信息自有数据库第三方 API 获取
控制力完全可控受限于第三方规则
适用场景自有 App 用户体系社交登录、快速注册

6.2 过期与自动刷新

client/auto-refresh.ts
class QRAutoRefresh {
private countdown: number = 0;
private timer: ReturnType<typeof setInterval> | null = null;

/** 启动倒计时,过期自动刷新 */
startCountdown(
expireAt: number,
onTick: (remaining: number) => void,
onExpire: () => void
): void {
this.stopCountdown();

this.timer = setInterval(() => {
const remaining = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
this.countdown = remaining;
onTick(remaining);

if (remaining <= 0) {
this.stopCountdown();
onExpire();
}
}, 1000);
}

stopCountdown(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}

6.3 异常处理与降级

client/error-handling.ts
/** 带自动降级的扫码登录客户端 */
class QRLoginClient {
private wsSupported: boolean;
private currentStrategy: 'websocket' | 'sse' | 'polling';

constructor() {
this.wsSupported = 'WebSocket' in globalThis;
this.currentStrategy = this.wsSupported ? 'websocket' : 'polling';
}

/** 连接(自动选择策略) */
connect(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
switch (this.currentStrategy) {
case 'websocket':
this.connectWebSocket(uuid, onStatusChange);
break;
case 'sse':
this.connectSSE(uuid, onStatusChange);
break;
case 'polling':
this.startPolling(uuid, onStatusChange);
break;
}
}

/** WebSocket 失败时降级为轮询 */
private connectWebSocket(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
const ws = new WebSocket(`wss://example.com/ws/qr?uuid=${uuid}`);
let connected = false;

ws.onopen = () => {
connected = true;
};

ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
onStatusChange(data);
};

ws.onerror = () => {
if (!connected) {
// 首次连接失败,降级为轮询
console.warn('WebSocket unavailable, falling back to polling');
this.currentStrategy = 'polling';
this.startPolling(uuid, onStatusChange);
}
};
}

private connectSSE(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
const es = new EventSource(`/api/qr/events?uuid=${uuid}`);
es.onmessage = (event: MessageEvent) => {
onStatusChange(JSON.parse(event.data as string));
};
}

private startPolling(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
const poll = async (): Promise<void> => {
try {
const res = await fetch(`/api/qr/status?uuid=${uuid}`);
const data = await res.json();
onStatusChange(data);

if (!['CONFIRMED', 'EXPIRED', 'CANCELLED'].includes(data.status)) {
setTimeout(poll, 2000);
}
} catch {
setTimeout(poll, 5000); // 出错时延长间隔
}
};
poll();
}
}

常见面试问题

Q1: 扫码登录的完整流程是什么?每一步涉及哪些技术?

答案

扫码登录分为 四个阶段,涉及跨设备通信、状态管理、Token 安全等多项技术:

阶段PC 端服务端移动端
1. 生成二维码请求接口 -> 渲染二维码生成 UUID -> 存入 Redis(TTL 5min)-
2. 建立连接WebSocket 连接(监听 UUID)WebSocket 网关 + Redis Pub/Sub-
3. 扫码上报显示"已扫码"+ 用户头像校验手机 Token -> 更新状态 -> 推送扫码 -> 解析 UUID -> 调接口
4. 确认登录收到 Token -> 存储并跳转生成 PC Token -> 推送给 PC 端点击确认 -> 调接口

关键技术点:

  • UUID 随机性:使用 crypto.randomUUID() 防枚举
  • 状态机:PENDING -> SCANNED -> CONFIRMED,不可逆转
  • 实时通信:WebSocket(主)+ 轮询(降级)
  • Token 安全:JWT + 短有效期 + Refresh Token

Q2: 为什么扫码登录比密码登录更安全?

答案

扫码登录在安全性上有多项优势:

安全维度密码登录扫码登录
键盘记录容易被 keylogger 窃取无需输入密码
钓鱼攻击伪造登录页骗取密码手机端会展示设备信息供确认
暴力破解字典攻击、彩虹表UUID 不可预测(2^122)
密码泄露拖库后批量撞库无密码,不存在泄露
中间人攻击HTTPS 降级可能泄露多因素(手机 + 确认操作)
凭证复用同一密码多站使用每次扫码生成新的一次性 UUID

但扫码登录也有局限:

  • 依赖手机:手机没电/丢失时无法登录
  • 二维码劫持:页面被注入恶意代码替换二维码
  • 社会工程:诱导用户扫描攻击者的二维码
安全防护要点
  1. 确认页面必须显示 PC 端设备信息(浏览器、IP 地理位置)
  2. 二维码必须有过期时间(3-5 分钟)
  3. 全链路 HTTPS/WSS 加密
  4. 扫码和确认必须是两步操作,不能自动确认

Q3: 轮询、WebSocket、SSE 三种方案如何选择?各自的优缺点是什么?

答案

在扫码登录场景中,需要 PC 端实时感知状态变化(已扫码/已确认等),三种方案各有适用场景:

方案选择策略
// 推荐策略:WebSocket 优先,自动降级
function selectStrategy(): 'websocket' | 'sse' | 'polling' {
if ('WebSocket' in globalThis) return 'websocket';
if ('EventSource' in globalThis) return 'sse';
return 'polling';
}

详细对比:

维度短轮询WebSocketSSE
实时性1-3s 延迟毫秒级毫秒级
连接数每次新建 HTTP1 条长连接1 条长连接
服务端推送不支持(客户端拉)支持支持
带宽消耗高(大量无效请求)
断线重连不需要需要手动实现浏览器自动重连
跨域需 CORS需 CORS需 CORS
代理友好度需配置 upgrade
心跳维持不需要需要 ping/pong不需要
最佳实践
  • 首选 WebSocket:实时性最好,扫码后 PC 端即时感知
  • 降级 SSE:如果只需服务端推送(扫码场景正好只需要服务端推送状态)
  • 兜底轮询:在不支持 WebSocket/SSE 的环境(老旧浏览器、某些代理后)使用

更多关于实时通信技术的对比可参考 WebSocket 与 SSE

Q4: 如何防止扫码登录中的安全攻击?

答案

扫码登录的攻击面和防护方案:

1. 二维码钓鱼攻击

攻击者将自己生成的二维码替换到受害者页面上,受害者扫码后实际上是帮攻击者登录。

防护:域名校验 + 设备信息展示
// 移动端:扫码后严格校验域名
function validateQRCode(content: string): boolean {
try {
const url = new URL(content);
const whitelist = ['example.com', 'auth.example.com'];
return whitelist.includes(url.hostname) && url.protocol === 'https:';
} catch {
return false;
}
}

// 服务端:确认接口返回 PC 端设备信息
interface ConfirmPageInfo {
browser: string; // "Chrome 120, Windows 11"
ip: string; // "120.36.xxx.xxx"
location: string; // "广东省广州市"
loginTime: string; // "2026-02-27 14:30"
}

2. CSRF 攻击防护

防护:state 参数 + CSRF Token
// 生成二维码时绑定 CSRF Token
async function generateQRWithCSRF(sessionId: string): Promise<string> {
const csrfToken = crypto.randomUUID();

// 将 CSRF Token 绑定到会话
await redis.set(`csrf:${csrfToken}`, sessionId, 'EX', 300);

return `https://example.com/qr-login?code=${uuid}&csrf=${csrfToken}`;
}

// 确认时校验 CSRF Token
async function verifyCSRF(csrfToken: string, sessionId: string): Promise<boolean> {
const stored = await redis.get(`csrf:${csrfToken}`);
return stored === sessionId;
}

3. 重放攻击防护

防护:一次性 UUID + Nonce
// UUID 确认后立即从 Redis 删除(或标记为终态且不可再变)
async function confirmAndInvalidate(uuid: string): Promise<void> {
const key = `qr:login:${uuid}`;
// 使用 Redis 事务保证原子性
await redis
.multi()
.set(key, JSON.stringify({ status: 'CONFIRMED' }), 'EX', 10) // 10s 后彻底删除
.exec();
}

// 请求中加入 Nonce 防重放
function generateNonce(): string {
return `${Date.now()}-${crypto.randomUUID()}`;
}

安全检查清单:

检查项状态
全链路 HTTPS/WSS 加密必须
UUID 使用加密级随机数必须
二维码 3-5 分钟过期必须
扫码 + 确认两步操作必须
确认页展示 PC 设备信息推荐
频率限制(生成/扫码/确认)推荐
IP 一致性校验可选
请求签名 + Nonce 防重放可选
CSP 防页面注入推荐

相关链接