Вход в VR-гарнитуру как на телевизоре
На этой неделе Bannermarch получила поддержку VR. Вы открываете игру в браузере Meta Quest, нажимаете кнопку, и оказываетесь внутри своей базы. Но прежде чем это произойдет, вам нужно войти в систему. На Quest это означает наведение лазера на парящую клавиатуру и посимвольное введение вашего email, а затем пароля, который, вероятно, хранится в менеджере паролей на вашем телефоне. Поэтому вы снимаете гарнитуру, чтобы посмотреть. (О том, как сама VR работает в браузере, я написал в отдельной статье.)
Телевизоры решили эту проблему много лет назад. Приложение YouTube на телевизоре показывает вам код, вы заходите по URL на своем телефоне, вводите его, и телевизор входит в систему. Netflix делает это, PlayStation делает это. Даже существует RFC для этого (RFC 8628, OAuth device authorization grant). Я реализовал то же самое для игры. Гарнитура показывает код, а вы подтверждаете его по адресу /pair на телефоне или ноутбуке, где вы уже вошли в систему.
Я расскажу, как это работает, потому что это хороший небольшой пример системного дизайна, и большинство людей использовали его, не задумываясь о том, что стоит за этим.
Кто с кем говорит
Гарнитура и ваш телефон никогда не общаются друг с другом. Я не использовал никаких хитрых технологий вроде Bluetooth или локальной сети. Оба устройства общаются с сервером игры, и сервер хранит ожидающий код в течение десяти минут. Вы — единственное связующее звено между двумя экранами. Вы считываете восемь символов с одного и вводите их в другой.
Гарнитура Сервер Телефон
| | |
|-- start ----------------->| |
|<-- code K7MP2QXR ---------| |
| + secret в cookie | |
| | |
|-- poll (still waiting) -->|<-- open /pair, type code -|
| |--- "Meta Quest, Miami" -->|
| |<-- approve ---------------|
|-- poll + secret --------->| |
|<-- signed in -------------| |
Вот порядок действий.
Гарнитура запрашивает у сервера начало сопряжения. Сервер генерирует короткий код для вас, например K7MP2QXR. Он также генерирует длинный случайный секрет, 32 байта, и отправляет его обратно гарнитуре в cookie. Сервер хранит хеш этого секрета, а не сам секрет.
Гарнитура выводит код на экран и начинает каждые 2,5 секунды запрашивать у сервера, было ли что-то одобрено. На экране также есть QR-код, который ведет на /pair#K7MP2QXR. Это отлично работает на телевизоре или ноутбуке. На гарнитуре это довольно бесполезно, но об этом позже.
Вы открываете /pair на своем телефоне и вводите код. Сервер ищет его и показывает вам, какое устройство запрашивает доступ и где оно примерно находится. Он определяет "Meta Quest" из User-Agent браузера и получает город и страну по IP-адресу. Вы нажимаете "одобрить". Сервер записывает ваш идентификатор учетной записи рядом с ожидающим кодом.
Следующий запрос гарнитуры отправляет cookie. Сервер хеширует секрет, сравнивает его, видит одобрение, удаляет запись о ожидании и выдает гарнитуре свою собственную сессию. Вы в игре.
Почему украденный код бесполезен
Код отображается на экране, поэтому я предполагаю, что другие люди могут его прочитать. Ваш брат на диване может. Любой, кто смотрит стрим, тоже может.
Вот почему нужен секрет. Когда вы одобряете, ваша учетная запись не передается тому, кто знает код. Она передается браузеру, который хранит секрет, а этот браузер — гарнитура. Cookie имеет флаг HttpOnly, поэтому JavaScript на странице не может его прочитать, и SameSite=Strict, поэтому другой сайт не может заставить браузер отправить его. Если кто-то скопирует код с вашего телевизора, он ничего с ним не сможет сделать.
Сессия может быть получена один раз. Сервер удаляет запись об ожидании в тот момент, когда гарнитура ее получает, и любой последующий запрос получает ответ "истекло". Если никто не одобрит код в течение десяти минут, он все равно исчезнет.
Я не особо беспокоился о подборе. Коды используют 31 символ (я исключил 0, O, 1, I и L, потому что люди их путают), и восемь из них дают около 850 миллиардов комбинаций. Одобрение дополнительно ограничивается по частоте запросов, и каждый код действует всего десять минут.
Фишинг
Секрет не поможет, если вы одобрите не тот код. Скажем, кто-то начнет сопряжение на своем ноутбуке и напишет вам: "введите этот код, чтобы подтвердить свою учетную запись". Вы его одобрите, и теперь их ноутбук получит вашу сессию. Это называется фишингом через устройство. RFC предупреждает об этом, и злоумышленники использовали это против учетных записей Microsoft 365.
Я не могу полностью это предотвратить, но могу сделать это очевидным. Экран одобрения показывает название устройства и место, откуда оно подключается, с предупреждением одобрять только код, который вы видите на устройстве, которое держите в руках. Если вы живете в Майами, а там написано "Windows PC" в какой-то стране, где вы никогда не были, вы, вероятно, заметите.
Гарнитуры — это не телевизоры
Примерно через двадцать минут после того, как я выпустил первую версию, я выпустил вторую. С телевизором вы можете одновременно смотреть на код и на телефон. В гарнитуре вы вообще не видите свой телефон, поэтому вам приходится снимать гарнитуру, чтобы прочитать код, вводить его, а затем снова надевать. QR-код тоже не работает, потому что камера вашего телефона не видит сквозь линзы.
Так что теперь это работает и в обратную сторону. На /pair есть кнопка "Получить код для вашей гарнитуры". Ваш телефон запрашивает у сервера код, привязанный к вашей учетной записи, вы его читаете, надеваете гарнитуру и вводите его на экране входа. Восемь символов на лазерной клавиатуре все еще раздражают. Но это гораздо менее раздражает, чем email и пароль.
Эта версия менее безопасна, чем первая. За кодом нет секрета, поэтому кто введет его первым, тот и получит доступ. Я все равно ее оставил и ограничил. Код выдается только вошедшей учетной записи с подтвержденным email. Он действует один раз и действителен десять минут, а ввод кодов ограничен по частоте.
Я также храню два типа кодов в разных таблицах. Если поле "введите код" принимало код гарнитуры, то сразу после того, как вы одобрили один, любой, кто видел его на вашем телевизоре, мог ввести его где-нибудь еще и получить вашу сессию. С отдельными таблицами код гарнитуры просто не пройдет там, одобрен он или нет.
Как это построено
Такова идея. Если вы хотите создать свою собственную, вот что на самом деле находится в коде. Сервер — это Cloudflare Worker на TypeScript. Только часть хранения специфична для 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 после того, как вы его прочитали и нажали кнопку. Все, что вызывает вошедший телефон, также проверяет наличие подтвержденного email и проходит через ограничение частоты запросов на учетную запись.
Создание кода
Код генерируется с помощью 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
Вот обработчик старта, немного сокращенный.
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...), что означает, что запрос poll вообще не нуждается в теле. Браузер отправляет cookie, и сервер знает, какой код проверить, и может доказать, что это правильная гарнитура.
Префикс __Host- сообщает браузеру, что этот cookie работает только через HTTPS на этом точном домене, и ни один поддомен не может его установить или перезаписать. И здесь есть цикл повторных попыток. Две гарнитуры могут случайно получить один и тот же код, и когда это происходит, begin возвращает null, а обработчик генерирует новый. При 850 миллиардах возможных кодов это практически никогда не случается, но это должно быть обработано.
Одна строка, несколько состояний
Каждый ожидающий код — это одна строка.
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 прошло, код считается истекшим, независимо от остального содержимого строки. Нет столбца состояния, который нужно синхронизировать, что уменьшает вероятность ошибок.
Вот что в конечном итоге вызывает poll гарнитуры.
claim(secretHash: string) {
const row = this.pairing(); // null, если отсутствует или прошло expires_at
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 };
}
Посмотрите на первую проверку. Неправильный секрет получает тот же ответ "истекло", что и отсутствующий код. Сервер никогда не сообщает незнакомцу: "этот код существует, но вы не то устройство". При успешном получении строка удаляется до отправки сессии, поэтому одно и то же одобрение не может быть использовано дважды.
Сторона гарнитуры
Клиент представляет собой небольшой цикл.
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 гарнитуры иногда пропадает на секунду, и это не должно стоить вам кода. Когда poll наконец завершается успешно, ответ содержит обычный cookie сессии, как и при входе по паролю, и игра загружается.
Где хранятся коды
Игра работает на Cloudflare Workers. Каждый код имеет свой собственный Durable Object, названный в честь кода, содержащий крошечную таблицу SQLite с одной строкой.
Я выбрал это из-за правила "получить один раз". Durable Object обрабатывает свои запросы по одному, поэтому два запроса poll для одного и того же кода не могут одновременно получить одобрение. Каждый из них также устанавливает будильник на минуту после истечения срока действия кода, и будильник удаляет свое хранилище. Мне не нужна задача очистки.
Если вы не используете Cloudflare, Redis выполнит ту же задачу. Храните каждый код как ключ с десятиминутным TTL (SET ... EX 600 NX, где NX бесплатно предоставляет проверку "код уже занят"). Для шага получения проверьте секрет, проверьте одобрение и удалите ключ внутри одного Lua-скрипта, чтобы все это происходило атомарно. Обычная таблица Postgres тоже подойдет, если вы выполняете получение как единый DELETE ... WHERE ... RETURNING и периодически запускаете запрос на очистку.
QR-код помещает код после # в URL. Браузеры не отправляют эту часть на сервер, поэтому коды никогда не появляются в логах доступа. А гарнитура опрашивает вместо поддержания открытого WebSocket. В худшем случае это 240 небольших запросов за десять минут, что ничтожно мало, и я не хотел отлаживать WebSockets в браузере Quest.