扫描登录实现

978 字 约 4 分钟

扫码登录:核心思想与参与者

扫码登录的核心思想是 “授权转移”。它利用一个已经处于登录状态的设备(通常是手机App),来为另一个未登录的设备(通常是PC浏览器)进行身份认证和授权,从而免去在PC端输入账号密码的繁琐过程。

核心参与者 (Actors)

  1. 用户 (User): 操作的发起者。
  2. PC端/浏览器 (Client): 需要登录的设备,是授权的“接收方”。
  3. 移动端App (Authenticator): 已登录的设备,是授权的“发起方”。
  4. 后端服务 (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)
2. 存储状态(UNSCANNED)到Redis,并设置短时效(e.g., 120s)
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, 验证用户身份
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. 再次校验, 确认无误
14. 为PC端生成一个Session Token (e.g., JWT)
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, 停止轮询
17. 存储Token(localStorage/Cookie)
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来存储。
    • 数据结构: 可以使用StringHash。Hash更合适,可以将status, userInfo, pc_token等都存在一个key下。
      • Key: qrcodelogin:{uuid}
      • Fields: status, userId, avatar, token
    • TTL (Time-To-Live): 必须为每个二维码ID设置过期时间(例如120秒),防止被恶意扫描或资源泄露。当用户扫码后,可以重置或延长这个过期时间。

4. 安全性考量

  • HTTPS: 所有通信链路必须使用HTTPS加密,防止中间人攻击。
  • UUID的不可预测性: 保证攻击者无法伪造或猜测出有效的二维码ID。
  • 一次性使用: 一个二维码ID在被成功使用一次后,必须立即失效。
  • 移动端Token: 移动端App在与后端交互时,必须携带自己的有效Token,以证明其操作的合法性。
  • 风险提示: 在手机确认界面,应明确显示登录的设备类型、地理位置(通过IP估算)、时间等信息,让用户清晰地知道自己在授权什么。

后端伪代码示例 (Java/Spring Boot)

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
@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();
}
}

总结

扫码登录是一个典型的分布式、多端协作的业务场景。其实现优雅与否,关键在于:

  1. 清晰的状态管理:定义好二维码的生命周期,并用可靠的工具(如Redis)进行管理。
  2. 高效的通信机制:选择长轮询或WebSocket来避免资源浪费,提升用户体验。
  3. 严密的安全性设计:在每个环节都考虑安全风险,确保授权过程不被劫持或滥用。