设计扫码登录系统
问题
如何设计一个安全可靠的扫码登录系统?从二维码生成、状态流转、实时通信到安全防护,请详细说明扫码登录的完整架构与关键技术实现,并扩展到微信/支付宝等第三方 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),编码为可扫描的图形。
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),状态之间只能按规定的路径流转:
/** 状态流转合法性校验 */
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 端需要实时感知二维码状态变化,有三种主流方案:
- 短轮询
- WebSocket(推荐)
- SSE(Server-Sent Events)
/** 短轮询方案 */
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;
}
}
}
/** WebSocket 方案(推荐) */
class QRWebSocket {
private ws: WebSocket | null = null;
private reconnectCount = 0;
private readonly MAX_RECONNECT = 5;
connect(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
const wsUrl = `wss://example.com/ws/qr?uuid=${uuid}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectCount = 0;
};
this.ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
onStatusChange(data);
// 终态关闭连接
if (['CONFIRMED', 'EXPIRED', 'CANCELLED'].includes(data.status)) {
this.close();
}
};
this.ws.onclose = () => {
// 非终态时自动重连
if (this.reconnectCount < this.MAX_RECONNECT) {
this.reconnectCount++;
const delay = Math.min(1000 * 2 ** this.reconnectCount, 30000);
setTimeout(() => this.connect(uuid, onStatusChange), delay);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
close(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
/** SSE 方案 */
class QRSSE {
private eventSource: EventSource | null = null;
connect(
uuid: string,
onStatusChange: (data: { status: QRStatus; token?: string }) => void
): void {
this.eventSource = new EventSource(`/api/qr/events?uuid=${uuid}`);
this.eventSource.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
onStatusChange(data);
if (['CONFIRMED', 'EXPIRED', 'CANCELLED'].includes(data.status)) {
this.close();
}
};
this.eventSource.onerror = () => {
// SSE 内置自动重连机制
console.error('SSE connection error');
};
}
close(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
三种方案对比:
| 特性 | 短轮询 | WebSocket | SSE |
|---|---|---|---|
| 实时性 | 取决于轮询间隔(1-3s) | 实时(毫秒级) | 实时(毫秒级) |
| 服务端压力 | 高(大量无效请求) | 低(长连接) | 低(长连接) |
| 实现复杂度 | 简单 | 中等(需心跳/重连) | 简单(内置重连) |
| 双向通信 | 否 | 是 | 否(服务端单向推送) |
| 浏览器兼容 | 全兼容 | IE10+ | IE 不支持 |
| 代理/CDN 友好 | 友好 | 需要配置 | 友好 |
| 资源消耗 | 带宽浪费大 | 连接资源 | 连接资源 |
| 适用场景 | 简单场景、兼容性要求高 | 推荐方案、需双向通信 | 服务端单向推送 |
推荐使用 WebSocket 作为主方案,短轮询作为降级方案。连接建立后设置心跳检测(30s 一次 ping/pong),异常断开后指数退避重连。SSE 也是不错的选择,因为扫码登录只需要服务端单向推送,SSE 的内置重连机制更省心。
更多实时通信方案的深入分析可参考 WebSocket 与 SSE 和 设计实时通讯系统。
四、关键技术实现
4.1 PC 端实现
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 移动端实现
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 实现
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 网关
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 安全实现
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,
},
};
- 全链路 HTTPS/WSS:防止中间人窃听
- UUID 不可预测:使用
crypto.randomUUID()而非自增 - 一次性使用:UUID 确认后立即失效
- 手动确认:扫码后必须手动点击确认按钮
- 设备信息展示:确认页面展示 PC 端的浏览器和 IP,方便用户识别异常
- 频率限制:防止枚举攻击
关于更多 Web 安全防护的详细内容,可参考 浏览器安全 和 JWT 认证。
六、扩展设计
6.1 第三方扫码登录(微信/支付宝 OAuth)
第三方扫码登录与自有 App 扫码原理类似,区别在于 引入了 OAuth 2.0 授权码流程:
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 的 Token | OAuth 授权码 -> access_token |
| 用户信息 | 自有数据库 | 第三方 API 获取 |
| 控制力 | 完全可控 | 受限于第三方规则 |
| 适用场景 | 自有 App 用户体系 | 社交登录、快速注册 |
6.2 过期与自动刷新
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 异常处理与降级
/** 带自动降级的扫码登录客户端 */
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 |
但扫码登录也有局限:
- 依赖手机:手机没电/丢失时无法登录
- 二维码劫持:页面被注入恶意代码替换二维码
- 社会工程:诱导用户扫描攻击者的二维码
- 确认页面必须显示 PC 端设备信息(浏览器、IP 地理位置)
- 二维码必须有过期时间(3-5 分钟)
- 全链路 HTTPS/WSS 加密
- 扫码和确认必须是两步操作,不能自动确认
Q3: 轮询、WebSocket、SSE 三种方案如何选择?各自的优缺点是什么?
答案:
在扫码登录场景中,需要 PC 端实时感知状态变化(已扫码/已确认等),三种方案各有适用场景:
// 推荐策略:WebSocket 优先,自动降级
function selectStrategy(): 'websocket' | 'sse' | 'polling' {
if ('WebSocket' in globalThis) return 'websocket';
if ('EventSource' in globalThis) return 'sse';
return 'polling';
}
详细对比:
| 维度 | 短轮询 | WebSocket | SSE |
|---|---|---|---|
| 实时性 | 1-3s 延迟 | 毫秒级 | 毫秒级 |
| 连接数 | 每次新建 HTTP | 1 条长连接 | 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 攻击防护
// 生成二维码时绑定 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 确认后立即从 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 防页面注入 | 推荐 |
相关链接
- WebSocket 与 SSE - WebSocket、Server-Sent Events、长轮询、Socket.IO
- 设计实时通讯系统 - WebSocket、消息投递、已读回执
- 浏览器安全 - XSS、CSRF、CSP、点击劫持
- JWT 认证 - 结构、优缺点、刷新机制
- 前端 SDK 通用架构设计 - SDK 架构设计
- 设计权限管理系统 - RBAC、认证授权
- MDN - WebSocket API
- MDN - EventSource (SSE)
- 微信开放平台 - 网站应用微信登录
- OAuth 2.0 RFC 6749
- qrcode - npm