侧边栏壁纸
博主头像
兰若春夏 博主等级

一日为坤,终生为坤

  • 累计撰写 22 篇文章
  • 累计创建 12 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

扫描登录实现

奥德坤
2025-07-27 / 0 评论 / 0 点赞 / 5 阅读 / 0 字

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

扫码登录的核心思想是 “授权转移”。它利用一个已经处于登录状态的设备(通常是手机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)<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来存储。
    • 数据结构: 可以使用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)

@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. 严密的安全性设计:在每个环节都考虑安全风险,确保授权过程不被劫持或滥用。
0

评论区