Реализация входа без пароля с помощью WebAuthn с использованием Node.js и Vue

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

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

Введите WebAuthn — новый метод аутентификации, основанный на стандартах, который может революционизировать способы входа на веб-сайты и в онлайн-сервисы.

В этой статье мы рассмотрим, что такое WebAuthn, как он работает и почему он меняет правила игры для будущего онлайн-безопасности и взаимодействия с пользователем.

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

Хотите погрузиться прямо в исходный код? Проверьте этот репозиторий GitHub.

Что такое WebAuthn и почему это важно?

Раскройте потенциал безопасной веб-аутентификации с помощью WebAuthn — передового стандарта, разработанного в сотрудничестве с крупнейшими разработчиками технологий.

Альянс W3C и FIDO объединил усилия с технологическими титанами, такими как Google, Microsoft, Mozilla и Yubico, чтобы предложить вам это революционное решение.

В рамках спецификаций FIDO2 WebAuthn использует протокол Client-to-Authenticator Protocol (CTAP) для подключения вашего клиентского устройства с доверенным ключом безопасности.

Вы соединяете свой ключ безопасности и клиентское устройство с помощью CTAP (протокол Client-to-Authenticator). Этот протокол обеспечивает бесперебойную связь между ними.

Но это еще не все — WebAuthn определяет протокол связи между вашим клиентским устройством и сервером для безопасной аутентификации в Интернете. Вместе CTAP и WebAuthn составляют решение FIDO2.

Так же, как сш

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

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

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

Почему это имеет значение?

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

Вот где на помощь приходит WebAuthn — более безопасная и удобная альтернатива паролям, снижающая риск захвата учетных записей и повышающая общую безопасность онлайн-сервисов.

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

Давайте изучим WebAuthn API, чтобы лучше понять его функции и возможности.

API-интерфейс WebAuthn

WebAuthn API содержит несколько функций и объектов для выполнения задач, связанных с аутентификацией. Нам нужен API для выполнения двух основных задач, регистрации и аутентификации. Полный API задокументирован W3C.

Постановка на учет

Чтобы зарегистрироваться в WebAuthn, мы начинаем с создания объекта учетных данных. Процесс требует только имя пользователя. Метод credentials.create принимает объект publicKeyCredentialsOptions с обязательными и необязательными полями.

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});

Объект publicKeyCredentialCreationOptions обычно создается на сервере и содержит следующие параметры.

const publicKeyCredentialCreationOptions = {
    rp: {
        name: "SimpleTechture",
        id: "simpletechture.nl",
    },    
    challenge: Uint8Array.from(
    randomStringFromServer, c => c.charCodeAt(0)),
    user: {
        id: Uint8Array.from(
            "UZSL54T9AFC", c => c.charCodeAt(0)),
        name: "[email protected]",
        displayName: "Patrick",
    },
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform",
    },
    timeout: 60000,
    attestation: "direct"
};

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

rp — относится к идентификации проверяющей стороны или веб-сайта, на который вы хотите войти. Он состоит из идентификатора (который должен быть действительной строкой домена) и имени.

Challenge – это случайно сгенерированная проверяющей стороной строка данных для предотвращения повторных атак. Сервер должен проверить этот вызов при получении от клиента и поддерживать его значение во время операции.

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

pubKeyCredParams — это массив объектов, описывающих допустимые сервером типы открытых ключей. Каждый объект имеет поля alg и type. Поле alg указывает алгоритм подписи, который можно использовать, а поле type определяет допустимые типы учетных данных (в настоящее время разрешено только “public-key”, но это может измениться в будущем). Поле alg может принимать любые значения, указанные в разделе Подписание и шифрование объектов CBOR.

authenticatorSelection — этот необязательный объект позволяет проверяющей стороне ограничить разрешенные устройства проверки подлинности, указав в поле «authenticatorAttachment» значения “cross-platform” или “platform”. «Кроссплатформенность» относится к внешним средствам проверки подлинности, таким как устройства U2F, а платформа — к интегрированным средствам проверки подлинности, таким как Windows Hello или сканеры отпечатков пальцев.

время ожидания —время (в миллисекундах), в течение которого пользователь должен ответить на запрос о регистрации, прежде чем будет возвращена ошибка.

