---
url: 'https://www.corbado.com/ru/blog/rukovodstvo-po-passkeys-kak-vnedrit-passkeys-v-veb-prilozheniya'
title: 'Руководство по ключам доступа: как внедрить ключи доступа в веб-приложения'
description: 'В этом руководстве объясняется, как внедрить ключи доступа (passkeys) в ваше веб-приложение. Мы используем Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript и MySQL.'
lang: 'ru'
author: 'Vincent Delitz'
date: '2025-06-20T11:06:06.221Z'
lastModified: '2026-03-25T10:07:00.248Z'
keywords: 'руководство по ключам доступа, руководство по passkeys'
category: 'Passkeys Implementation'
---

# Руководство по ключам доступа: как внедрить ключи доступа в веб-приложения

## 1. Введение: как внедрить ключи доступа

В этом руководстве мы поможем вам с внедрением ключей доступа, предложив пошаговую
инструкцию по добавлению passkeys на ваш сайт.

Современная, надежная и удобная для пользователя аутентификация — это ключ к созданию
отличного веб-сайта или приложения. Ключи доступа (passkeys) стали ответом на этот вызов.
Являясь новым стандартом для входа в систему, они обещают будущее без недостатков
традиционных паролей, обеспечивая по-настоящему беспарольный вход (что не только
безопасно, но и очень удобно).

Что действительно говорит о потенциале ключей доступа, так это поддержка, которую они
получили. Все основные браузеры, будь то Chrome, Firefox, Safari или Edge, и все важные
производители устройств (Apple, Microsoft, Google) внедрили их поддержку. Это единодушное
принятие показывает, что ключи доступа — это новый стандарт для входа в систему.

