news 2026/11/5 5:30:53

从一家奶茶店讲透扫码登录:手机怎么让电脑登录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从一家奶茶店讲透扫码登录:手机怎么让电脑登录

目录

  • 一、扫码登录要解决的真问题

  • 二、完整流程:从生成二维码到换取登录态

  • 三、动手写一个能跑的扫码登录后端

  • 四、为什么是长轮询,而不是 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_ORIGINS

Redis 里两个 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 = 180

3.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,还有两种等价方案:

方案写法适用
GETDELR.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+ 一次性 codeSameSite + 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"}'
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 5:28:54

电池后段测试到底处于电池生产的哪个环节?一文看懂电池制造流程

在了解电池后段测试设备时&#xff0c;很多刚进入新能源行业的朋友都会产生一个疑问&#xff1a;电池在生产过程中&#xff0c;究竟什么时候需要进行测试&#xff1f;测试设备是生产线上的最后一道工序&#xff0c;还是从电芯制造开始就一直参与其中&#xff1f;要理解这个问题…

作者头像 李华
网站建设 2026/11/5 5:28:39

2026继电器厂家选型指南:新能源行业选型标准

目录一、行业现状与选型背景二、新能源继电器的核心选型标准三、应用解析&#xff1a;电动汽车充电站四、应用解析&#xff1a;储能系统五、应用解析&#xff1a;太阳能发电与功率调节六、应用解析&#xff1a;数据中心与智能楼宇七、选型趋势展望八、总结与建议一、行业现状与…

作者头像 李华
网站建设 2026/11/5 5:28:39

计算机通信网络基础6:路由器与路由

ARP表、路由表 每台主机&#xff08;包括路由器&#xff09;上保存有ARP表&#xff0c;每行为IP-MAC&#xff0c;主机发送以太包时先确定接收主机是否在同一个广播域内(局域网上被路由器分成的更小网络)&#xff1b;若否则将目标MAC写为默认网关&#xff08;即路由器&#xff0…

作者头像 李华
网站建设 2026/11/5 5:24:25

9 个开源个性化工具,30 分钟重塑千篇一律的 Android 桌面

9 个开源个性化工具&#xff0c;30 分钟重塑千篇一律的 Android 桌面 【免费下载链接】open-source-android-apps Open-Source Android Apps 项目地址: https://gitcode.com/gh_mirrors/op/open-source-android-apps 还在盯着出厂的 Android 主屏幕发呆&#xff1f;以开…

作者头像 李华
网站建设 2026/11/5 5:24:19

如何用 OpenCore Legacy Patcher 免费升级旧 Mac:新手完整教程

如何用 OpenCore Legacy Patcher 免费升级旧 Mac&#xff1a;新手完整教程 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 苹果每隔几年就会缩短旧机型的系统…

作者头像 李华
网站建设 2026/11/5 5:23:30

您是正在寻找 Keras 入门单页介绍的机器学习工程师吗?请阅读我们的指南

Keras 入门关于Keras的基础入门知识, 这里提供相关的学习资料。如果您是当前正在找用于看一遍就大概知晓情况的, 关于Keras这个工具的入门性质的单页版式信息的机器学习工程师的话, 那么建议您应当去仔细阅读我们这边提供的这样一份指南, 也就是名为“面向工程师的 Keras 入门介…

作者头像 李华