аттестация —какой тип данных аттестации требуется проверяющей стороне? Значение по умолчанию — «нет», что означает отсутствие предпочтений для данных аттестации. Другие варианты включают «косвенный», «прямой» или «предприятие».

Диалог регистрации

В тот момент, когда вы вызываете метод navigator.credentials.create с publicKeyCredentialCreationOptions, браузер покажет вам диалоговое окно аутентификации, в котором показаны возможные варианты аутентификации для вашей платформы и браузера.

Завершить регистрацию

Объект, возвращенный из navigator.credentials.create, отправляется обратно на сервер для проверки, проверки и хранения в базе данных.

Вход

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

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

{
 "challenge": "u_5ntSMaR5STaQF1Lm6BE5mb-ioCDWmPVKjQg_m7l-I",
 "allowCredentials": [{
  "type": "public-key",
  "id": "SoygCgGrf8uqMP_rq1ipqQ",
  "transports": ["usb", "nfc", "ble", "internal"]
 }],
 "userVerification": "preferred",
 "rpId": "localhost",
 "timeout": 60000
}

Рассмотрим каждое из полей объекта.

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

allowCredentials — этот массив сообщает браузеру, с какими учетными данными сервер хочет, чтобы пользователь аутентифицировался. Идентификатор — это значение, которое было сгенерировано и сохранено при регистрации. Сервер может дополнительно указать, какие транспорты он предпочитает.

userVerification — этот элемент определяет требования проверяющей стороны в отношении проверки пользователя для операции получения.

rpId — этот элемент определяет идентификатор RP, заявленный проверяющей стороной. Клиент ДОЛЖЕН убедиться, что источник проверяющей стороны соответствует области действия этого идентификатора RP. Аутентификатор ДОЛЖЕН убедиться, что этот идентификатор RP точно равен rpId учетных данных, которые будут использоваться для церемонии аутентификации.

время ожидания — как и при регистрации, здесь можно указать время (в миллисекундах), которое пользователь должен ответить на запрос аутентификации.

Передача ответа API WebAuthn

Затем ответ передается в качестве аргумента функции navigator.credentials.get, которая показывает диалоговое окно аутентификации браузера.

const credential = await navigator.credentials.get({ publicKey });

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

{
 "rawId": "SoygCgGrf8uqMP_rq1ipqQ",
 "response": {
  "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABg",
  "signature": "MEUCIGVbyNqSMK4Fdw6hFnO_g1FdOs7KmcBKp5VWoEs8XUNyAiEAqiFpe353J0MIMtiy0_NgPiOfbzSaBDjsktZDUa0R-6c",
  "userHandle": "qiKRsKsfsIBSl7fgWoFF6ZKoR50K5j9pQsUiMm9UfZs",
  "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoidV81bnRTTWFSNVNUYVFGMUxtNkJFNW1iLWlvQ0RXbVBWS2pRZ19tN2wtSSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
 },
 "authenticatorAttachment": null,
 "getClientExtensionResults": {},
 "id": "SoygCgGrf8uqMP_rq1ipqQ",
 "type": "public-key"
}

Рассмотрим каждое из полей.

id — идентификатор учетных данных, которые использовались для создания утверждения аутентификации.

rawId — снова идентификатор, но в двоичной форме. В приведенном выше примере мы преобразовали его в строку base64, чтобы мы могли отправить ее на сервер.

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

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

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

userHandle — это поле необязательно предоставляется аутентификатором и представляет собой идентификатор пользователя, предоставленный во время регистрации. Его можно использовать, чтобы связать это утверждение с пользователем на сервере.

Проверка данных аутентификации

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

Присоединяйтесь ко мне в изучении приложения, которое реализует WebAuthn на практике.

Реализация WebAuthn с использованием Node.js и Vue

Я создал приложение, демонстрирующее возможности WebAuthn. Внешний интерфейс сделан с помощью Vue3, а серверная часть поддерживается Node.js. Серверная часть использует Fastify в качестве веб-фреймворка. Приложение хранит информацию о пользователе и WebAuthn в базе данных Sqlite.

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

Регистрация новой учетной записи пользователя

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

