←返回博客

在 VR 头显中像电视一样登录

2026年9月26日10 min read

Bannermarch 本周获得了 VR 支持。你可以在 Meta Quest 浏览器中打开游戏,按下按钮,然后你就置身于你的仓库中。不过,在那之前,你必须先登录。在 Quest 上,这意味着将激光对准一个漂浮的键盘,一个字母一个字母地敲出你的电子邮件,然后是你的密码,而密码可能保存在你手机上的密码管理器中。所以你必须摘下头显去查看。(关于VR 本身如何在浏览器中运行,我另写了一篇文章。)

电视多年前就解决了这个问题。电视上的 YouTube 应用会显示一个代码,你在手机上访问一个 URL,输入代码,电视就会自动登录。Netflix 也是这样,PlayStation 也是。甚至还有一个 RFC(RFC 8628,OAuth 设备授权许可)。我为游戏构建了同样的功能。头显显示一个代码,你在手机或笔记本电脑上的 /pair 页面上进行批准,而你已经在这些设备上登录了。

我将详细介绍它是如何工作的,因为它是一个很棒的小型系统设计案例,而且大多数人都在使用它而没有去思考它背后的原理。

谁与谁交谈

头显和你的手机永远不会直接通信。我没有使用蓝牙或本地网络做任何花哨的操作。它们都与游戏的服务器通信,服务器会保存一个待处理的代码十分钟。你就是连接这两个屏幕的唯一纽带。你从一个屏幕上读出八个字符,然后输入到另一个屏幕上。

头显                     服务器                      手机
| | |
|-- 开始 ----------------->| |
|<-- 代码 K7MP2QXR ---------| |
| + 秘密保存在 cookie 中 | |
| | |
|-- 轮询 (仍在等待) ------->|<-- 打开 /pair,输入代码 --<|
| |--- "Meta Quest, Miami" -->|
| |<-- 批准 ---------------|
|-- 轮询 + 秘密 --------->| |
|<-- 已登录 -------------| |

这是事件发生的顺序。

头显向服务器请求开始配对。服务器为你生成一个简短的代码,例如 K7MP2QXR。它还会生成一个 32 字节的长随机密钥,并通过 cookie 返回给头显。服务器存储该密钥的哈希值,而不是密钥本身。

头显将代码显示在屏幕上,并开始每隔 2.5 秒向服务器询问是否有人批准了它。屏幕上还有一个二维码,指向 /pair#K7MP2QXR。这在电视或笔记本电脑上效果很好。在头显上,这几乎没用,但我稍后会讲到。

你在手机上打开 /pair 并输入代码。服务器查找该代码,并向你显示正在请求的设备及其大致位置。它从浏览器的 User-Agent 中猜测出 "Meta Quest",并从 IP 地址获取城市和国家。你点击批准。服务器将你的账户 ID 附加到待处理的代码旁边。

头显的下一次轮询会发送 cookie。服务器对密钥进行哈希处理,进行比较,看到批准信息,删除待处理条目,并授予头显自己的会话。你就成功登录游戏了。

为什么被盗的代码是无用的

代码会显示在屏幕上,所以我假设其他人可以读取它。沙发上的你兄弟可以。任何观看直播的人也可以。

这就是密钥的原因。当你批准时,你的账户不会交给知道代码的人。它会交给持有密钥的那个浏览器,而那个浏览器就是头显。Cookie 是 HttpOnly 的,所以页面上的 JavaScript 无法读取它,并且 SameSite=Strict,所以其他网站无法让浏览器发送它。如果有人从你的电视上抄走了代码,他们也无法用它收集任何东西。

会话只能被收集一次。头显获取批准后,服务器会立即删除待处理条目,任何后续的轮询都会收到 "expired" 的响应。如果在十分钟内没有人批准代码,它也会被删除。

我没有过多担心猜测的问题。代码使用 31 个字符(我删除了 0、O、1、I 和 L,因为人们容易混淆),八个字符大约有 8.5 亿种组合。批准操作还受到速率限制,并且每个代码只存在十分钟。

网络钓鱼

如果你批准了错误的代码,密钥就无济于事了。假设有人在自己的笔记本电脑上启动配对,然后给你发送消息说“输入此代码以确认你的账户”。你批准了,然后他们的笔记本电脑就获得了你的会话。这被称为设备代码网络钓鱼。RFC 警告过这种情况,攻击者曾用它来攻击 Microsoft 365 账户。

