目录
一、扫码登录要解决的真问题
二、完整流程:从生成二维码到换取登录态
三、动手写一个能跑的扫码登录后端
四、为什么是长轮询,而不是 WebSocket
五、一次领取:三层保护缺一不可
六、状态机:pending 到 confirmed 到 consumed
七、二维码登录劫持:原理与三道防线
八、它和 CSRF、和第三方扫码的区别
九、一份能直接抄的排查清单
一、扫码登录要解决的真问题
先摆平一个常见误解:扫码登录不是「更方便的输密码方式」,它解决的是两个更底层的问题。
问题一:PC 端没有输入凭证的能力。密码登录的前提是「用户能在输入框里敲字符」,而扫码登录的适用场景恰恰是输入能力被拿走的地方:
| 场景 | 密码登录为什么不行 |
|---|---|
| 收银一体机、货架终端,无物理键盘 | 触屏软键盘慢到崩溃,还容易把密码敲错框 |
| 电视、车机、大屏,遥控器输入 | 输一个密码要按几十次方向键 |
| 内网机器,禁止复制粘贴 | 口令粘不进去,只能手敲 |
问题二:二维码天然携带了设备绑定信息。二维码内容不是裸账号,而是一串服务端自己签发的一次性随机串(本文叫ticket):服务端生成、服务端知道它对应哪台 PC、几十秒内有效、天生只能用一次。二维码把「谁在登录」从「用户口述」变成了「服务端自己记账」—— 密码登录时服务端只知道「有人提交了正确的密码」,扫码登录时服务端知道「是这台 PC 的请求,被持有账号 X 的手机确认了」。
顺带说清定位:扫码登录不替代密码,它是把凭证从 PC 端转移到手机端。手机端点「确认登录」等价于在 PC 端输入密码,确认页每次点击的权限都要给足。
二、完整流程:从生成二维码到换取登录态
拆成五步,每一步都有明确的接口和明确的状态:
第 1 步:PC 生成二维码。PC 端调POST /api/qr/create,服务端生成随机ticket(32 位 hex),落缓存,状态置pending,记下过期时间(我设 180 秒),把「扫码 URL + ticket」渲染成二维码。
第 2 步:手机扫码,查状态。手机解析出ticket问服务端。服务端返回pending,同时返回要在手机上展示给人看的信息—— 这一步常被忽略,但它是防劫持的关键。
第 3 步:手机确认,服务端发一次性 code。用户点「确认登录」,服务端把 ticket 改成confirmed,同时生成一个一次性授权码code写进缓存,TTL 60 秒。
第 4 步:PC 长轮询拿 code。没确认就挂着等,确认了立刻返回。
第 5 步:PC 用 code 换登录态。PC 提交code,服务端校验后签发属于这台 PC 的那个会话(Cookie / Token)。
注意第 3、4 步的分工:手机只发 code,从不碰 PC 的登录态;PC 只拿 code,从不碰手机端的会话凭证。中间那个code是一次性的交接棒 —— 为什么非要过这一道?因为载荷必须与发起方绑定:PC 拿 code 换自己的会话,而不是把「我是 u_10086」写在 URL 里传。
三、动手写一个能跑的扫码登录后端
四个接口,一个 Flask 文件。服务端骨架:
# -*- coding: utf-8 -*- """ 晚风奶茶(WANFENG)会员系统 · 扫码登录靶场 Python 3.13 + Flask 3.1 + redis-py 5.x(Redis 7.x 协议) ⚠️ 本机靶场:所有接口只跑在 127.0.0.1,禁止部署到公网。 """ import hashlib import json import os import time import uuid from urllib.parse import unquote import redis from flask import Flask, jsonify, request app = Flask(__name__) R = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"), decode_responses=True) # DEMO_INSECURE=1 时关掉账号一致性和 Origin 校验,用来复现二维码登录劫持 INSECURE = os.getenv("DEMO_INSECURE") == "1" QR_TTL = 180 # 二维码有效期 3 分钟 CODE_TTL = 60 # 授权码有效期 1 分钟 POLL_MAX = 60 # 长轮询总上限(秒) POLL_STEP = 2 # 长轮询内部检查间隔(秒) ALLOWED_ORIGINS = {"http://127.0.0.1:8020", "http://127.0.0.1:8021"} # 演示用的「手机端已登录会话」:真实系统里 uid 从手机端登录态取,不信前端传值 PHONE_SESSIONS = { "phone-demo-9f2a": {"uid": "u_10086", "phone": "13800138000"}, "phone-demo-77bc": {"uid": "u_20751", "phone": "13900139000"}, } def sha256(s: str) -> str: return hashlib.sha256(s.encode()).hexdigest() def origin_ok() -> bool: origin = request.headers.get("Origin") return origin is None or origin in ALLOWED_ORIGINSRedis 里两个 key:
# qr:ticket:{ticket} Hash status/pc/account/device/code/uid TTL 180 # qr:code:{code} String {"uid": ..., "pc": ...} TTL 60 # # pc 字段存 PC 会话 id 的 sha256,不是明文 —— 日志万一漏了也不至于直接可用3.1 PC 端:生成二维码
@app.post("/api/qr/create") def qr_create(): """PC 生成二维码:随机 ticket 落库,绑定本 PC 会话""" body = request.get_json(silent=True) or {} account = body.get("account", "") # PC 登录页上输入的账号 pc_sid = request.cookies.get("pc_sid") or uuid.uuid4().hex ticket = uuid.uuid4().hex R.hset(f"qr:ticket:{ticket}", mapping={ "status": "pending", "pc": sha256(pc_sid), "account": account, "device": body.get("device", ""), }) R.expire(f"qr:ticket:{ticket}", QR_TTL) resp = jsonify({ "ticket": ticket, "qr_url": f"http://127.0.0.1:8020/scan?ticket={ticket}", "expires_in": QR_TTL, "poll": {"interval": POLL_STEP, "timeout": POLL_MAX}, "display_account": account, # 回显给 PC 页面,让用户核对 }) resp.set_cookie("pc_sid", pc_sid, httponly=True, samesite="Lax", max_age=3600) return resp实测响应和 Redis 里的状态:
{ "display_account": "13800138000", "expires_in": 180, "poll": { "interval": 2, "timeout": 60 }, "qr_url": "http://127.0.0.1:8020/scan?ticket=ac9daf95905c46e98247e37620b56880", "ticket": "ac9daf95905c46e98247e37620b56880" }HGETALL qr:ticket:ac9daf95905c46e98247e37620b56880 {'status': 'pending', 'pc': 'ada7a543f632e1604a342eea5841f5fdebfae3386f39a3d541b0de8d1fd0eb0c', 'account': '13800138000', 'device': 'Windows 11'} TTL qr:ticket:ac9daf95905c46e98247e37620b56880 = 1803.2 手机端:查状态 + 确认
@app.get("/api/qr/status") def qr_status(): """手机扫码后先查状态:拿到要展示给人看的信息""" ticket = request.args.get("ticket", "") data = R.hgetall(f"qr:ticket:{ticket}") if not data: return jsonify({"ok": False, "error": "二维码已过期或不存在"}), 404 if data.get("status") != "pending": return jsonify({"ok": False, "error": "该二维码已被处理", "status": data["status"]}), 409 return jsonify({ "ok": True, "status": "pending", "need_confirm": True, "display_account": data.get("account", ""), "pc_device": data.get("device", ""), "pc_location": unquote(request.headers.get("X-Geo", "未知地区")), }) @app.post("/api/qr/confirm") def qr_confirm(): """用户在手机上点「确认登录」""" if not INSECURE and not origin_ok(): return jsonify({"ok": False, "error": "跨站请求被拒绝"}), 403 body = request.get_json(silent=True) or {} ticket = body.get("ticket", "") sess = PHONE_SESSIONS.get(request.headers.get("X-Phone-Session", "")) if not sess: return jsonify({"ok": False, "error": "手机端未登录"}), 401 key = f"qr:ticket:{ticket}" data = R.hgetall(key) if not data: return jsonify({"ok": False, "error": "二维码已过期"}), 404 # 账号一致性:PC 端必须先声明要登哪个账号,且要和手机端当前账号完全一致 if not INSECURE: if not data.get("account"): return jsonify({"ok": False, "error": "PC 端未声明登录账号,拒绝确认"}), 400 if data["account"] != sess["phone"]: return jsonify({"ok": False, "error": "PC 端请求登录的账号与当前账号不一致", "pc_account": data["account"], "phone_account": sess["phone"]}), 409 code = uuid.uuid4().hex payload = json.dumps({"uid": sess["uid"], "pc": data.get("pc", "")}) r = R.eval(CONFIRM_LUA, 2, key, f"qr:code:{code}", code, QR_TTL, sess["uid"], payload, CODE_TTL) if r == -1: return jsonify({"ok": False, "error": "二维码已过期"}), 404 if r == 0: return jsonify({"ok": False, "error": "该二维码已被确认过"}), 409 return jsonify({"ok": True, "ticket": ticket})这里最关键的一行,是uid的来源。我没从请求体读用户 ID,而是从X-Phone-Session查出来:
sess = PHONE_SESSIONS.get(request.headers.get("X-Phone-Session", "")) uid = sess["uid"]如果写成uid = request.json.get("uid"),任何人都能把 uid 改成别人的 ID 来确认登录 —— 授权动作就变成「谁都能替别人点确认」。授权凭证必须来自服务端签发的会话,不是请求体。
3.3 PC 端:长轮询 + 兑换登录态
@app.get("/api/qr/poll") def qr_poll(): """PC 长轮询:服务端挂起连接,每 2 秒查一次,最多 60 秒""" ticket = request.args.get("ticket", "") key = f"qr:ticket:{ticket}" deadline = time.time() + POLL_MAX while True: data = R.hgetall(key) if not data: return jsonify({"status": "expired", "msg": "二维码已过期,请刷新"}) if data.get("status") == "confirmed": code = R.eval(CLAIM_LUA, 1, key) if code: return jsonify({"status": "confirmed", "code": code}) return jsonify({"status": "expired", "msg": "该二维码已被其他请求领取"}) if data.get("status") == "consumed": return jsonify({"status": "expired", "msg": "该二维码已被使用"}) if time.time() >= deadline: return jsonify({"status": "timeout", "msg": "等待超时,请刷新重试"}) time.sleep(POLL_STEP) @app.post("/api/session/exchange") def session_exchange(): """PC 拿 code 换自己的登录态:GETDEL 原子取+删,并校验 PC 会话绑定""" body = request.get_json(silent=True) or {} code = body.get("code", "") pc_sid = request.cookies.get("pc_sid", "") raw = R.getdel(f"qr:code:{code}") # 原子:并发下只可能被兑换一次 if not raw: return jsonify({"ok": False, "error": "授权码无效或已被使用"}), 401 payload = json.loads(raw) if payload.get("pc") != sha256(pc_sid): return jsonify({"ok": False, "error": "授权码不属于当前设备"}), 403 token = uuid.uuid4().hex R.setex(f"milktea:session:{token}", 3600, payload["uid"]) resp = jsonify({"ok": True, "token": token, "uid": payload["uid"]}) resp.set_cookie("milktea_session", token, httponly=True, samesite="Lax") return resp跑一遍完整流程,PC 端拿到的最终结果:
--- A5. PC 用 code 换登录态 --- HTTP 200 { "ok": true, "token": "b929ca5387b04ad8bb210ea33e6d9038", "uid": "u_10086" } --- A6. 验证登录态 --- HTTP 200 { "logged_in": true, "uid": "u_10086" } --- A7. 同一个 code 再兑换一次(应 401)--- HTTP 401 { "error": "授权码无效或已被使用", "ok": false }A7 是重点:同一个 code 第二次兑换直接 401。这不靠「用完记得删」实现,靠的是GETDEL这一个原子命令(下文详述)。
四、为什么是长轮询,而不是 WebSocket
扫码登录的信息推送有四种常见做法:
| 方案 | 怎么工作 | 扫码登录场景的适配度 |
|---|---|---|
| 短轮询 | 客户端每 2 秒问一次「好了吗」 | 能用,但请求数 = 等待时长 / 间隔,用户不扫就是纯浪费 |
| 长轮询 | 服务端挂住连接直到有结果或超时 | ✅ 最合适:一次连接解决,用户不扫时占用可控 |
| SSE | 服务端通过常驻 HTTP 流单向推送 | 要处理断线重连、Last-Event-ID续传,收益不明显 |
| WebSocket | 双向长连接协议 | 杀鸡用牛刀:多一套连接池和鉴权 |
还有一个常被忽略的理由:降级容易—— 它本质还是普通 HTTP 请求,超时了客户端重发就行。
但长轮询必须有上限。这是实操里最容易翻车的地方:
deadline = time.time() + POLL_MAX # POLL_MAX = 60 while True: ... if time.time() >= deadline: return jsonify({"status": "timeout", "msg": "等待超时,请刷新重试"}) time.sleep(POLL_STEP) # POLL_STEP = 2两个参数缺一不可:间隔(2 秒)太小压垮服务端,太大用户觉得卡;总上限(60 秒)必须有 —— 没有上限,用户离开页面后连接会一直挂着,直到中间层(proxy_read_timeout)替你杀掉它,用户看到的是「转圈转到天荒地老」,不是明确的「二维码已过期」。
实测无人扫码时的行为:
--- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { "msg": "等待超时,请刷新重试", "status": "timeout" }关于退避:压力大时可以指数退避(2s → 4s → 8s),但退避上限要和总超时对齐—— 总超时 60 秒而间隔退避到 32 秒,体感会明显变差。我建议固定 2 秒,60 秒 × 30 次 = 30 个请求,扛得住。
五、一次领取:三层保护缺一不可
「一次领取」(one-time use)听上去是一件事,其实是三件独立的事,任何一层单独做都不够。
第一层:ticket 一次性
ticket 的「一次性」体现在状态机只允许往前推一次:pending → confirmed → consumed。我用 Lua 脚本,因为「读状态、判断、写入」这三步必须原子完成:
# ---- 原子脚本 1:pending -> confirmed,重复确认直接被挡 ---------- CONFIRM_LUA = """ local st = redis.call('HGET', KEYS[1], 'status') if not st then return -1 end if st ~= 'pending' then return 0 end redis.call('HSET', KEYS[1], 'status', 'confirmed', 'code', ARGV[1], 'uid', ARGV[3]) redis.call('EXPIRE', KEYS[1], ARGV[2]) redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[5]) return 1 """ # ---- 原子脚本 2:confirmed -> consumed,confirmed 只被读一次 ------ CLAIM_LUA = """ local st = redis.call('HGET', KEYS[1], 'status') if not st then return nil end if st ~= 'confirmed' then return nil end redis.call('HSET', KEYS[1], 'status', 'consumed') redis.call('EXPIRE', KEYS[1], 30) return redis.call('HGET', KEYS[1], 'code') """CLAIM_LUA值得单独说:PC 轮询拿到 code 的那一刻,ticket 状态就从confirmed变成consumed。这不是优化,是安全属性 —— 否则被复制走的 ticket 还能被第二个 PC 再领一次。
第二层:code 一次性短效
code 的属性是:60 秒内有效,用过即删。错误写法:
raw = R.get(key) # ① 先读 if raw: ... R.delete(key) # ② 再删
这中间有一个竞态窗口。我写了个 20 线程对照实验:
# -*- coding: utf-8 -*- """兑换 code 的并发竞态:先 GET 再 DEL vs GETDEL""" import threading, time, uuid, redis R = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) N = 20 def race_get_then_del(): """错误写法:GET 和 DEL 是两条命令,中间有竞态窗口""" key = "demo:code:" + uuid.uuid4().hex R.set(key, "PAYLOAD", ex=60) ok = [] lock = threading.Lock() barrier = threading.Barrier(N) def worker(): barrier.wait() v = R.get(key) # ① 读到 time.sleep(0.02) # ② 网络/序列化耗时,窗口就在这 if v: R.delete(key) # ③ 再删 with lock: ok.append(v) ts = [threading.Thread(target=worker) for _ in range(N)] [t.start() for t in ts]; [t.join() for t in ts] return len(ok)
实测输出:
并发线程数 20,同一个 code 只应被兑换 1 次 GET + DEL 分几次成功兑换: 6 GETDEL 分几次成功兑换: 1
一个 code 被成功兑换了 6 次。这 6 个线程都读到了 code,都以为自己拿到了授权。
Redis 6.2 之后的GETDEL一条命令完成「读 + 删」:
def race_getdel(): """正确写法:GETDEL 是一条原子命令""" key = "demo:code:" + uuid.uuid4().hex R.set(key, "PAYLOAD", ex=60) ok = [] lock = threading.Lock() barrier = threading.Barrier(N) def worker(): barrier.wait() v = R.getdel(key) # 原子:读+删 if v: with lock: ok.append(v) ts = [threading.Thread(target=worker) for _ in range(N)] [t.start() for t in ts]; [t.join() for t in ts] return len(ok)
除了GETDEL,还有两种等价方案:
| 方案 | 写法 | 适用 |
|---|---|---|
GETDEL | R.getdel(key) | Redis 6.2+,首选,最简单 |
SET NX占位 | if R.set(lock_key, "1", nx=True, ex=60): ... | 需要「先占坑再干活」的场景 |
| Lua 脚本 | 读 + 校验 + 删 写在一个脚本里 | 校验逻辑复杂时(本文CLAIM_LUA就是这种) |
注意GETDEL的副作用:删完才校验 PC 会话绑定,所以校验失败返回 403 时 code 也已经没了 —— 这是对的行为。一次失败尝试就该消耗凭证,否则攻击者能拿别人的 code 无限试。
第三层:code 与 PC 端会话绑定
前两层保证「同一个 code 只能用一次」,但没保证「只能被正确的那个 PC用一次」。code 一旦出现在 URL 里或被截获,攻击者就能拿它换登录态。第三层是:code 里存了生成它的那个 PC 会话 id 的哈希,兑换时校验。
raw = R.getdel(f"qr:code:{code}") if not raw: return jsonify({"ok": False, "error": "授权码无效或已被使用"}), 401 payload = json.loads(raw) if payload.get("pc") != sha256(pc_sid): # ← 绑定校验 return jsonify({"ok": False, "error": "授权码不属于当前设备"}), 403实测:把 code 拿到另一台 PC 上兑换。
--- B3. 把 code 拿到另一台 PC 上兑换(应 403)--- HTTP 403 { "error": "授权码不属于当前设备", "ok": false }六、状态机:pending 到 confirmed 到 consumed
合起来,ticket 就是一个四状态机。所有流转必须是原子的,否则状态会走岔。
PC 生成二维码 手机点确认 PC 领走 code ○ ───────────────→ pending ───────────→ confirmed ───────────→ consumed │ │ │ 超时(TTL 到期) │ └──────────────→ expired ←────────────┘
pending允许手机查和确认,confirmed只允许 PC 领一次 code,consumed和expired什么都不接受。价值在于每个非法转移都有明确的拒绝理由:
--- B1. 同一个二维码重复确认(应 409)--- HTTP 409 { "error": "该二维码已被确认过", "ok": false } --- B2. 查一个不存在的 ticket(应 404)--- HTTP 404 { "error": "二维码已过期或不存在", "ok": false }为什么一定要用 Lua 而不是「先 HGET 再 HSET」?因为「读 status、判断是不是 pending、写成 confirmed」分成三条命令时,两个并发的确认请求可能都读到pending然后都写confirmed,于是生成两个 code、覆盖掉其中一个。CONFIRM_LUA把判断和写入塞进一个脚本,在 Redis 里原子执行。返回值三态:-1ticket 不存在(404),0状态不是 pending(409),1成功。
七、二维码登录劫持:原理与三道防线
先说清楚它和 CSRF 有什么不一样。
CSRF:网站替用户发请求,用户没做任何操作,浏览器自动带着 Cookie 发了个 POST。
二维码登录劫持:用户主动点了确认,但确认的不是自己那台设备。
区别在于 —— 授权动作真实发生了,是受害者亲手点的确认按钮,服务端看到的是一个完全合法的请求:ticket 合法、code 合法、请求来自持有账号的手机。服务端没有任何技术手段能识别这次确认「不该发生」,唯一防线是让用户在确认前看出不对劲。
攻击链条
我在自己的靶场上完整复现了一遍(DEMO_INSECURE=1,只打127.0.0.1):
第 1、2 步:攻击者生成属于他自己的二维码,再送到受害者面前。他拿到一个绑定他自己 PC 会话的ticket,贴在共享充电宝上、印在传单上、或者把真实登录页上的码换掉。
第 3 步:受害者扫码,看到「确认登录」,点了确认。
--- 步骤2. 受害者手机扫码,服务端回给他要展示的信息 --- HTTP 200 { "display_account": "", "need_confirm": true, "ok": true, "pc_device": "Ubuntu 24.04 / Firefox 133", "pc_location": "浙江 杭州", "status": "pending" } --- 步骤3. 受害者点了「确认登录」 --- HTTP 200 { "ok": true, "ticket": "3ba9ef4f3509419887a6c231043e3179" }第 4 步:攻击者的 PC 轮询到 code 并兑换。
--- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { "code": "433ab7afd64e40f2b981968591e8e93a", "status": "confirmed" } --- 步骤5. 攻击者兑换 code,拿到了受害者账号的登录态 --- HTTP 200 { "ok": true, "token": "17f490a397d84570b60aef7af3ab83dc", "uid": "u_10086" } --- 步骤6. 攻击者以受害者身份访问会员接口 --- HTTP 200 { "logged_in": true, "uid": "u_10086" }看清楚第 5 步:uid是u_10086,受害者的账号。攻击者拿到的是受害者账号的会话—— 因为点确认的是受害者本人。这也解释了为什么第三层「code 绑定 PC 会话」挡不住它:code 里的pc哈希就是攻击者那台 PC 的,他用自己的 PC 兑换,校验天然通过。
三道防线
防线一:让用户在确认前能核对信息。这是唯一真正有效的防线,核心是给用户足够的信息去发现异常:PC 端扫码前就显示「你正在用哪个账号登录」(脱敏);移动端在确认按钮上方必须显示当前设备和地点。用户扫的是别人的码,页面上却显示着自己的账号 —— 这就是他能察觉的破绽。靶场里这三个字段的唯一作用就是被人看到。
防线二:Origin 校验与 CSRF 防护。确认接口必须拒绝跨站请求:
if not INSECURE and not origin_ok(): return jsonify({"ok": False, "error": "跨站请求被拒绝"}), 403实测:
--- B4. 跨站 Origin 提交确认(应 403)--- HTTP 403 { "error": "跨站请求被拒绝", "ok": false }这能挡住「攻击者做个网页,用受害者浏览器自动提交确认」这种半自动手法,但挡不住「受害者亲手扫码」—— 那种情况下 Origin 是干净的。所以这层是补充,不是主防线。
防线三:二维码内容用短时效随机串。二维码里绝不能放 uid、手机号、或任何「长期有效 + 可猜」的东西,必须是服务端签发、几十秒过期、只能用一次的随机串。我用uuid.uuid4().hex(128 位随机),TTL 180 秒 ——攻击者就算截获了,也只能在这个很短窗口内用掉。
再加账号一致性校验兜底 —— PC 端必须先声明要登哪个账号,手机端确认时账号必须完全一致:
if not data.get("account"): return jsonify({"ok": False, "error": "PC 端未声明登录账号,拒绝确认"}), 400 if data["account"] != sess["phone"]: return jsonify({"ok": False, "error": "PC 端请求登录的账号与当前账号不一致", ...}), 409把加固版服务跑一遍同样的攻击链条,链条在第 3 步就断了:
--- 步骤3. 受害者点了「确认登录」 --- HTTP 400 { "error": "PC 端未声明登录账号,拒绝确认", "ok": false } --- 步骤4. 攻击者的 PC 轮询到了 code --- HTTP 200 { "msg": "等待超时,请刷新重试", "status": "timeout" } --- 步骤6. 攻击者以受害者身份访问会员接口 --- HTTP 200 { "logged_in": false, "uid": null }最后还有高风险操作的二次验证(新设备、新地点、涉及资金的操作再要一次短信验证码),以及一个容易被忽略的点 ——确认页不能只写「确认登录」四个字,要给出足够上下文让用户判断。
八、它和 CSRF、和第三方扫码的区别
这三个概念经常被混着讲,边界其实很清楚:
| 维度 | 扫码登录(本系统内) | 第三方 App 扫码登录 | CSRF |
|---|---|---|---|
| 认证委托给谁 | 本系统自己 | 委托给已有账号体系(对方说「这个人是 u_10086」) | 不涉及认证 |
| 手机端在做什么 | 用本系统的登录态确认 | 用第三方的登录态换本系统凭证 | 浏览器自动带 Cookie 发请求 |
| 授权动作 | 用户点确认 | 用户点确认 | 用户没做任何动作 |
| 主要风险 | 二维码被替换(劫持) | 授权码被截获、state 校验缺失 | 站点替用户发请求 |
| 关键防线 | 核对信息 + 短随机串 | state+ 一次性 code | SameSite + CSRF Token |
第三方扫码登录多出来一个关键环节state。它是随机串,同时放进「授权请求 URL」和「回调 URL」,回调时校验两者是否一致 —— 防止攻击者把别人的授权码和自己的state拼在一起。本质上是防 CSRF 的手法。本系统的扫码登录没有第三方,所以必须自己实现等价的东西—— 我这里对应角色的是ticket+ PC 会话绑定 + Origin 校验。
还有个概念必须分清:二维码 ≠ 二维码携带的内容安全。二维码只是编码方式,里面 URL 可以是任意内容,安全性全来自服务端对那个串的管控。
九、一份能直接抄的排查清单
扫码登录出问题时,按这个顺序查:
| 现象 | 先查什么 |
|---|---|
| 二维码扫出来是空白/乱码 | 前端渲染问题。查qr_url拼接和qrcode库版本 |
| 扫码提示「二维码已过期」 | ticket 的 TTL 是不是太短(我设 180 秒);手机和服务器有没有时钟偏差 |
| 「我明明扫了」但 PC 一直转圈 | 看/api/qr/poll有没有进来。PC 和手机是不是同一个 Redis(REDIS_URL) |
| 「该二维码已被确认过」(409) | 有重复确认请求。查前端有没有重复提交 |
| 「授权码无效或已被使用」(401) | code 过期(60 秒 TTL)或已被兑换。查qr:code:*的 TTL |
| 「授权码不属于当前设备」(403) | PC 会话(pc_sidCookie)丢了或换了。常见于多标签页、跨域 |
| 三个最常见的坑,最后再强调一次: |
坑一:二维码过期时间太短。我见过设成 30 秒的,用户掏手机、找摄像头、对焦、切换 APP —— 40 秒就没了。180 秒到 3 分钟更合理。
坑二:轮询没有超时上限。用户关掉页面后连接还挂着,必须有POLL_MAX。
坑三:code 被日志打印出来。这条最隐蔽:
2026-10-30 22:50:15,302 INFO 用户确认登录,code=d408ef2c83c443f5956f6fa39b2c538a, ticket=e61e8aea1e4a4fd8acbf4aba0734211b
日志系统保存周期更长、权限更宽松,还有第三方采集,这行日志等于把一次性凭证抄送给了所有能看日志的人。正确做法是只记前 8 位:
logging.info("用户确认登录,code=%s, ticket=%s", code[:8] + "****", ticket[:8] + "****") # 用户确认登录,code=d408ef2c****, ticket=e61e8aea****排查时的两个提效技巧:一是直接看 Redis 里的状态:
redis-cli HGETALL qr:ticket:<ticket> redis-cli TTL qr:ticket:<ticket> redis-cli KEYS 'qr:code:*'
二是用 curl 绕过前端验证。长轮询在前端有各种封装,curl 能让你看到服务端到底返回了什么:
curl -i -X POST "http://127.0.0.1:8020/api/qr/create" \ -H "Content-Type: application/json" \ -H "Origin: http://127.0.0.1:8020" \ -d '{"account":"13800138000","device":"Windows 11"}'