Чтобы зарегистрироваться, просто введите свое имя и адрес электронной почты и нажмите кнопку «Регистрация». Затем внешний интерфейс отправит запрос POST на /api/user/register с введенным именем и адресом электронной почты, включенными в тело запроса.

Ниже вы видите реализацию функции регистрации, которая прикреплена к кнопке регистрации. При отправке сообщения в хранилище Vuex запрос POST отправляется на серверную часть.

async register() {
  await this.$store.dispatch('startRegistration', {username: this.email, name: this.name });
  const credentialInfo = await navigator.credentials.create({ publicKey: {...this.challenge}});
  const encodedCredentialInfo = utils.encodeCredentialInfoRequest(credentialInfo);
  await this.$store.dispatch('completeRegistration', encodedCredentialInfo);
}

Серверная часть создает и возвращает объект publicKeyCredentialCreationOptions, используя имя и адрес электронной почты. Затем внешний интерфейс передает этот объект в качестве аргумента API WebAuthn через функцию navigator.credentials.create.

При выполнении функции navigator.credentials.create отображается диалоговое окно регистрации WebAuthn. Тип диалогового окна зависит от используемой операционной системы и браузера. На ноутбуке с Windows отображается следующее диалоговое окно.

Выбрав «Внешний ключ безопасности или встроенный датчик», пользователь может зарегистрироваться с помощью Windows Hello или сканера отпечатков пальцев.

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

Если имя пользователя не существует, пользователь сохраняется в базе данных, запрос учетных данных создается и возвращается во внешний интерфейс.

userController.startRegistration = async (req, reply) => {
  const { username, name } = req.body;

  const userFromDb = await database.getUser(username);
  if (userFromDb && userFromDb.registered) {
    reply.badRequest(`Username ${userFromDb.username} already exists`);
    return;
  }

  const id = utils.randomBase64URLBuffer();
  await database.addUser(username, name, false, null, null, id);

  const makeCredChallenge = utils.generateServerMakeCredRequest(username, name, id);
  makeCredChallenge.status = "ok";

  req.session.username = username;
  req.session.challenge = makeCredChallenge.challenge;
  req.session.username = username;

  reply.send(makeCredChallenge);
};

После того, как пользователь завершит процесс регистрации с помощью внешнего ключа безопасности или внутреннего датчика, мы отправляем запрос PUT на серверную часть для завершения регистрации.

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

userController.finishRegistration = async (req, reply) => {
  const { id, rawId, response, type } = req.body;

  let result;

  if (type !== "public-key") {
    reply.badRequest({
      status: "error",
      message: "Registration failed! type is not public-key",
    });
    return;
  }

  const clientData = JSON.parse(base64url.decode(response.clientDataJSON));
  if (clientData.challenge !== req.session.challenge) {
    reply.badRequest({
      status: "error",
      message: "Registration failed! Challenges do not match",
    });
    return;
  }

  if (clientData.origin !== "http://localhost:8081") {
    reply.badRequest({
      status: "error",
      message: "Registration failed! Origins do not match",
    });
    return;
  }

  if (response.attestationObject !== undefined) {
    log.info(
      "Handling create credential request, storing information in database for: " +
        req.session.username
    );

    // This is a create credential request
    result = utils.verifyAuthenticatorAttestationResponse(response);

    if (result.verified) {
      await database.updateUser(req.session.username, true, result.authrInfo.fmt, 
        result.authrInfo.publicKey, result.authrInfo.credID);
    }
  } else {
    reply.badRequest("Cannot determine the type of response");
    return;
  }

  if (result.verified) {
    req.session.loggedIn = true;
    reply.send("Registration successfull");
    return;
  } else {
    reply.badRequest("Cannot authenticate signature");
    return;
  }
};

Когда процесс регистрации завершается без каких-либо ошибок, пользователь успешно зарегистрирован и может войти в приложение без пароля.

Вход с использованием зарегистрированной учетной записи

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

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

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

loginController.login = async (_req, reply) => {
  const { username } = _req.body;

  const user = await database.getUser(username)
  if (!user || !user.registered) {
    reply.badRequest(`User ${username} does not exist or is not registered`);
    return;
  }

  const authenticator = { fmt: user.fmt, publicKey: user.publicKey, credID: user.credID };
  const getAssertion = utils.generateServerGetAssertion(
    [authenticator]
  );
  getAssertion.status = "ok";

  _req.session.challenge = getAssertion.challenge;
  _req.session.username = username;

  reply.send(getAssertion);
};