我无法完全阻止这种情况,但我可以让它变得显而易见。批准屏幕会显示连接设备的名称和位置,并警告你只批准你能在你手中设备上看到的代码。如果你住在迈阿密,而屏幕上显示的是你在从未去过的某个国家的“Windows PC”,你很可能会注意到。

头显不是电视

在我发布第一个版本大约二十分钟后,我又发布了第二个版本。对于电视,你可以同时查看代码和手机。戴着头显时,你根本看不到手机,所以你不得不摘下头显读取代码,输入代码,然后重新戴上。二维码也无法使用,因为你的手机摄像头无法穿透镜片。

所以现在它也可以反向进行。在 /pair 页面上有一个“为你的头显获取代码”按钮。你的手机向服务器请求一个与你账户关联的代码,你读取它,戴上头显,然后在登录屏幕上输入。用激光键盘输入八个字符仍然很麻烦。但比输入电子邮件和密码要好得多。

这个版本比第一个版本安全性稍低。代码后面没有密钥,所以谁先输入代码谁就能登录。我还是保留了它,并对其进行了限制。代码只发放给已登录且经过验证的电子邮件账户。它只使用一次,有效期为十分钟,并且输入代码受到速率限制。

我还将两种代码保存在不同的表中。如果“输入代码”框接受了头显的代码,那么任何在电视上看到过该代码的人都可以将其输入到其他地方并获取你的会话。使用单独的表,头显的代码无论是否被批准,在那里都会失败。

它是如何构建的

这就是想法。如果你想自己构建一个,这里是代码中实际包含的内容。服务器是一个用 TypeScript 编写的 Cloudflare Worker。只有存储部分是 Cloudflare 特有的,我将说明如何替换它。

六个端点

端点调用方功能
POST /api/auth/device/start头显生成代码和密钥,设置 cookie
POST /api/auth/device/poll头显响应待处理、过期或登录头显
POST /api/auth/device/lookup已登录手机显示代码背后的设备和位置
POST /api/auth/device/approve已登录手机将你的账户关联到代码
POST /api/auth/device/issue已登录手机为反向流程生成代码
POST /api/auth/device/redeem头显用输入的代码兑换会话

每一个都是 POST 请求,并且每个响应都有 Cache-Control: no-store,这样任何包含代码或会话的信息都不会在途中被缓存。Lookup 和 approve 是特意分开的。手机先调用 lookup 来显示设备名称和位置,只有在你阅读并点击按钮后才会调用 approve。任何已登录手机调用的请求都会检查已验证的电子邮件,并通过每个账户的速率限制。

生成代码

代码来自 crypto.getRandomValues,绝不是 Math.random。有一个小陷阱。随机字节的范围是 0 到 255,如果你只取 byte % 31,那么字母表的前几个字母出现的频率会比其他字母略高,因为 256 不能被 31 整除。解决方法是丢弃任何大于或等于 248 的字节,然后重新获取一个。这被称为拒绝采样,只需要三行额外的代码。

const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 个符号,无 0 O 1 I L

function newPairingCode(): string {
  // 256 不是 31 的倍数,所以丢弃高位字节以保持每个符号的概率相等
  const limit = 256 - (256 % alphabet.length); // 248
  let code = "";
  while (code.length < 8) {
    for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
      if (byte < limit && code.length < 8) code += alphabet[byte % alphabet.length];
    }
  }
  return code;
}

// 用户输入 "k7mp-2qxr" 或 "K7MP 2QXR"。两者都接受。
function normalizeCode(input: string): string | null {
  const code = input.toUpperCase().replace(/[\s-]/g, "");
  return code.length === 8 && [...code].every((c) => alphabet.includes(c)) ? code : null;
}

服务器还会容忍用户的输入方式。小写字母、空格和连字符都会在检查代码之前被清理掉,这样没有人会因为输入方式不同而收到错误。

密钥存储在 cookie 中

这是 start 处理程序,已略作删减。

async function startPairing(request: Request, env: Env): Promise<Response> {
  const secret = base64Url(crypto.getRandomValues(new Uint8Array(32)));
  const secretHash = await sha256Hex(secret);

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const code = newPairingCode();
    const expiresAt = await env.DEVICE_PAIRING
      .getByName(`device-pairing:${code}`)
      .begin(secretHash, deviceLabel(request), devicePlace(request));
    if (expiresAt === null) continue; // 代码已被占用,重新生成一个

    const headers = new Headers({ "Cache-Control": "no-store" });
    headers.append("Set-Cookie",
      `__Host-bm_device_pairing=${code}.${secret}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=600`);
    return Response.json({ code, expiresAt }, { headers });
  }
  return Response.json({ error: "Try again." }, { status: 503 });
}

