打开你手机里的淘宝、微信、抖音等等 App,你会发现扫码登录”已经成为了几乎所有多端(PC端 + 移动端)应用的标配功能。
对于用户而言,扫码登录极大地提升了体验,即无需在键盘上繁琐地输入账号密码,只需拿起手机轻轻一扫,即可完成授权。而对于企业而言,可以通过这一技术将PC端流量向移动端引流、提升App日活和用户粘性的重要运营手段。
那么,这个看似简单的“一扫一按”背后,到底隐藏着怎样的技术实现原理?前后端是如何配合的?
一、 为什么我们需要扫码登录?
在深入技术细节之前,我们先理清业务背景。传统的账号密码登录存在几个痛点:
- 输入成本高:尤其是在公共设备或陌生设备上,输入长串密码非常不便。
- 安全风险:在网吧或公共电脑上输入密码,存在被键盘记录器等木马盗取的风险。
- 多端割裂:用户在手机上明明已经处于登录状态,但在PC上还要重新走一遍身份验证流程。
扫码登录完美解决了这些问题。它的本质是:利用已经完成身份认证的移动端App,去授权未完成身份认证的PC端。
二、 扫码登录的核心生命周期
无论是微信扫码、QQ扫码还是各类内部系统的扫码登录,其底层逻辑万变不离其宗。我们可以将整个复杂的生命周期抽象为五个大步骤,并在工程实现上将其精简为三大核心阶段。
完整的用户体验五步曲:
- 生成二维码:PC端展示一个带有时效性的二维码。
- 扫码:用户掏出手机App扫描该二维码。
- 确认登录:手机App提示“是否允许登录PC端”,用户点击确认。
- PC端轮询/监听:PC端感知到授权成功。
- 登录成功:PC端获取到身份凭证(Token),正常访问系统。
在后端架构与业务流程设计上,我们将其提炼为三个核心技术步骤:生成二维码 -> 扫码 -> 确认登录。下面我们将逐一进行源码级深度的剖析。
三、 核心步骤拆解与技术实现
要实现扫码登录,我们需要借助一个关键的中间件:Redis。因为扫码登录涉及到多端(PC浏览器与移动端App)之间的状态共享与通信,而Redis凭借其高性能的内存读写和Key过期机制,是处理此类状态流转的最佳选择。
阶段一:生成二维码 (构建授权凭证)
当用户在PC端点击“扫码登录”时,故事就开始了。
1. 前端请求后端生成凭证
PC端前端会向后端服务器发起一个请求,要求获取登录二维码。
2. 后端生成全局唯一ID (QR_Code_ID)
后端接收到请求后,会生成一个全局唯一的ID(通常使用UUID即可)。这个ID就是这个二维码的“灵魂”,它将贯穿整个登录流程。
3. Redis 状态存储
后端拿到这个生成的 QR_Code_ID 后,会将其存入 Redis 中。在这里,我们需要维护二维码的状态机。初始状态为 NEW(待扫描)。
为了防止二维码无限期有效导致安全漏洞和内存泄漏,我们必须为这个 Key 设置一个过期时间(TTL),通常是 1 到 5 分钟。
Redis 数据结构示例:
- Key:
qrcode:status:{QR_Code_ID} - Value:
NEW(或对应的枚举值 0) - Expire:
300s
4. 响应前端并生成图片
后端将这个 QR_Code_ID 返回给 PC端。
关于二维码图片的生成,有两种流派:
- 后端生成:后端通过 ZXing 等库,将
QR_Code_ID结合特定的 URL Scheme 生成二维码图片,转成 Base64 字符串返回给前端直接渲染。 - 前端生成(推荐):后端只返回
QR_Code_ID和扫描跳转的协议链接(如myapp://login/auth?qrcodeId=xxx),前端利用 JavaScript 库(如qrcode.js)在本地浏览器直接生成图片。这种方式可以极大地减轻服务器的 CPU 计算压力。
阶段二:App扫码 (状态流转与临时授权)
二维码生成后,PC端屏幕上就出现了一个等待被扫描的图案。此时,PC端不能傻等着,它需要知道用户到底扫没扫。
1. PC端的“翘首以盼”(状态同步)
PC端需要不断地向后端确认这个二维码的状态。常见的技术方案有三种:
- 短轮询 (Short Polling):前端利用
setInterval每隔 1-2 秒向后端发一次 HTTP 请求。实现极其简单,但对服务器压力大,产生大量无效请求。(淘宝目前部分场景仍在使用轮询)。 - 长链接 (WebSocket):PC端和后端建立双向通信通道。状态一旦改变,后端主动推送到前端。实时性极高,但增加了后端的架构复杂度。
- SSE (Server-Sent Events):一种单向的长链接技术,后端可以向前端推送数据。在扫码登录这种“后端向前端单向通知”的场景中非常合适。
2. App 端发起扫码
用户打开手机 App,且此时手机App必须已经是登录状态。App调用系统摄像头扫描二维码,解析出其中的 QR_Code_ID。
3. App 向后端发送扫码请求
App 拿到 QR_Code_ID 后,立刻向后端发送一个“我扫了”的请求。
这个请求非常关键,它必须携带两样东西:
- 刚刚解析出来的
QR_Code_ID。 - App 当前用户的身份凭证(如
App_User_Token)。
4. 后端处理扫码逻辑
后端收到请求后,首先校验 App_User_Token 的合法性。如果 Token 失效,会要求用户先在 App 上登录。
校验通过后,后端确认了“是哪个用户正在扫码”。接着,后端会对 Redis 进行更新:
- 将
qrcode:status:{QR_Code_ID}的状态从NEW(待扫描)修改为SCANNED(已扫描待确认)。 - 注意:生成临时 Token (Temp_Token)。后端生成一个一次性的临时 Token,并将其与
QR_Code_ID绑定存入 Redis。然后将这个Temp_Token返回给 App 端。
为什么不直接登录?为什么要多一步“临时 Token”?
这是为了安全和用户控制权。扫码并不意味着立刻授权,用户有可能是不小心扫到了,或者是在不知情的情况下被诱导扫码。因此,App 扫码后,必须在手机上弹出一个“确认登录 PC 端”的界面。这个临时 Token 就是为了下一步“确认操作”做防伪验证的,确保确认请求确实来自于刚才扫码的同一台手机。
此时,PC端通过轮询或 WebSocket 感知到了 Redis 中状态变成了 SCANNED,页面会立刻反馈,通常是给二维码蒙上一层半透明的遮罩,并提示:“扫描成功,请在手机端确认登录”。
阶段三:确认登录 (颁发正式凭证)
此时,压力来到了手机 App 这边,界面上正展示着“确认登录 Web 端”的按钮。
1. App 发送确认请求
用户点击“确认登录”。App 会携带上一步获取到的临时凭证 Temp_Token,向后端发起最终的确认请求。
2. 后端执行最终校验与授权
后端收到确认请求,开始执行最核心的登录逻辑:
- 校验 Temp_Token:去 Redis 中核对这个临时 Token 是否合法、是否与当前要操作的
QR_Code_ID匹配。 - 防重复处理:一旦校验成功,立刻将这个
Temp_Token从 Redis 中删除(或标记失效),保证其一次性使用的安全性。 - 状态变更为已确认:将
qrcode:status:{QR_Code_ID}的状态从SCANNED变更为VERIFIED(已确认/已使用)。 - 颁发 PC 端 Token:既然用户已经同意,后端此时会为 PC 端生成一个全新的、具有相应权限的
PC_User_Token。 - 绑定用户信息:将
PC_User_Token与该用户的账号信息绑定,存入 Redis(或后端的 Session 体系中)。 - 关联 Token 与 二维码:为了让 PC 端拿到这个 Token,后端需要把生成的
PC_User_Token暂时存储在 Redis 中,Key 可以依然是这个QR_Code_ID。
3. PC端拿到钥匙,大门敞开
PC 端的那一次轮询(或者 WebSocket 监听)终于等到了好消息。它查询到二维码状态变成了 VERIFIED,并且后端在响应中附带了刚刚生成的 PC_User_Token。
PC 端前端拿到这个 Token 后,将其存入本地的 Cookie 或 LocalStorage 中。随后,页面进行重定向跳转到系统的首页。
至此,整个扫码登录流程完美闭环!
四、核心代码结构展示
下面是一段后端处理逻辑的伪代码骨架,展示状态机的判断逻辑。
枚举类:二维码状态定义
public enum QRCodeStateEnum {
NEW(0, "待扫描"),
SCANNED(1, "已扫描,待确认"),
VERIFIED(2, "已确认登录"),
EXPIRED(3, "二维码已过期");
// ... 省略属性和构造方法
}后端确认登录接口
@PostMapping("/qrcode/confirm")
public Result confirmLogin(@RequestParam("tempToken") String tempToken,
@RequestHeader("App-Token") String appToken) {
// 1. 校验 App Token 是否有效
UserInfo user = authService.verifyAppToken(appToken);
if (user == null) {
return Result.error(ErrorCodeEnum.LOGIN_FAIL, "App登录已失效,请重新登录");
}
// 2. 校验临时 Token,并解析出对应的二维码 ID
String qrCodeId = redisService.get(RedisKey.TEMP_TOKEN_PREFIX + tempToken);
if (StringUtils.isBlank(qrCodeId)) {
return Result.error(ErrorCodeEnum.EXPIRED, "操作已过期,请重新扫码");
}
// 3. 防并发与幂等:删除临时 Token
redisService.delete(RedisKey.TEMP_TOKEN_PREFIX + tempToken);
// 4. 生成 PC 端的正式 Token
String pcToken = jwtUtils.generateToken(user.getUserId());
// 5. 更新二维码状态为已确认,并将 PC Token 放入 Redis 供前端轮询获取
Map<String, Object> stateData = new HashMap<>();
stateData.put("status", QRCodeStateEnum.VERIFIED.getCode());
stateData.put("pcToken", pcToken);
redisService.set(
RedisKey.QR_CODE_STATUS_PREFIX + qrCodeId,
stateData,
60,
TimeUnit.SECONDS
);
return Result.success("登录成功");
}PC 端轮询接口
@GetMapping("/qrcode/status")
public Result checkStatus(@RequestParam("qrCodeId") String qrCodeId) {
Object data = redisService.get(RedisKey.QR_CODE_STATUS_PREFIX + qrCodeId);
if (data == null) {
return Result.error(ErrorCodeEnum.QRCODE_EXPIRED, "二维码已失效");
}
// 解析 Redis 中的状态
Map<String, Object> stateData = (Map<String, Object>) data;
Integer status = (Integer) stateData.get("status");
switch (status) {
case 0: // NEW
return Result.success(data).setMessage("请用手机扫码");
case 1: // SCANNED
return Result.success(data).setMessage("已扫描,请在手机端确认");
case 2: // VERIFIED
// 此时 data 中包含了 pcToken
return Result.success(data).setMessage("登录成功");
default:
return Result.error(ErrorCodeEnum.SERVER_ERROR, "未知状态");
}
}五、 异常场景与安全性设计
在真实的企业应用中,可能会面对一系列“非正常场景”:
1. 安全性考量:二维码被劫持怎么办?
整个通信过程必须严格采用 HTTPS 协议,防止中间人抓包。此外,二维码的 QR_Code_ID 应当具有足够的随机性(比如 UUIDv4),防止被暴力遍历或猜测。在 App 扫码时,还可以增加设备环境检测(如 IP 地址异地风险提示),如果发现扫码 IP 与 PC 端请求 IP 跨省份,可以增加二次验证。
2. 为什么一定要分成“已扫描”和“已确认”两个状态?
这是经典的 CSRF(跨站请求伪造)防御思想。如果扫码即登录,黑客可以将你的登录二维码伪装成一个诱导性的图片(比如“扫码领红包”)发到群里。不知情的用户一旦用 App 扫码,黑客的 PC 端就直接登录了你的账号。引入“待确认”状态,强制用户在 App 界面上看到“确认登录 Windows 版微信”等明确提示,将最终授权的决定权交还给用户。
3. 二维码过期机制如何设计?
依托于 Redis 的 TTL 机制。当生成 QR_Code_ID 时,设置 Key 的过期时间为 3 分钟。PC 端的轮询接口发现查不到该 Key 时,即可判定二维码已过期,前端展示“二维码已失效,请点击刷新”的遮罩。重新刷新即重新走阶段一的流程。