Страница LoginUser.vue во внешнем интерфейсе вызывает метод navigator.credentials.get и передает вызов, возвращенный из внутреннего интерфейса, в качестве аргумента.

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

const credentialInfo = await navigator.credentials.get({publicKey: {...this.assertChallenge}});
const encodedCredentialInfo = utils.encodeCredentialInfoRequest(credentialInfo);
await this.$store.dispatch('verifyLogin', encodedCredentialInfo);

Последним этапом процесса является функция loginVerify в серверной части, которая получает подписанные учетные данные для проверки. См. метод контроллера ниже.

Функция контроллера сначала проверяет тип запроса, вызов и источник. Затем он извлекает пользователя из базы данных, используя его идентификатор, и передает информацию методу verifyAuthenticatorAssertionResponse.

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

loginController.loginVerify = async (_req, reply) => {
  const { id, response, type } = _req.body;

  let result;

  if (type !== "public-key") {
    reply.badRequest({
      status: "error",
      message: "Registration failed! type is not public-key",
    });
    return;
  }

  const clientData = JSON.parse(base64url.decode(response.clientDataJSON));
  if (clientData.challenge !== _req.session.challenge) {
    reply.badRequest({
      status: "error",
      message: "Registration failed! Challenges do not match",
    });
    return;
  }

  if (clientData.origin !== "http://localhost:8081") {
    reply.badRequest({
      status: "error",
      message: "Registration failed! Origins do not match",
    });
    return;
  }

  let user;
  if (response.authenticatorData !== undefined) {

    let user = await database.getUserByCredId(id)
    if (!user || !user.registered) {
      reply.badRequest(`User ${username} does not exist or is not registered`);
      return;
    }

    result = utils.verifyAuthenticatorAssertionResponse(
      id,
      response,
      [{ fmt: user.fmt, publicKey: user.publicKey, credID: user.credID }],
    );
  } else {
    reply.badRequest("Cannot determine the type of response");
    return;
  }

  if (result.verified) {
    const token = jwt.sign(id, config.jwt.secret);
    _req.session.loggedIn = true;
    reply.send({verification: true, token, message: "Login successfull", status: "ok"});
  } else {
    reply.badRequest({verification: false, message: "Cannot authenticate signature", status: "error"});
  }
};

Получив успешный ответ от серверной части, клиентская часть направляет пользователя на страницу панели инструментов.

На странице панели инструментов отображается список несуществующих клиентов, чтобы продемонстрировать просмотр защищенного списка. Внутренняя функция использует веб-маркер JSON (JWT) для проверки подлинности пользователя. См. страницу ниже.

После описанной реализации WebAuthn с использованием Node.js и Vue все, что осталось, — это предоставить инструкции по установке и запуску приложения, чтобы вы могли начать изучать и экспериментировать с ним самостоятельно.

Запуск и установка приложения

Для начала клонируйте репозиторий WebAuthnTest GitHub. Затем перейдите в папку server и запустите npm install. Повторите этот шаг в папке client.

Чтобы запустить приложение, сначала запустите сервер, перейдя в папку server и выполнив npm run. В другом терминале перейдите в папку client и запустите npm run serve. После этого веб-приложение будет доступно по адресу http://localhost:8081/.

Заключение

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

Реализация WebAuthn с помощью Vue и Node.js обеспечивает прочную основу для безопасных систем входа. Благодаря многочисленным преимуществам, включая устранение паролей, поддержку биометрической аутентификации и улучшенную защиту от фишинга, WebAuthn предлагает более безопасный и удобный процесс аутентификации.

WebAuthn поддерживается растущим числом браузеров, включая Google Chrome, Mozilla Firefox, Microsoft Edge (на базе Chromium) и Apple Safari. WebAuthn также поддерживается на некоторых мобильных платформах, включая Google Android 7 или более позднюю версию и Apple iOS 13.3 или более позднюю версию.

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

Надеюсь, вам понравилась статья, и вы рассмотрите возможность внедрения WebAuthn в свои приложения. Как всегда, дайте мне знать, если у вас есть какие-либо замечания или вопросы!