这里有几点需要注意。服务器只存储 secretHash。原始密钥只发送一次,在 cookie 中,所以即使有人读取数据库也无法冒充头显。Cookie 同时携带代码和密钥(K7MP2QXR.abc...),这意味着轮询请求根本不需要请求体。浏览器发送 cookie,服务器就知道要检查哪个代码,并能证明它是正确的头显。

__Host- 前缀告诉浏览器此 cookie 仅在 HTTPS 上此确切域上有效,任何子域都无法设置或覆盖它。还有重试循环。两个头显可能会随机获得相同的代码,当这种情况发生时,begin 返回 null,处理程序会重新生成一个。有 8.5 亿种可能的代码,这种情况几乎不会发生,但必须处理。

一行,几个状态

每个待处理的代码都占用一行。

CREATE TABLE pairing (
  id           INTEGER PRIMARY KEY CHECK (id = 1),  -- 每个对象只有一行,永远如此
  secret_hash  TEXT NOT NULL,                       -- 头显密钥的 sha256 哈希值
  device_label TEXT NOT NULL,                       -- "Meta Quest"
  place        TEXT,                                -- "Miami, US"
  expires_at   INTEGER NOT NULL,
  account_key  TEXT                                 -- 直到有人批准前为 NULL
);

配对的状态直接来自该行。如果 account_key 为空,则表示待处理。一旦有人批准,它就会被填充。当头显获取批准时,该行将被删除。如果 expires_at 已过,无论该行其他内容如何,代码都将被视为过期。没有需要同步的状态列,这减少了一种出错的可能性。

这是头显轮询最终调用的内容。

claim(secretHash: string) {
  const row = this.pairing(); // 如果缺失或已过期则为 null
  if (!row || row.secret_hash !== secretHash) return { status: "expired" };
  if (!row.account_key) return { status: "pending" };

  this.ctx.storage.sql.exec("DELETE FROM pairing"); // 只收集一次
  return { status: "approved", accountKey: row.account_key };
}

看看第一个检查。错误的密钥会得到与缺失代码相同的“expired”响应。服务器绝不会告诉陌生人“该代码存在,但你不是正确的设备”。在成功收集后,会在会话发出之前删除该行,因此同一个批准不能被使用两次。

头显端

客户端是一个小循环。

const poll = () => {
  timer = setTimeout(async () => {
    try {
      const result = await api.pollDevicePairing();
      if (result.authenticated) onSignedIn(result);
      else if (result.status === "expired") showExpired();
      else poll(); // 仍在待处理,2.5 秒后再次询问
    } catch {
      poll(); // 不稳定的头显 Wi-Fi 不应导致配对失败
    }
  }, 2_500);
};

它使用 setTimeout 来重新安排自身,而不是 setInterval。如果一个请求很慢,下一个请求会等待它,而不是堆积在它后面。错误会使循环继续,因为头显的 Wi-Fi 会偶尔中断一秒钟,这不应该导致你丢失代码。当轮询最终成功时,响应会携带正常的会话 cookie,与密码登录一样,然后游戏就会加载。

代码存储在哪里

游戏运行在 Cloudflare Workers 上。每个代码都有自己的 Durable Object,以代码命名,其中包含一个只有一行的微型 SQLite 表。

我选择这种方式是因为“一次性收集”规则。Durable Object 一次处理一个请求,所以两个针对同一代码的轮询不能同时获取批准。每个对象还会设置一个在代码过期后一分钟触发的警报,警报会删除其存储。我不需要一个清理作业。

如果你不在 Cloudflare 上,Redis 可以完成同样的工作。将每个代码存储为一个键,并设置十分钟的 TTL(SET ... EX 600 NX,其中 NX 会免费为你提供“代码已被占用”的检查)。对于收集步骤,在单个 Lua 脚本中检查密钥、检查批准并删除键,这样所有操作都是原子的。一个普通的 Postgres 表也可以,如果你将收集操作作为一个单独的 DELETE ... WHERE ... RETURNING 来执行,并定期运行一个清理查询。

二维码将代码放在 URL 的 # 后面。浏览器不会将这部分发送到服务器,因此代码永远不会出现在访问日志中。头显会进行轮询而不是保持 WebSocket 连接。最坏的情况下,这会在十分钟内发送 240 个小请求,这不算什么,而且我不想在 Quest 浏览器中调试 WebSockets。

保持更新

将最新文章和见解发送到您的收件箱。

不发送垃圾邮件,可随时退订。