Да, уже существуют руководства по интеграции ключей доступа в веб-приложения. Будь то для
фронтенд-фреймворков, таких как [React](https://www.corbado.com/blog/react-passkeys),
[Vue.js](https://www.corbado.com/blog/vuejs-passkeys) или [Next.js](https://www.corbado.com/blog/nextjs-passkeys), существует множество
руководств, призванных облегчить трудности и ускорить внедрение ключей доступа. Однако
**полноценного руководства**, которое оставалось бы **минималистичным и низкоуровневым**,
не хватает. Многие разработчики обращались к нам с просьбой о руководстве, которое внесло
бы ясность во **внедрение ключей доступа для веб-приложений**.

Именно поэтому мы и создали это руководство. Наша цель? Создать минимально жизнеспособную
конфигурацию для ключей доступа, охватывающую **фронтенд, бэкенд и уровень базы данных**
(последний часто упускают из виду, хотя он может вызвать серьезные головные боли).

К концу этого пути вы создадите минимально жизнеспособное веб-приложение, в котором
сможете:

- Создавать ключ доступа
- Использовать ключ доступа для входа в систему

Для тех, кто торопится или хочет иметь под рукой готовый пример, вся кодовая база доступна
на [GitHub](https://github.com/corbado/passkey-tutorial).

Интересно, как выглядит конечный результат? Вот краткий обзор финального проекта
(признаем, он выглядит очень просто, но самое интересное скрыто под капотом):

![Экран входа в систему из руководства по Passkey](https://www.corbado.com/website-assets/6572cd2ed5d547903c8ad74d_passkey_tutorial_register_login_screen_f55a0f5ae9.png)

Мы полностью осознаем, что части кода и проекта можно реализовать иначе или более
изощренно, но мы хотели сосредоточиться на самом необходимом. Поэтому мы намеренно сделали
все простым и ориентированным на ключи доступа.

**Как добавить ключи доступа на мой рабочий сайт?**

Это очень минималистичный пример аутентификации с помощью ключей доступа. Следующие
моменты **НЕ** рассматриваются / не реализованы в этом руководстве или реализованы очень
базово:

- Условный UI / Условная медиация / автозаполнение ключей доступа
- Управление устройствами
- Управление сессиями
- Безопасное добавление нескольких устройств к аккаунту
- Обратная совместимость
- Правильная кроссплатформенная и кросс-устройственная поддержка
- Резервная аутентификация
- Правильная обработка ошибок
- Страница управления ключами доступа

Получение полной поддержки всех этих функций требует значительно больших усилий по
разработке. Тем, кто заинтересован, мы рекомендуем ознакомиться с этой статьей о
заблуждениях разработчиков относительно ключей доступа.

## 2. Предварительные требования для интеграции ключей доступа

Прежде чем углубляться в реализацию ключей доступа, давайте рассмотрим необходимые навыки
и инструменты. Вот что вам понадобится для начала:

### 2.1 Фронтенд: Vanilla HTML и JavaScript

Необходимо твердое знание основ веба — HTML, CSS и JavaScript. Мы намеренно все упростили,
воздержавшись от современных JavaScript-фреймворков и полагаясь на Vanilla JavaScript /
HTML. Единственная более сложная вещь, которую мы используем, — это библиотека-обертка для
WebAuthn [@simplewebauthn/browser](https://simplewebauthn.dev/docs/packages/browser/).

### 2.2 Бэкенд: Node.js (Express) на TypeScript + SimpleWebAuthn

Для нашего бэкенда мы используем сервер на [Node.js](https://www.corbado.com/blog/nodejs-passkeys) (Express),
написанный на TypeScript. Мы также решили работать с серверной реализацией WebAuthn от
SimpleWebAuthn (`@simplewebauthn/server` вместе с `@simplewebauthn/typescript-types`).
Существует множество серверных реализаций WebAuthn, так что вы, конечно, можете
использовать любую из них. Поскольку мы выбрали сервер WebAuthn на TypeScript, требуются
базовые знания [Node.js](https://www.corbado.com/blog/nodejs-passkeys) и npm.

### 2.3 База данных: MySQL

Все данные пользователей и публичные ключи passkeys хранятся в базе данных. Мы выбрали
[MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) в качестве технологии баз данных. Базовое
понимание [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) и реляционных баз данных будет
полезным, хотя мы проведем вас по всем шагам.

Далее мы часто будем использовать термины WebAuthn и passkeys как взаимозаменяемые, хотя
официально они могут означать не одно и то же. Однако для лучшего понимания, особенно в
части кода, мы делаем это допущение.

Имея эти предварительные знания, вы готовы погрузиться в мир ключей доступа.

## 3. Обзор архитектуры: пример реализации ключей доступа

Прежде чем переходить к коду и конфигурациям, давайте рассмотрим архитектуру системы,
которую мы собираемся построить. Вот разбивка архитектуры, которую мы будем настраивать:

- **Фронтенд:** Он состоит из двух кнопок: одна для регистрации пользователя (создание
  ключа доступа), а другая для аутентификации (вход с использованием ключа доступа).
- **Устройство и браузер:** Как только на фронтенде инициируется действие, в игру вступают
  устройство и браузер. Они облегчают создание и проверку ключа доступа, выступая
  посредниками между пользователем и бэкендом.
- **Бэкенд:** Бэкенд — это место, где в нашем приложении происходит настоящая магия. Он
  обрабатывает все запросы, инициированные фронтендом. Этот процесс включает создание и
  проверку ключей доступа. В основе операций бэкенда лежит сервер WebAuthn. Вопреки тому,
  что может показаться из названия, это не отдельный сервер. Вместо этого это библиотека
  или пакет, реализующий стандарт WebAuthn. Две основные функции: **Регистрация (Sign-up),
  где новые пользователи создают свои ключи доступа, и Аутентификация (Login): где
  существующие пользователи входят в систему с помощью своих ключей доступа.** В своей
  простейшей форме сервер WebAuthn предоставляет четыре публичных конечных точки API,
  разделенных на две категории: две для регистрации и две для аутентификации. Они
  предназначены для получения данных в определенном формате, которые затем обрабатываются
  сервером WebAuthn. Сервер WebAuthn отвечает за все необходимые криптографические
  операции. Важно отметить, что эти конечные точки API должны обслуживаться по протоколу
  HTTPS.
- **База данных MySQL:** Выступая в качестве нашего хранилища, база данных
  [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) отвечает за хранение данных пользователей
  и их соответствующих учетных данных.

С этим обзором архитектуры у вас должна быть концептуальная карта того, как
взаимодействуют компоненты нашего приложения. По мере продвижения мы будем углубляться в
каждый из этих компонентов, подробно описывая их настройку, конфигурацию и взаимодействие.

Следующая диаграмма описывает ход процесса во время регистрации (sign-up):

![Схема процесса регистрации с помощью Passkey](https://www.corbado.com/website-assets/6572cd4d3243003bb3589e88_passkey_sign_up_process_chart_b73d643b4c.png)

Следующая диаграмма описывает ход процесса во время аутентификации (login):

![Схема процесса входа с помощью Passkey](https://www.corbado.com/website-assets/6572cd5f04fd73a7ff5d501b_passkey_login_process_chart_a44262b767.png)

Кроме того, здесь вы найдете структуру проекта (только самые важные файлы):

```
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 компилирует ваш код
```

## 4. Настройка базы данных MySQL

При внедрении ключей доступа настройка базы данных является ключевым компонентом. Наш
подход использует Docker-контейнер с MySQL, предлагая простую и изолированную среду,
необходимую для надежного тестирования и развертывания.

Наша схема базы данных намеренно минималистична и содержит всего две таблицы. Эта простота
способствует более ясному пониманию и облегчает обслуживание.

**Подробная структура таблиц**

**1. Таблица Credentials:** Центральная для аутентификации с помощью ключей доступа, эта
таблица хранит учетные данные passkey. Критически важные столбцы:

- **credential_id:** Уникальный идентификатор для каждого учетного данного. Выбор
  правильного типа данных для этого поля жизненно важен, чтобы избежать ошибок
  форматирования.
- **public_key:** Хранит публичный ключ для каждого учетного данного. Как и в случае с
  `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`, чтобы хранить больше информации об учетных данных и улучшить
пользовательский опыт. Однако в этом руководстве мы от этого воздержимся.

```sql filename="init-db.sql"
CREATE 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` на корневом уровне
проекта:

```yaml filename="docker-compose.yml"
version: "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` включает только этот один контейнер, но позже мы добавим больше
компонентов. Чтобы запустить контейнер, используйте следующую команду:

```bash
docker compose up -d
```

Как только контейнер будет запущен, нам нужно проверить, работает ли база данных как
ожидалось. Откройте терминал и выполните следующую команду для взаимодействия с базой
данных MySQL:

```bash
docker exec -it <container ID> mysql -uroot -p
```

Вам будет предложено ввести пароль root, который в нашем примере `my-secret-pw`. После
входа выберите базу данных `webauthn_db` и отобразите таблицы с помощью этих команд:

```sql
use
webauthn_db;
show
tables;
```

На этом этапе вы должны увидеть две таблицы, определенные в нашем скрипте. Изначально эти
таблицы будут пустыми, что указывает на то, что наша настройка базы данных завершена и
готова к следующим шагам по внедрению ключей доступа.

## 5. Внедрение ключей доступа: шаги по интеграции бэкенда

Бэкенд является ядром любого приложения с ключами доступа, выступая в качестве
центрального узла для обработки запросов на аутентификацию пользователей с фронтенда. Он
взаимодействует с библиотекой сервера WebAuthn для обработки запросов на регистрацию и
аутентификацию, а также взаимодействует с вашей базой данных MySQL для хранения и
извлечения учетных данных пользователей. Ниже мы проведем вас через настройку вашего
бэкенда с использованием [Node.js](https://www.corbado.com/blog/nodejs-passkeys) (Express) с TypeScript, который
будет предоставлять публичный API для обработки всех запросов.

### 5.1 Инициализация сервера Node.js (Express)

Сначала создайте новый каталог для вашего проекта и перейдите в него с помощью терминала
или командной строки.

Выполните команду

```bash
npx create-express-typescript-application passkeys-tutorial
```

Это создает базовый скелет кода приложения Node.js (Express), написанного на TypeScript,
который мы можем использовать для дальнейших адаптаций.

Вашему проекту потребуется несколько ключевых пакетов, которые нам нужно установить
дополнительно:

- **@simplewebauthn/server:** Серверная библиотека для облегчения операций WebAuthn, таких
  как регистрация пользователя и аутентификация.
- **express-session:** Промежуточное ПО для [Express.js](https://www.corbado.com/blog/nodejs-passkeys) для
  управления сессиями, хранения данных сессий на стороне сервера и обработки cookie.
- **uuid:** Утилита для генерации универсально уникальных идентификаторов (UUID), обычно
  используемых для создания уникальных ключей или идентификаторов в приложениях.
- **mysql2:** Клиент Node.js для MySQL, предоставляющий возможности для подключения и
  выполнения запросов к базам данных MySQL.

Перейдите в новый каталог и установите их с помощью следующих команд (мы также
устанавливаем необходимые типы TypeScript):

```bash
cd passkeys-tutorial
npm install @simplewebauthn/server mysql2 uuid express-session
@types/express-session @types/uuid
```

Чтобы убедиться, что все установлено правильно, выполните

```bash
npm run dev:nodemon
```

Это должно запустить ваш сервер Node.js в режиме разработки с Nodemon, который
автоматически перезапускает сервер при любых изменениях файлов.

![Запуск приложения из руководства по Passkey](https://www.corbado.com/website-assets/6572cd86e1fa2982352c7ca1_passkey_tutorial_start_app_b442392b4d.png)

**Совет по устранению неполадок:** Если вы столкнулись с ошибками, попробуйте обновить
`ts-node` до версии 10.8.1 в файле `package.json`, а затем выполните `npm i` для установки
обновлений.

Ваш файл `server.ts` имеет базовую настройку и промежуточное ПО для приложения Express.
Чтобы интегрировать функциональность ключей доступа, вам нужно будет добавить:

- **Маршруты:** Определить новые маршруты для регистрации и аутентификации с помощью
  ключей доступа.
- **Контроллеры:** Создать контроллеры для обработки логики этих маршрутов.
- **Промежуточное ПО:** Интегрировать промежуточное ПО для обработки запросов и ошибок.
- **Сервисы:** Создать сервисы для извлечения и хранения данных в базе данных.
- **Вспомогательные функции:** Включить вспомогательные функции для эффективных операций с
  кодом.

Эти усовершенствования являются ключом к включению аутентификации с помощью ключей доступа
в бэкенде вашего приложения. Мы настроим их позже.

### 5.2 Подключение к базе данных MySQL

После того как мы создали и запустили базу данных в
[разделе 4](#4-setup-of-the-mysql-database), нам нужно убедиться, что наш бэкенд может к
ней подключиться. Для этого мы создаем новый файл `database.ts` в папке `/src` и добавляем
следующее содержимое:

```ts filename="database.ts"
import 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();
```

Этот файл позже будет использоваться нашим сервером для доступа к базе данных.

### 5.3 Конфигурация сервера приложений

Давайте кратко рассмотрим наш `config.json`, где уже определены две переменные: порт, на
котором мы запускаем приложение, и среда:

```json filename="config.json"
{
    "PORT": 8080,
    "NODE_ENV": "development"
}
```

`package.json` может остаться как есть и должен выглядеть так:

```json filename="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` выглядит так:

```ts filename="index.ts"
import 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`. Кроме того, мы оптимизируем маршрутизацию и пока убираем
обработку ошибок (это будет добавлено в промежуточное ПО позже):

```ts filename="server.ts"
import 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;
```

### 5.4 Сервисы CredentialService и UserService

Для эффективного управления данными в наших двух созданных таблицах мы разработаем два
отдельных сервиса в новом каталоге `src/services`: `authenticatorService.ts` и
`userService.ts`.

Каждый сервис будет инкапсулировать методы
[CRUD](https://www.corbado.com/ru/blog/crud-prilozhenie-react-express-mysql) (Create, Read, Update, Delete), что
позволит нам взаимодействовать с базой данных модульным и организованным способом. Эти
сервисы облегчат хранение, извлечение и обновление данных в таблицах `authenticator` и
`user`. Вот как должна быть организована структура этих необходимых файлов:

`userService.ts` выглядит так:

```ts filename="userService.ts"
import { 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` выглядит следующим образом:

```ts filename="credentialService.ts"
import { 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;
        }
    },
};
```

### 5.5 Промежуточное ПО (Middleware)

Для централизованной обработки ошибок и облегчения отладки мы добавляем файл
`errorHandler.ts`:

```ts filename="errorHandler.ts"
import { 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`, так как позже мы хотим иметь
возможность создавать пользовательские ошибки, чтобы быстрее находить баги:

```ts filename="customError.ts"
export class CustomError extends Error {
    statusCode: number;

    constructor(message: string, statusCode: number = 500) {
        super(message);
        this.statusCode = statusCode;
        Object.setPrototypeOf(this, CustomError.prototype);
    }
}
```

### 5.6 Утилиты

В папке `utils` мы создаем два файла: `constants.ts` и `utils.ts`.

`constant.ts` содержит некоторую базовую информацию о сервере WebAuthn, такую как имя
проверяющей стороны, ID проверяющей стороны и origin:

```ts filename="constant.ts"
export const rpName: string = "Passkeys Tutorial";
export const rpID: string = "localhost";
export const origin: string = `http://${rpID}:8080`;
```

`utils.ts` содержит две функции, которые нам понадобятся позже для кодирования и
декодирования данных:

```ts filename="utils.ts"
export const uint8ArrayToBase64 = (uint8Array: Uint8Array): string =>
    Buffer.from(uint8Array).toString("base64");

export const base64ToUint8Array = (base64: string): Uint8Array =>
    new Uint8Array(Buffer.from(base64, "base64"));
```

### 5.7 Контроллеры ключей доступа с SimpleWebAuthn

Теперь мы подходим к сердцу нашего бэкенда: контроллерам. Мы создаем два контроллера: один
для создания нового ключа доступа (`registration.ts`) и один для входа с помощью ключа
доступа (`authentication.ts`).

`registration.ts` выглядит так:

```ts filename="registration.ts"
import {
    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](https://www.corbado.com/glossary/publickeycredentialcreationoptions).
`residentKey` устанавливается в `preferred`, а `attestationType` в `direct`, собирая
больше данных от аутентификатора для потенциального хранения в базе данных.

В общем,
[PublicKeyCredentialCreationOptions](https://www.corbado.com/glossary/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:** Представляет информацию о проверяющей стороне (веб-сайте или сервисе), обычно
  включая ее имя (`rp.name`) и домен (`rp.id`).
- **user:** Содержит данные учетной записи пользователя, такие как `user.name`, `user.id`
  и `user.displayName`.
- **challenge:** Безопасное, случайное значение, созданное сервером WebAuthn для
  предотвращения атак повторного воспроизведения во время процесса регистрации.
- **pubKeyCredParams:** Указывает тип создаваемого учетного данного с открытым ключом,
  включая используемый криптографический алгоритм.
- **timeout:** Необязательно, устанавливает время в миллисекундах, которое есть у
  пользователя для завершения взаимодействия.
- **excludeCredentials:** Список учетных данных, которые следует исключить; используется
  для предотвращения многократной регистрации ключа доступа для одного и того же
  устройства / аутентификатора.
- **authenticatorSelection:** Критерии для выбора аутентификатора, например, должен ли он
  поддерживать верификацию пользователя или как следует поощрять использование резидентных
  ключей.
- **attestation:** Указывает желаемое предпочтение передачи аттестации, например, "none",
  "indirect" или "direct".
- **extensions:** Необязательно, позволяет использовать дополнительные клиентские
  расширения.

ID пользователя и вызов хранятся в объекте сессии, что упрощает процесс для целей
руководства. Кроме того, сессия очищается после каждой попытки регистрации или
аутентификации.

**2. Конечная точка handleRegisterFinish:**

Эта конечная точка извлекает ранее установленные ID пользователя и вызов. Она проверяет
`RegistrationResponse` с помощью вызова. Если он действителен, она сохраняет новое учетное
данное для пользователя. После сохранения в базе данных ID пользователя и вызов удаляются
из сессии.

Совет: При отладке вашего приложения мы настоятельно рекомендуем использовать браузер
Chrome и его встроенные функции для улучшения опыта разработки приложений на основе ключей
доступа, например, виртуальный аутентификатор WebAuthn и лог устройства (см. наши советы
для разработчиков ниже для получения дополнительной информации)

Далее мы переходим к `authentication.ts`, который имеет схожую структуру и
функциональность.

`authentication.ts` выглядит так:

```ts filename="authentication.ts"
import { 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](https://www.corbado.com/glossary/publickeycredentialrequestoptions).
`allowCredentials` остается пустым, чтобы не ограничивать использование учетных данных.
Поэтому в модальном окне passkey могут быть выбраны все доступные ключи доступа для этой
проверяющей стороны.

Сгенерированный вызов также сохраняется в сессии, а
[PublicKeyCredentialRequestOptions](https://www.corbado.com/glossary/publickeycredentialrequestoptions)
отправляются обратно на фронтенд.

[PublicKeyCredentialRequestOptions](https://www.corbado.com/glossary/publickeycredentialrequestoptions) состоят
из следующих данных:

```
dictionary PublicKeyCredentialRequestOptions {
    required BufferSource                challenge;
    unsigned long                        timeout;
    USVString                            rpId;
    sequence<PublicKeyCredentialDescriptor> allowCredentials = [];
    DOMString                            userVerification = "preferred";
    AuthenticationExtensionsClientInputs extensions;
};
```

- **challenge:** Безопасное, случайное значение от сервера WebAuthn, используемое для
  предотвращения атак повторного воспроизведения во время процесса аутентификации.
- **timeout:** Необязательно, устанавливает время в миллисекундах, которое есть у
  пользователя для ответа на запрос аутентификации.
- **rpId:** ID проверяющей стороны, обычно домен сервиса.
- **allowCredentials:** Необязательный список дескрипторов учетных данных, указывающий,
  какие учетные данные могут быть использованы для этой аутентификации.
- **userVerification:** Указывает требование к верификации пользователя, например,
  "required", "preferred" или "discouraged".
- **extensions:** Необязательно, позволяет использовать дополнительные клиентские
  расширения.

**2. Конечная точка handleLoginFinish:**

Эта конечная точка извлекает `currentChallenge` и `loggedInUserId` из сессии.

Она запрашивает в базе данных нужное учетное данное, используя ID учетного данного из тела
запроса. Если учетное данное найдено, это означает, что пользователь, связанный с этим ID
учетного данного, теперь может быть аутентифицирован. Затем мы можем запросить
пользователя из таблицы пользователей по ID пользователя, который мы получаем из учетного
данного, и проверить `authenticationResponse`, используя вызов и тело запроса. Если все
успешно, мы показываем сообщение об успешном входе. Если соответствующее учетное данное не
найдено, отправляется ошибка.

Кроме того, если проверка проходит успешно, счетчик учетного данного обновляется, а
использованный вызов и loggedInUserId удаляются из сессии.

Кроме того, мы можем удалить папки `src/app` и `src/constant` вместе со всеми файлами в
них.

Примечание: Правильное управление сессиями и защита маршрутов, критически важные в
реальных приложениях, здесь опущены для простоты в этом руководстве.

### 5.8 Маршруты для ключей доступа

И последнее, но не менее важное: нам нужно убедиться, что наши контроллеры доступны,
добавив соответствующие маршруты в `routes.ts`, который находится в новом каталоге
`src/routes`:

```ts filename="routes.ts"
import 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 };
```

## 6. Интеграция ключей доступа во фронтенд

Эта часть руководства по ключам доступа посвящена тому, как поддерживать passkeys на
фронтенде вашего приложения. У нас очень простой фронтенд, состоящий из трех файлов:
`index.html`, `styles.css` и `script.js`. Все три файла находятся в новой папке
`src/public`.

Файл `index.html` содержит поле ввода для имени пользователя и две кнопки для регистрации
и входа. Кроме того, мы импортируем скрипт `@simplewebauthn/browser`, который упрощает
взаимодействие с API веб-аутентификации браузера в файле `js/script.js`.

`index.html` выглядит так:

```html filename="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` выглядит следующим образом:

```javascript filename="script.js"
document.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](https://www.corbado.com/faq/is-face-id-passkey) или Touch ID). После успешной локальной
аутентификации подписанный вызов отправляется обратно на конечную точку
`passkeyRegisterFinish`, завершая процесс создания ключа доступа.

Во время процесса регистрации объект аттестации играет решающую роль, поэтому давайте
рассмотрим его поближе.

![Объект аттестации Passkey](https://www.corbado.com/website-assets/6572cda3f3869adee2c38e05_passkey_attestation_object_5bfcce1977.png)

Объект аттестации в основном состоит из трех компонентов: `fmt`, `attStmt` и `authData`.
Элемент `fmt` обозначает формат заявления об аттестации, а `attStmt` представляет собой
само заявление об аттестации. В сценариях, где аттестация считается ненужной, `fmt` будет
обозначен как "none", что приведет к пустому `attStmt`.

Основное внимание уделяется сегменту `authData` в этой структуре. Этот сегмент является
ключом для извлечения на нашем сервере таких важных элементов, как ID проверяющей стороны,
флаги, счетчик и данные аттестованных учетных данных. Что касается флагов, особый интерес
представляют BS (Backup State) и BE (Backup Eligibility), которые предоставляют больше
информации о том, синхронизирован ли ключ доступа (например, через
[iCloud Keychain](https://www.corbado.com/glossary/icloud-keychain) или
[1Password](https://www.corbado.com/blog/1password-passkeys-best-practices-analysis)). Кроме того, UV (User
Verification) и UP (User Presence) предоставляют более полезную информацию.

![Данные аутентификатора Passkey](https://www.corbado.com/website-assets/6572cf3ce1fa2982352d6c88_passkey_authenticator_data_7f61aceaea.png)

Важно отметить, что различные части объекта аттестации, включая данные аутентификатора, ID
проверяющей стороны и заявление об аттестации, либо хэшируются, либо подписываются
цифровой подписью аутентификатора с использованием его закрытого ключа. Этот процесс
является неотъемлемой частью поддержания общей целостности объекта аттестации.

**3. Функция Login:**

Активируется, когда пользователь нажимает «Login». Подобно функции регистрации, она
извлекает имя пользователя и отправляет его на конечную точку `passkeyLoginStart`. Ответ,
содержащий **PublicKeyCredentialRequestOptions**, преобразуется в JSON и используется с
`SimpleWebAuthnBrowser.startAuthentication`. Это запускает локальную аутентификацию на
устройстве. Затем подписанный вызов отправляется обратно на конечную точку
`passkeyLoginFinish`. Успешный ответ от этой конечной точки указывает на то, что
пользователь успешно вошел в приложение.

Кроме того, сопутствующий CSS-файл предоставляет простое оформление для приложения:

```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;
}
```

## 7. Запуск примера приложения с ключами доступа

Чтобы увидеть ваше приложение в действии, скомпилируйте и запустите ваш TypeScript-код с
помощью:

```bash
npm run dev
```

Ваш сервер теперь должен быть запущен и работать по адресу
[http://localhost:8080](http://localhost:8080).

**Соображения для производственной среды:**

Помните, что мы рассмотрели базовый план. При развертывании приложения с ключами доступа в
производственной среде вам необходимо глубже изучить:

- **Меры безопасности:** Внедрите надежные практики безопасности для защиты данных
  пользователей.
- **Обработка ошибок:** Убедитесь, что ваше приложение корректно обрабатывает и
  регистрирует ошибки.
- **Управление базой данных:** Оптимизируйте операции с базой данных для масштабируемости
  и надежности.

## 8. Интеграция DevOps для ключей доступа

Мы уже настроили Docker-контейнер для нашей базы данных. Далее мы расширим нашу
конфигурацию Docker Compose, включив в нее сервер с бэкендом и фронтендом. Ваш файл
`docker-compose.yml` должен быть обновлен соответствующим образом.

Чтобы контейнеризировать наше приложение, мы создаем новый Dockerfile, который
устанавливает необходимые пакеты и запускает сервер разработки:

```docker filename="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`, чтобы запустить этот контейнер:

```yaml filename="docker-compose.yml"
version: "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](http://localhost:8080), вы должны увидеть рабочую версию вашего
веб-приложения с ключами доступа (здесь запущено на
[Windows 11](https://www.corbado.com/blog/passkeys-windows-11) 23H2 + Chrome 119):

![Руководство по Passkey с Windows Hello](https://www.corbado.com/website-assets/6572cf57f3869adee2c49ff5_passkey_tutorial_windows_hello_5ac0585957.png)

## 9. Дополнительные советы по ключам доступа для разработчиков

Поскольку мы уже довольно долго работаем с реализациями ключей доступа, мы столкнулись с
рядом проблем при работе с реальными приложениями passkey:

- Совместимость и поддержка устройств / платформ
- Онбординг и обучение пользователей
- Обработка утерянных или измененных устройств
- Кроссплатформенная аутентификация
- Резервные механизмы
- Сложность кодирования: кодирование часто является самой сложной частью, так как
  приходится иметь дело с JSON, [CBOR](https://www.corbado.com/glossary/cbor), uint8arrays, буферами, блобами,
  различными базами данных, base64 и base64url, где может возникнуть много ошибок
- Управление ключами доступа (например, для добавления, удаления или переименования ключей
  доступа)

Кроме того, у нас есть следующие советы для разработчиков, когда дело доходит до части
реализации:

**Используйте Passkeys Debugger**

[**Passkeys debugger**](https://www.passkeys-debugger.io/) помогает тестировать различные
настройки сервера WebAuthn и ответы клиента. Кроме того, он предоставляет отличный парсер
для ответов аутентификатора.

**Отладка с помощью функции Chrome Device Log**

Используйте лог устройства Chrome (доступен по адресу
[chrome://device-log/](chrome://device-log/)) для мониторинга вызовов
[FIDO](https://www.corbado.com/ru/blog/emv-3ds-acs-passkeys-fido-spc-obzor)/WebAuthn. Эта функция предоставляет
логи процесса аутентификации в реальном времени, позволяя вам видеть обмениваемые данные и
устранять возникающие проблемы.

Еще один очень полезный ярлык для доступа ко всем вашим ключам доступа в Chrome — это
[chrome://settings/passkeys](chrome://settings/passkeys).

**Используйте виртуальный аутентификатор WebAuthn в Chrome**

Чтобы избежать использования запросов Touch ID, [Face ID](https://www.corbado.com/faq/is-face-id-passkey) или
[Windows Hello](https://www.corbado.com/glossary/windows-hello) во время разработки, в Chrome есть очень удобный
виртуальный аутентификатор WebAuthn, который эмулирует реальный аутентификатор. Мы
настоятельно рекомендуем использовать его для ускорения работы. Подробнее можно узнать
[здесь](https://developer.chrome.com/docs/devtools/webauthn/).

**Тестируйте на разных платформах и в разных браузерах**

Обеспечьте совместимость и функциональность на различных браузерах и платформах. WebAuthn
ведет себя по-разному в разных браузерах, поэтому тщательное тестирование является
ключевым.

**Тестируйте на разных устройствах**

Здесь особенно полезно работать с такими инструментами, как
[ngrok](https://www.corbado.com/blog/multi-device-passkey-login-corbado-ngrok), с помощью которых вы можете
сделать ваше локальное приложение доступным на других (мобильных) устройствах.

**Установите для верификации пользователя значение "Preferred"**

При определении свойств для `userVerification` в **PublicKeyCredentialRequestOptions**,
выберите значение `preferred`, так как это хороший компромисс между удобством
использования и безопасностью. Это означает, что проверки безопасности будут действовать
на подходящих устройствах, в то время как на устройствах без биометрических возможностей
сохранится удобство для пользователя.

## 10. Заключение: руководство по ключам доступа

Мы надеемся, что это руководство по ключам доступа дало вам ясное понимание того, как
эффективно их внедрять. В ходе руководства мы рассмотрели основные шаги по созданию
приложения с ключами доступа, сосредоточившись на фундаментальных концепциях и
практической реализации. Хотя это руководство служит отправной точкой, в мире WebAuthn
есть еще много чего исследовать и совершенствовать.

Мы призываем разработчиков глубже погрузиться в нюансы ключей доступа (например,
добавление нескольких ключей доступа, проверка готовности устройств к работе с passkeys
или предложение решений для восстановления). Это путешествие, которое стоит предпринять,
предлагая как вызовы, так и огромные преимущества в улучшении аутентификации
пользователей. С ключами доступа вы не просто создаете новую функцию; вы вносите свой
вклад в более безопасный и удобный для пользователя цифровой мир.
