В этом руководстве объясняется, как внедрить ключи доступа (passkeys) в ваше веб-приложение. Мы используем Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript и MySQL.
Vincent
Created: June 20, 2025
Updated: June 20, 2025
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
В этом руководстве мы поможем вам с внедрением ключей доступа, предложив пошаговую инструкцию по добавлению passkeys на ваш сайт.
Современная, надежная и удобная для пользователя аутентификация — это ключ к созданию отличного веб-сайта или приложения. Ключи доступа (passkeys) стали ответом на этот вызов. Являясь новым стандартом для входа в систему, они обещают будущее без недостатков традиционных паролей, обеспечивая по-настоящему беспарольный вход (что не только безопасно, но и очень удобно).
Recent Articles
👤
Как включить ключи доступа в Windows
📖
Ключи доступа в нативных приложениях: нативная реализация vs. реализация через WebView
👤
Решение проблем с ключами доступа: руководство по устранению ошибок
👤
Как включить ключи доступа в Windows
⚙️
Руководство по ключам доступа: как внедрить ключи доступа в веб-приложения
Что действительно говорит о потенциале ключей доступа, так это поддержка, которую они получили. Все основные браузеры, будь то Chrome, Firefox, Safari или Edge, и все важные производители устройств (Apple, Microsoft, Google) внедрили их поддержку. Это единодушное принятие показывает, что ключи доступа — это новый стандарт для входа в систему.
Да, уже существуют руководства по интеграции ключей доступа в веб-приложения. Будь то для фронтенд-фреймворков, таких как React, Vue.js или Next.js, существует множество руководств, призванных облегчить трудности и ускорить внедрение ключей доступа. Однако полноценного руководства, которое оставалось бы минималистичным и низкоуровневым, не хватает. Многие разработчики обращались к нам с просьбой о руководстве, которое внесло бы ясность во внедрение ключей доступа для веб-приложений.
Именно поэтому мы и создали это руководство. Наша цель? Создать минимально жизнеспособную конфигурацию для ключей доступа, охватывающую фронтенд, бэкенд и уровень базы данных (последний часто упускают из виду, хотя он может вызвать серьезные головные боли).
К концу этого пути вы создадите минимально жизнеспособное веб-приложение, в котором сможете:
Для тех, кто торопится или хочет иметь под рукой готовый пример, вся кодовая база доступна на GitHub.
Интересно, как выглядит конечный результат? Вот краткий обзор финального проекта (признаем, он выглядит очень просто, но самое интересное скрыто под капотом):
Мы полностью осознаем, что части кода и проекта можно реализовать иначе или более изощренно, но мы хотели сосредоточиться на самом необходимом. Поэтому мы намеренно сделали все простым и ориентированным на ключи доступа.
Как добавить ключи доступа на мой рабочий сайт?
Это очень минималистичный пример аутентификации с помощью ключей доступа. Следующие моменты НЕ рассматриваются / не реализованы в этом руководстве или реализованы очень базово:
Получение полной поддержки всех этих функций требует значительно больших усилий по разработке. Тем, кто заинтересован, мы рекомендуем ознакомиться с этой статьей о заблуждениях разработчиков относительно ключей доступа.
Прежде чем углубляться в реализацию ключей доступа, давайте рассмотрим необходимые навыки и инструменты. Вот что вам понадобится для начала:
Необходимо твердое знание основ веба — HTML, CSS и JavaScript. Мы намеренно все упростили, воздержавшись от современных JavaScript-фреймворков и полагаясь на Vanilla JavaScript / HTML. Единственная более сложная вещь, которую мы используем, — это библиотека-обертка для WebAuthn @simplewebauthn/browser.
Для нашего бэкенда мы используем сервер на Node.js (Express),
написанный на TypeScript. Мы также решили работать с серверной реализацией WebAuthn от
SimpleWebAuthn (@simplewebauthn/server
вместе с @simplewebauthn/typescript-types
).
Существует множество серверных реализаций WebAuthn, так что вы, конечно, можете
использовать любую из них. Поскольку мы выбрали сервер WebAuthn на TypeScript, требуются
базовые знания Node.js и npm.
Все данные пользователей и публичные ключи passkeys хранятся в базе данных. Мы выбрали MySQL в качестве технологии баз данных. Базовое понимание MySQL и реляционных баз данных будет полезным, хотя мы проведем вас по всем шагам.
Далее мы часто будем использовать термины WebAuthn и passkeys как взаимозаменяемые, хотя официально они могут означать не одно и то же. Однако для лучшего понимания, особенно в части кода, мы делаем это допущение.
Имея эти предварительные знания, вы готовы погрузиться в мир ключей доступа.
Прежде чем переходить к коду и конфигурациям, давайте рассмотрим архитектуру системы, которую мы собираемся построить. Вот разбивка архитектуры, которую мы будем настраивать:
С этим обзором архитектуры у вас должна быть концептуальная карта того, как взаимодействуют компоненты нашего приложения. По мере продвижения мы будем углубляться в каждый из этих компонентов, подробно описывая их настройку, конфигурацию и взаимодействие.
Следующая диаграмма описывает ход процесса во время регистрации (sign-up):
Следующая диаграмма описывает ход процесса во время аутентификации (login):
Кроме того, здесь вы найдете структуру проекта (только самые важные файлы):
passkeys-tutorial ├── src # Содержит весь исходный код бэкенда на TypeScript │ ├── controllers # Бизнес-логика для обработки определенных типов запросов │ │ ├── authentication.ts # Логика аутентификации с помощью ключа доступа │ │ └── registration.ts # Логика регистрации с помощью ключа доступа │ ├── middleware │ │ ├── customError.ts # Добавление пользовательских сообщений об ошибках в стандартизированном виде │ │ └── errorHandler.ts # Общий обработчик ошибок │ ├── public │ │ ├── index.html # Основной HTML-файл фронтенда │ │ ├── css │ │ │ └── style.css # Базовые стили │ │ └── js │ │ └── script.js # Логика JavaScript (включая WebAuthn API) │ ├── routes # Определения маршрутов API и их обработчиков │ │ └── routes.ts # Специфичные маршруты для ключей доступа │ ├── services │ │ ├── credentialService.ts# Взаимодействует с таблицей учетных данных │ │ └── userService.ts # Взаимодействует с таблицей пользователей │ ├── utils # Вспомогательные функции и утилиты │ | ├── constants.ts # Некоторые константы (например, rpID) │ | └── utils.ts # Вспомогательная функция │ ├── database.ts # Создает соединение от Node.js к базе данных MySQL │ ├── index.ts # Точка входа сервера Node.js │ └── server.ts # Управляет всеми настройками сервера ├── config.json # Некоторые конфигурации для проекта Node.js ├── docker-compose.yml # Определяет сервисы, сети и тома для контейнеров Docker ├── Dockerfile # Создает Docker-образ проекта ├── init-db.sql # Определяет схему нашей базы данных MySQL ├── package.json # Управляет зависимостями и скриптами проекта Node.js └── tsconfig.json # Настраивает, как TypeScript компилирует ваш код
При внедрении ключей доступа настройка базы данных является ключевым компонентом. Наш подход использует Docker-контейнер с MySQL, предлагая простую и изолированную среду, необходимую для надежного тестирования и развертывания.
Наша схема базы данных намеренно минималистична и содержит всего две таблицы. Эта простота способствует более ясному пониманию и облегчает обслуживание.
Подробная структура таблиц
1. Таблица Credentials: Центральная для аутентификации с помощью ключей доступа, эта таблица хранит учетные данные passkey. Критически важные столбцы:
credential_id
, важны соответствующий тип данных и форматирование.2. Таблица Users: Связывает учетные записи пользователей с их соответствующими учетными данными.
Обратите внимание, что мы назвали первую таблицу credentials
, так как, согласно нашему
опыту и рекомендациям других библиотек, это более подходящее название (в отличие от
предложения SimpleWebAuthn назвать ее authenticator
или authenticator_device
).
Типы данных для credential_id
и public_key
имеют решающее значение. Ошибки часто
возникают из-за неверных типов данных, кодировки или форматирования (особенно разница
между Base64 и Base64URL является частой причиной ошибок), что может нарушить весь процесс
регистрации или аутентификации.
Все необходимые SQL-команды для настройки этих таблиц содержатся в файле init-db.sql
.
Этот скрипт обеспечивает быструю и безошибочную инициализацию базы данных.
Для более сложных случаев вы можете добавить credential_device_type
или
credential_backed_up
, чтобы хранить больше информации об учетных данных и улучшить
пользовательский опыт. Однако в этом руководстве мы от этого воздержимся.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
После создания этого файла мы создаем новый файл docker-compose.yml
на корневом уровне
проекта:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
Этот файл запускает базу данных MySQL на порту 3306 и создает определенную структуру базы данных. Важно отметить, что имя и пароль для базы данных, используемые здесь, сделаны простыми для демонстрационных целей. В производственной среде следует использовать более сложные учетные данные для повышения безопасности.
Далее мы переходим к запуску нашего Docker-контейнера. На данный момент наш файл
docker-compose.yml
включает только этот один контейнер, но позже мы добавим больше
компонентов. Чтобы запустить контейнер, используйте следующую команду:
docker compose up -d
Как только контейнер будет запущен, нам нужно проверить, работает ли база данных как ожидалось. Откройте терминал и выполните следующую команду для взаимодействия с базой данных MySQL:
docker exec -it <container ID> mysql -uroot -p
Вам будет предложено ввести пароль root, который в нашем примере my-secret-pw
. После
входа выберите базу данных webauthn_db
и отобразите таблицы с помощью этих команд:
use webauthn_db; show tables;
На этом этапе вы должны увидеть две таблицы, определенные в нашем скрипте. Изначально эти таблицы будут пустыми, что указывает на то, что наша настройка базы данных завершена и готова к следующим шагам по внедрению ключей доступа.
Бэкенд является ядром любого приложения с ключами доступа, выступая в качестве центрального узла для обработки запросов на аутентификацию пользователей с фронтенда. Он взаимодействует с библиотекой сервера WebAuthn для обработки запросов на регистрацию и аутентификацию, а также взаимодействует с вашей базой данных MySQL для хранения и извлечения учетных данных пользователей. Ниже мы проведем вас через настройку вашего бэкенда с использованием Node.js (Express) с TypeScript, который будет предоставлять публичный API для обработки всех запросов.
Сначала создайте новый каталог для вашего проекта и перейдите в него с помощью терминала или командной строки.
Выполните команду
npx create-express-typescript-application passkeys-tutorial
Это создает базовый скелет кода приложения Node.js (Express), написанного на TypeScript, который мы можем использовать для дальнейших адаптаций.
Вашему проекту потребуется несколько ключевых пакетов, которые нам нужно установить дополнительно:
Перейдите в новый каталог и установите их с помощью следующих команд (мы также устанавливаем необходимые типы TypeScript):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Чтобы убедиться, что все установлено правильно, выполните
npm run dev:nodemon
Это должно запустить ваш сервер Node.js в режиме разработки с Nodemon, который автоматически перезапускает сервер при любых изменениях файлов.
Совет по устранению неполадок: Если вы столкнулись с ошибками, попробуйте обновить
ts-node
до версии 10.8.1 в файле package.json
, а затем выполните npm i
для установки
обновлений.
Ваш файл server.ts
имеет базовую настройку и промежуточное ПО для приложения
Express. Чтобы интегрировать функциональность ключей доступа, вам
нужно будет добавить:
Эти усовершенствования являются ключом к включению аутентификации с помощью ключей доступа в бэкенде вашего приложения. Мы настроим их позже.
После того как мы создали и запустили базу данных в
разделе 4, нам нужно убедиться, что наш бэкенд может к
ней подключиться. Для этого мы создаем новый файл database.ts
в папке /src
и добавляем
следующее содержимое:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
Этот файл позже будет использоваться нашим сервером для доступа к базе данных.
Давайте кратко рассмотрим наш config.json
, где уже определены две переменные: порт, на
котором мы запускаем приложение, и среда:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
может остаться как есть и должен выглядеть так:
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
выглядит так:
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
В server.ts
нам нужно адаптировать еще несколько вещей. Кроме того, необходим временный
кэш какого-либо рода (например, redis, memcache или
express-session) для хранения временных вызовов, по которым
пользователи могут аутентифицироваться. Мы решили использовать express-session
и
объявляем модуль express-session
вверху, чтобы все работало с express-session
. Кроме
того, мы оптимизируем маршрутизацию и пока убираем обработку ошибок (это будет добавлено в
промежуточное ПО позже):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
Для эффективного управления данными в наших двух созданных таблицах мы разработаем два
отдельных сервиса в новом каталоге src/services
: authenticatorService.ts
и
userService.ts
.
Каждый сервис будет инкапсулировать методы CRUD (Create, Read, Update, Delete), что
позволит нам взаимодействовать с базой данных модульным и организованным способом. Эти
сервисы облегчат хранение, извлечение и обновление данных в таблицах authenticator
и
user
. Вот как должна быть организована структура этих необходимых файлов:
userService.ts
выглядит так:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
выглядит следующим образом:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
Для централизованной обработки ошибок и облегчения отладки мы добавляем файл
errorHandler.ts
:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
Кроме того, мы добавляем новый файл customError.ts
, так как позже мы хотим иметь
возможность создавать пользовательские ошибки, чтобы быстрее находить баги:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
В папке utils
мы создаем два файла: constants.ts
и utils.ts
.
constant.ts
содержит некоторую базовую информацию о сервере WebAuthn, такую как имя
проверяющей стороны, ID проверяющей стороны и origin:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
содержит две функции, которые нам понадобятся позже для кодирования и
декодирования данных:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Теперь мы подходим к сердцу нашего бэкенда: контроллерам. Мы создаем два контроллера: один
для создания нового ключа доступа (registration.ts
) и один для входа с помощью ключа
доступа (authentication.ts
).
registration.ts
выглядит так:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
Давайте рассмотрим функциональность наших контроллеров, которые обрабатывают две ключевые конечные точки в процессе регистрации WebAuthn. Здесь же кроется одно из самых больших отличий от аутентификации на основе паролей: для каждой попытки регистрации или аутентификации требуется два вызова API бэкенда, которые требуют специфического контента на фронтенде между ними. Паролям обычно нужна только одна конечная точка.
1. Конечная точка handleRegisterStart:
Эта конечная точка запускается фронтендом, получая имя пользователя для создания нового ключа доступа и аккаунта. В этом примере мы разрешаем создание нового аккаунта / ключа доступа только в том случае, если аккаунт еще не существует. В реальных приложениях вам нужно было бы обрабатывать это таким образом, чтобы пользователям сообщалось, что ключ доступа уже существует, и добавление с того же устройства невозможно (но пользователь мог бы добавить ключи доступа с другого устройства после некоторой формы подтверждения). Для простоты мы упускаем это в этом руководстве.
Подготавливаются
PublicKeyCredentialCreationOptions.
residentKey
устанавливается в preferred
, а attestationType
в direct
, собирая
больше данных от аутентификатора для потенциального хранения в базе данных.
В общем, PublicKeyCredentialCreationOptions состоят из следующих данных:
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
) и домен (rp.id
).user.name
, user.id
и user.displayName
.ID пользователя и вызов хранятся в объекте сессии, что упрощает процесс для целей руководства. Кроме того, сессия очищается после каждой попытки регистрации или аутентификации.
2. Конечная точка handleRegisterFinish:
Эта конечная точка извлекает ранее установленные ID пользователя и вызов. Она проверяет
RegistrationResponse
с помощью вызова. Если он действителен, она сохраняет новое учетное
данное для пользователя. После сохранения в базе данных ID пользователя и вызов удаляются
из сессии.
Совет: При отладке вашего приложения мы настоятельно рекомендуем использовать браузер Chrome и его встроенные функции для улучшения опыта разработки приложений на основе ключей доступа, например, виртуальный аутентификатор WebAuthn и лог устройства (см. наши советы для разработчиков ниже для получения дополнительной информации)
Далее мы переходим к authentication.ts
, который имеет схожую структуру и
функциональность.
authentication.ts
выглядит так:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
Наш процесс аутентификации включает две конечные точки:
1. Конечная точка handleLoginStart:
Эта конечная точка активируется, когда пользователь пытается войти в систему. Сначала она проверяет, существует ли имя пользователя в базе данных, возвращая ошибку, если оно не найдено. В реальном сценарии вы могли бы предложить создать новый аккаунт.
Для существующих пользователей она извлекает ID пользователя из базы данных, сохраняет его
в сессии и генерирует опции
PublicKeyCredentialRequestOptions.
allowCredentials
остается пустым, чтобы не ограничивать использование учетных данных.
Поэтому в модальном окне passkey могут быть выбраны все доступные ключи доступа для этой
проверяющей стороны.
Сгенерированный вызов также сохраняется в сессии, а PublicKeyCredentialRequestOptions отправляются обратно на фронтенд.
PublicKeyCredentialRequestOptions состоят из следующих данных:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Конечная точка handleLoginFinish:
Эта конечная точка извлекает currentChallenge
и loggedInUserId
из сессии.
Она запрашивает в базе данных нужное учетное данное, используя ID учетного данного из тела
запроса. Если учетное данное найдено, это означает, что пользователь, связанный с этим ID
учетного данного, теперь может быть аутентифицирован. Затем мы можем запросить
пользователя из таблицы пользователей по ID пользователя, который мы получаем из учетного
данного, и проверить authenticationResponse
, используя вызов и тело запроса. Если все
успешно, мы показываем сообщение об успешном входе. Если соответствующее учетное данное не
найдено, отправляется ошибка.
Кроме того, если проверка проходит успешно, счетчик учетного данного обновляется, а использованный вызов и loggedInUserId удаляются из сессии.
Кроме того, мы можем удалить папки src/app
и src/constant
вместе со всеми файлами в
них.
Примечание: Правильное управление сессиями и защита маршрутов, критически важные в реальных приложениях, здесь опущены для простоты в этом руководстве.
И последнее, но не менее важное: нам нужно убедиться, что наши контроллеры доступны,
добавив соответствующие маршруты в routes.ts
, который находится в новом каталоге
src/routes
:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Эта часть руководства по ключам доступа посвящена тому, как поддерживать passkeys на
фронтенде вашего приложения. У нас очень простой фронтенд, состоящий из трех файлов:
index.html
, styles.css
и script.js
. Все три файла находятся в новой папке
src/public
.
Файл index.html
содержит поле ввода для имени пользователя и две кнопки для регистрации
и входа. Кроме того, мы импортируем скрипт @simplewebauthn/browser
, который упрощает
взаимодействие с API веб-аутентификации браузера в файле js/script.js
.
index.html
выглядит так:
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
выглядит следующим образом:
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
В script.js
есть три основные функции:
1. Функция showMessage:
Это вспомогательная функция, используемая в основном для отображения сообщений об ошибках, что помогает в отладке.
2. Функция Register:
Запускается, когда пользователь нажимает «Register». Она извлекает имя пользователя из
поля ввода и отправляет его на конечную точку passkeyRegisterStart
. Ответ включает
PublicKeyCredentialCreationOptions, которые преобразуются в JSON и передаются в
SimpleWebAuthnBrowser.startRegistration
. Этот вызов активирует аутентификатор устройства
(например, Face ID или Touch ID). После успешной локальной аутентификации подписанный
вызов отправляется обратно на конечную точку passkeyRegisterFinish
, завершая процесс
создания ключа доступа.
Во время процесса регистрации объект аттестации играет решающую роль, поэтому давайте рассмотрим его поближе.
Объект аттестации в основном состоит из трех компонентов: fmt
, attStmt
и authData
.
Элемент fmt
обозначает формат заявления об аттестации, а attStmt
представляет собой
само заявление об аттестации. В сценариях, где аттестация считается ненужной, fmt
будет
обозначен как "none", что приведет к пустому attStmt
.
Основное внимание уделяется сегменту authData
в этой структуре. Этот сегмент является
ключом для извлечения на нашем сервере таких важных элементов, как ID проверяющей стороны,
флаги, счетчик и данные аттестованных учетных данных. Что касается флагов, особый интерес
представляют BS (Backup State) и BE (Backup Eligibility), которые предоставляют больше
информации о том, синхронизирован ли ключ доступа (например, через
iCloud Keychain или
1Password). Кроме того, UV (User
Verification) и UP (User Presence) предоставляют более полезную информацию.
Важно отметить, что различные части объекта аттестации, включая данные аутентификатора, ID проверяющей стороны и заявление об аттестации, либо хэшируются, либо подписываются цифровой подписью аутентификатора с использованием его закрытого ключа. Этот процесс является неотъемлемой частью поддержания общей целостности объекта аттестации.
3. Функция Login:
Активируется, когда пользователь нажимает «Login». Подобно функции регистрации, она
извлекает имя пользователя и отправляет его на конечную точку passkeyLoginStart
. Ответ,
содержащий PublicKeyCredentialRequestOptions, преобразуется в JSON и используется с
SimpleWebAuthnBrowser.startAuthentication
. Это запускает локальную аутентификацию на
устройстве. Затем подписанный вызов отправляется обратно на конечную точку
passkeyLoginFinish
. Успешный ответ от этой конечной точки указывает на то, что
пользователь успешно вошел в приложение.
Кроме того, сопутствующий CSS-файл предоставляет простое оформление для приложения:
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
Чтобы увидеть ваше приложение в действии, скомпилируйте и запустите ваш TypeScript-код с помощью:
npm run dev
Ваш сервер теперь должен быть запущен и работать по адресу http://localhost:8080.
Соображения для производственной среды:
Помните, что мы рассмотрели базовый план. При развертывании приложения с ключами доступа в производственной среде вам необходимо глубже изучить:
Мы уже настроили Docker-контейнер для нашей базы данных. Далее мы расширим нашу
конфигурацию Docker Compose, включив в нее сервер с бэкендом и фронтендом. Ваш файл
docker-compose.yml
должен быть обновлен соответствующим образом.
Чтобы контейнеризировать наше приложение, мы создаем новый Dockerfile, который устанавливает необходимые пакеты и запускает сервер разработки:
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
Затем мы также расширяем файл docker-compose.yml
, чтобы запустить этот контейнер:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
Если вы теперь выполните docker compose up
в своем терминале и перейдете по адресу
http://localhost:8080, вы должны увидеть рабочую версию вашего
веб-приложения с ключами доступа (здесь запущено на
Windows 11 23H2 + Chrome 119):
Поскольку мы уже довольно долго работаем с реализациями ключей доступа, мы столкнулись с рядом проблем при работе с реальными приложениями passkey:
Кроме того, у нас есть следующие советы для разработчиков, когда дело доходит до части реализации:
Используйте Passkeys Debugger
Passkeys debugger помогает тестировать различные настройки сервера WebAuthn и ответы клиента. Кроме того, он предоставляет отличный парсер для ответов аутентификатора.
Отладка с помощью функции Chrome Device Log
Используйте лог устройства Chrome (доступен по адресу chrome://device-log/) для мониторинга вызовов FIDO/WebAuthn. Эта функция предоставляет логи процесса аутентификации в реальном времени, позволяя вам видеть обмениваемые данные и устранять возникающие проблемы.
Еще один очень полезный ярлык для доступа ко всем вашим ключам доступа в Chrome — это chrome://settings/passkeys.
Используйте виртуальный аутентификатор WebAuthn в Chrome
Чтобы избежать использования запросов Touch ID, Face ID или Windows Hello во время разработки, в Chrome есть очень удобный виртуальный аутентификатор WebAuthn, который эмулирует реальный аутентификатор. Мы настоятельно рекомендуем использовать его для ускорения работы. Подробнее можно узнать здесь.
Тестируйте на разных платформах и в разных браузерах
Обеспечьте совместимость и функциональность на различных браузерах и платформах. WebAuthn ведет себя по-разному в разных браузерах, поэтому тщательное тестирование является ключевым.
Тестируйте на разных устройствах
Здесь особенно полезно работать с такими инструментами, как ngrok, с помощью которых вы можете сделать ваше локальное приложение доступным на других (мобильных) устройствах.
Установите для верификации пользователя значение "Preferred"
При определении свойств для userVerification
в PublicKeyCredentialRequestOptions,
выберите значение preferred
, так как это хороший компромисс между удобством
использования и безопасностью. Это означает, что проверки безопасности будут действовать
на подходящих устройствах, в то время как на устройствах без биометрических возможностей
сохранится удобство для пользователя.
Мы надеемся, что это руководство по ключам доступа дало вам ясное понимание того, как эффективно их внедрять. В ходе руководства мы рассмотрели основные шаги по созданию приложения с ключами доступа, сосредоточившись на фундаментальных концепциях и практической реализации. Хотя это руководство служит отправной точкой, в мире WebAuthn есть еще много чего исследовать и совершенствовать.
Мы призываем разработчиков глубже погрузиться в нюансы ключей доступа (например, добавление нескольких ключей доступа, проверка готовности устройств к работе с passkeys или предложение решений для восстановления). Это путешествие, которое стоит предпринять, предлагая как вызовы, так и огромные преимущества в улучшении аутентификации пользователей. С ключами доступа вы не просто создаете новую функцию; вы вносите свой вклад в более безопасный и удобный для пользователя цифровой мир.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents