扫码登录:核心思想与参与者
扫码登录的核心思想是 “授权转移”。它利用一个已经处于登录状态的设备(通常是手机App),来为另一个未登录的设备(通常是PC浏览器)进行身份认证和授权,从而免去在PC端输入账号密码的繁琐过程。
核心参与者 (Actors)
- 用户 (User): 操作的发起者。
- PC端/浏览器 (Client): 需要登录的设备,是授权的“接收方”。
- 移动端App (Authenticator): 已登录的设备,是授权的“发起方”。
- 后端服务 (Server): 整个流程的协调者和状态管理者。
技术实现流程详解
整个过程可以分解为几个关键阶段,其核心是围绕一个一次性、有时效性的唯一ID进行状态流转。
架构与交互流程图 (Sequence Diagram)
下面是整个扫码登录过程的详细交互时序图。
sequenceDiagram
participant PC端 as PC/Browser
participant 后端服务 as Server
participant 移动端App as Mobile App
participant 用户 as User
%% 1. 获取二维码
PC端->>后端服务: GET /api/auth/qrcode (请求登录二维码)
后端服务->>后端服务: 1. 生成唯一ID (UUID)<br>2. 存储状态(UNSCANNED)到Redis,并设置短时效(e.g., 120s)<br> key: qr:login:UUID, value: {status:"UNSCANNED"}
后端服务-->>PC端: 返回 {qrCodeId: UUID, url: "app://login?id=UUID"}
PC端->>PC端: 3. 根据返回的URL生成二维码并展示
PC端->>后端服务: 4. 开始轮询: GET /api/auth/status/{UUID}
loop 轮询检查状态
后端服务-->>PC端: 返回当前状态 {status:"UNSCANNED"}
end
%% 2. 用户扫码与确认
User->>移动端App: 打开App扫描二维码
移动端App->>移动端App: 5. 解析二维码, 获得UUID
移动端App->>后端服务: 6. POST /api/auth/scan (携带App的Token和UUID)
后端服务->>后端服务: 7. 校验App Token, 验证用户身份<br>8. 更新Redis状态: {status:"SCANNED", user:"..."}, 并重置过期时间
后端服务-->>移动端App: 返回成功, 要求用户确认
移动端App->>User: 9. 显示确认登录界面 (e.g., "确认在XX电脑登录?")
%% 3. PC端获取到状态变更
Note right of PC端: 轮询请求仍在继续
PC端->>后端服务: GET /api/auth/status/{UUID}
后端服务-->>PC端: 返回 {status:"SCANNED", avatar:"..."}
PC端->>PC端: 10. 更新UI, 显示用户头像, 提示"扫码成功,请在手机上确认"
%% 4. 用户确认,完成登录
User->>移动端App: 11. 点击"确认登录"
移动端App->>后端服务: 12. POST /api/auth/confirm (携带App Token和UUID)
后端服务->>后端服务: 13. 再次校验, 确认无误<br>14. 为PC端生成一个Session Token (e.g., JWT)<br>15. 更新Redis状态: {status:"CONFIRMED", token:"JWT_FOR_PC"}
后端服务-->>移动端App: 返回登录成功
%% 5. PC端最终获取Token
Note right of PC端: 轮询请求仍在继续
PC端->>后端服务: GET /api/auth/status/{UUID}
后端服务-->>PC端: 返回 {status:"CONFIRMED", token:"JWT_FOR_PC"}
PC端->>PC端: 16. 获取到Token, 停止轮询<br>17. 存储Token(localStorage/Cookie)<br>18. 跳转到用户主页, 登录成功!
后端服务->>后端服务: 19. (可选)清理Redis中的UUID数据
二维码状态机 (State Machine)
二维码的生命周期可以用一个简单的状态机来描述,后端服务需要严格管理这些状态。
stateDiagram-v2
direction LR
[*] --> UNSCANNED: 生成二维码
UNSCANNED --> SCANNED: 手机扫码
UNSCANNED --> EXPIRED: 超时
SCANNED --> CONFIRMED: 用户在手机上确认
SCANNED --> CANCELLED: 用户在手机上取消
SCANNED --> EXPIRED: 确认超时
CONFIRMED --> [*]: PC端获取Token后
CANCELLED --> [*]: 流程终止
EXPIRED --> [*]: 流程终止
关键技术点与实现细节
1. 二维码的生成与内容
- 唯一标识 (UUID): 核心是生成一个全局唯一、不可预测、一次性的ID。Java中的
UUID.randomUUID().toString()
是绝佳选择。 - 二维码内容: 二维码中存储的内容不应是敏感信息,通常是一个自定义协议的URL,例如
myapp://login?qrcode_id=xxxx-xxxx-xxxx-xxxx
。移动端App注册了这个协议,扫码后能直接唤起App并传递qrcode_id
。
2. PC端与服务器的通信方式
PC端在展示二维码后,需要等待状态变更。有几种主流方式:
- 短轮询 (Short Polling): 实现简单,但延迟高、服务器压力大。不推荐。
- 长轮询 (Long Polling): 推荐方案。客户端发送请求后,如果服务器没有新状态,则将请求挂起(hold),直到有新状态或超时再返回。这大大减少了无效请求,降低了延迟。
- WebSocket: 最佳方案。建立一个持久的双向连接。服务器可以直接推送(push)状态变更给PC端,实时性最好,开销也最小。实现复杂度稍高。
3. 状态管理
- Redis: 扫码登录的状态是临时的、高频访问的,非常适合用Redis来存储。
- 数据结构: 可以使用
String
或Hash
。Hash更合适,可以将status
,userInfo
,pc_token
等都存在一个key下。- Key:
qrcodelogin:{uuid}
- Fields:
status
,userId
,avatar
,token
…
- Key:
- TTL (Time-To-Live): 必须为每个二维码ID设置过期时间(例如120秒),防止被恶意扫描或资源泄露。当用户扫码后,可以重置或延长这个过期时间。
- 数据结构: 可以使用
4. 安全性考量
- HTTPS: 所有通信链路必须使用HTTPS加密,防止中间人攻击。
- UUID的不可预测性: 保证攻击者无法伪造或猜测出有效的二维码ID。
- 一次性使用: 一个二维码ID在被成功使用一次后,必须立即失效。
- 移动端Token: 移动端App在与后端交互时,必须携带自己的有效Token,以证明其操作的合法性。
- 风险提示: 在手机确认界面,应明确显示登录的设备类型、地理位置(通过IP估算)、时间等信息,让用户清晰地知道自己在授权什么。
后端伪代码示例 (Java/Spring Boot)
@RestController
@RequestMapping("/api/auth")
public class QrCodeLoginController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final long QR_CODE_EXPIRE_SECONDS = 120;
// DTO for state management in Redis
@Data
static class QrCodeState {
private String status; // UNSCANNED, SCANNED, CONFIRMED, CANCELLED, EXPIRED
private String userId;
private String userAvatar;
private String pcToken;
}
/**
* 1. PC端获取二维码ID
*/
@GetMapping("/qrcode")
public Map<String, String> getQrCode() {
String uuid = UUID.randomUUID().toString();
String redisKey = "qrcodelogin:" + uuid;
QrCodeState state = new QrCodeState();
state.setStatus("UNSCANNED");
// Store in Redis with TTL
redisTemplate.opsForValue().set(redisKey, state, QR_CODE_EXPIRE_SECONDS, TimeUnit.SECONDS);
Map<String, String> response = new HashMap<>();
response.put("qrCodeId", uuid);
return response;
}
/**
* 2. 移动端扫码后调用
*/
@PostMapping("/scan")
public ResponseEntity<Void> scan(@RequestParam String qrCodeId, @RequestHeader("Authorization") String mobileToken) {
// 1. Validate mobileToken and get user info
UserInfo user = validateAndGetUser(mobileToken);
String redisKey = "qrcodelogin:" + qrCodeId;
QrCodeState state = (QrCodeState) redisTemplate.opsForValue().get(redisKey);
// 2. Check if QR code is valid and in UNSCANNED state
if (state == null || !"UNSCANNED".equals(state.getStatus())) {
return ResponseEntity.badRequest().build(); // Or other error status
}
// 3. Update state to SCANNED
state.setStatus("SCANNED");
state.setUserId(user.getId());
state.setUserAvatar(user.getAvatar());
redisTemplate.opsForValue().set(redisKey, state, QR_CODE_EXPIRE_SECONDS, TimeUnit.SECONDS); // Reset TTL
return ResponseEntity.ok().build();
}
/**
* 3. PC端长轮询接口
*/
@GetMapping("/status/{qrCodeId}")
public DeferredResult<QrCodeState> getStatus(@PathVariable String qrCodeId) {
String redisKey = "qrcodelogin:" + qrCodeId;
// Using DeferredResult for long polling
DeferredResult<QrCodeState> deferredResult = new DeferredResult<>(QR_CODE_EXPIRE_SECONDS * 1000L, new QrCodeState("EXPIRED"));
// Here you'd typically use a more sophisticated mechanism than a simple scheduled task
// For simplicity, we imagine a background task that checks Redis and completes the DeferredResult
// when the status changes from UNSCANNED.
// In a real implementation, you'd have a map of qrCodeId -> List<DeferredResult>
// When a state changes (e.g., in /scan or /confirm), you'd find the corresponding DeferredResult and set its result.
// Simplified check:
QrCodeState currentState = (QrCodeState) redisTemplate.opsForValue().get(redisKey);
if (currentState != null && !"UNSCANNED".equals(currentState.getStatus())) {
deferredResult.setResult(currentState);
} else {
// Add to a waiting queue to be resolved later
// longPollingManager.add(qrCodeId, deferredResult);
}
return deferredResult;
}
/**
* 4. 移动端确认登录
*/
@PostMapping("/confirm")
public ResponseEntity<Void> confirm(@RequestParam String qrCodeId, @RequestHeader("Authorization") String mobileToken) {
// 1. Validate mobileToken
validateToken(mobileToken);
String redisKey = "qrcodelogin:" + qrCodeId;
QrCodeState state = (QrCodeState) redisTemplate.opsForValue().get(redisKey);
// 2. Check if QR code is in SCANNED state
if (state == null || !"SCANNED".equals(state.getStatus())) {
return ResponseEntity.badRequest().build();
}
// 3. Generate a new token for the PC session
String pcToken = generateJwtForUser(state.getUserId());
// 4. Update state to CONFIRMED and store the token
state.setStatus("CONFIRMED");
state.setPcToken(pcToken);
redisTemplate.opsForValue().set(redisKey, state, 60, TimeUnit.SECONDS); // Short TTL for PC to fetch the token
// Notify the long-polling request (if any)
// longPollingManager.notify(qrCodeId, state);
return ResponseEntity.ok().build();
}
}
总结
扫码登录是一个典型的分布式、多端协作的业务场景。其实现优雅与否,关键在于:
- 清晰的状态管理:定义好二维码的生命周期,并用可靠的工具(如Redis)进行管理。
- 高效的通信机制:选择长轮询或WebSocket来避免资源浪费,提升用户体验。
- 严密的安全性设计:在每个环节都考虑安全风险,确保授权过程不被劫持或滥用。
评论区