Пример биометрической аутентификации в веб-приложениях

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В довольно длинном и скучном посте описывается пример аутентификации пользователя в веб-приложениях при помощи биометрических средств (FaceID, отпечаток пальца), встроенных в мобильные телефоны. Код проекта - тут, рабочее демо - тут. Пример написан на чистом JavaScript и может быть отдебажен как на бэке (nodejs), так и в браузере.

КДПВ
КДПВ

Общий обзор

Если совсем по-простому, то в мобильном телефоне есть две программы - Браузер (в котором крутится веб приложение) и Аутентификатор, как часть ОС мобильного телефона (Android или iPhone). Аутентификатор умеет общаться с периферией мобильного телефона (камера и сканер отпечатка пальца), а Браузер умеет общаться с Аутентификатором (для этого предназначен WebAuthn API).

Браузер и Аутентификатор
Браузер и Аутентификатор

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

Аттестация и Подтверждение

Процесс аутентификации разбивается на два шага:

  • Аттестация (Attestation): бэкенд веб-приложения получает публичный ключ, созданный в мобильном телефоне, и сохраняет его в базе данных, привязывая к конкретному пользователю. Так как пользователь может иметь больше одного мобильного телефона, то и привязка телефона (публичного ключа) к пользователю идёт в соотношении “один ко многим” (один пользователь - много публичных ключей).

  • Подтверждение (Assertion): чтобы аутентифицировать пользователя, бэкенд создаёт вызов (challenge, случайную последовательность байтов) и отправляет его на фронт. Браузер фронта просит Аутентификатор подписать полученный вызов секретным ключом, после чего возвращает подпись бэку. Бэк проверяет подпись при помощи публичного ключа, сохранённого ранее, и аутентифицирует пользователя.

Аттестация и Подтверждение
Аттестация и Подтверждение

Виртуальный аутентификатор

Браузер Chrome предоставляет инструментарий для разработки веб-приложений, с использованием WebAuthn API. Включить виртуальный аутентификатор можно в “DevTools / Customize and control DevTools (triple-dots) / More tools / WebAuth”:

Enable WebAuth
Enable WebAuth

В списке доступных панелей появится панель аутентификатора. Я переместил у себя эту панель в нижнюю часть, но по-умолчанию она появляется среди основных панелей доступных инструментов. Чтобы виртуальный аутентификатор заработал, его предварительно нужно создать, выбрав в качестве транспорта “Internal” (с другими типами я не экспериментировал):

Панель WebAuthn
Панель WebAuthn

После перезапуска браузера нужно каждый раз включать виртуальный аутентификатор (Enable virtual authenticator environment). При этом созданные ранее учётные данные (credentials) теряются.

Аттестация

Создание вызова

Аттестация пользователя происходит либо при его регистрации (в этом случае бэкенд создаёт у себя нового пользователя), либо при подключении нового устройства (смартфона) к существующей учётке (бэкенд находит пользователя по его идентификатору - email’у, логину, …). 

  • Demo_Front_Ui_Route_Sign_Up

  • Demo_Front_Mod_User.register

  • Demo_Back_Web_Api_Sign_Up

В обоих случаях бэкенд должен сгенерировать уникальный вызов (challenge), привязанный к определённому пользователю, и отправить его на фронт:

  • Demo_Back_Web_Api_Sign_Up

  • Fl32_Auth_Back_Mod_PubKey.attestChallengeCreate

Типичный вызов выглядит примерно так:

{
    "challenge": "O-SjwzNHvaJrIMBILj7vaupmbSXqaSpzhBiMaiXtq-w",
    "uuid": "user@email.com"
}

Получение публичного ключа

После получения вызова с бэка фронт создаёт запрос к Аутентификатору на получение публичного ключа:

/** @type {PublicKeyCredential} */
const attestation = await navigator.credentials.create({publicKey});

Структура данных, передаваемых Аутентификатору, примерно такая:

{
  "publicKey": {
    "rp": {
      "name": "WebAuthn Demo"
    },
    "user": {
      "id": {/* binary data */},
      "name": "user@email.com",
      "displayName": "user@email.com"
    },
    "challenge": {/* binary data */},
    "pubKeyCredParams": [
      {
        "type": "public-key",
        "alg": -7
      }
    ],
    "timeout": 300000,
    "authenticatorSelection": {
      "authenticatorAttachment": "platform",
      "userVerification": "preferred"
    }
  }
}

В опциях указан алгоритм ECDSA (SHA-256), его код - “-7”.

  • Demo_Front_Ui_Route_Sign_Up

  • Fl32_Auth_Front_Mod_PubKey.composeOptPkCreate

Аутентификатор аутентифицирует пользователя любым доступным способом (по отпечатку пальца, через FaceID, при помощи графического ключа, …) и возвращает в браузер (фронту) учётные данные пользователя, включающие его публичный ключ (AttestationObject):

AttestationObject
AttestationObject

Сохранение публичного ключа на бэке

Аутентификатор оперирует бинарными данными. Для передачи их на бэк нужно закодировать бинарные данные в каком-либо текстовом формате, например Base64UrlEncoded:

{
  "cred": {
    "attestationId": "DDn8LhxnQB8g7qNKngMy-noDzSDIOyUMGg2soOeS6XA",
    "attestationObj": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikf6szqCHzhqRvtjZCcGybmpvrF7EKK4PpOd3KgOFc2VJFAAAAAQECAwQFBgcIAQIDBAUGBwgAIAw5_C4cZ0AfIO6jSp4DMvp6A80gyDslDBoNrKDnkulwpQECAyYgASFYIJ3Q9MQ0iOYg2HXVc6jO1wrIrmqhyOWAIu7G-QmMf9K0IlggF2qdOPRGQOPFyYOchDy-f2uqalA_NtSsk5Rqs85pN0U",
    "clientData": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiTFBJYlcyODdBZlVTeWRfNVlWUVJ4QjdSY1htVWY5Ym10NXBsNVZHbnllcyIsIm9yaWdpbiI6Imh0dHBzOi8vcGsuYXV0aC5kZW1vLnRlcWZ3LmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
  }
}
  • Demo_Front_Ui_Route_Sign_Up

  • Fl32_Auth_Front_Mod_PubKey.attest

  • Fl32_Auth_Back_Web_Api_Attest

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

  • cbor

  • parse-cosekey

Я извлекал публичный ключ в JWK (JSON Web Key) формате и сохранял его в БД в виде текста:

{
  "kty": "EC",
  "alg": "ES256",
  "crv": "P-256",
  "x": "1rSQKqnG0I3uSLaUPsCqEzdHAqDWYWajw3UrPiy4BuI",
  "y": "KhXxXe5uJPlSSlYBADbA-rt38_FtyuVK0Jv3wTzgBlk"
}

Публичный ключ привязывается к идентификатору аттестата (DDn…6XA), который затем используется при подтверждении аутентификации (assertion).

  • Fl32_Auth_Back_Web_Api_Attest

  • Fl32_Auth_Back_Util_WebAuthn.decodeAttestationObj

  • Fl32_Auth_Back_Util_WebAuthn.decodeAuthData

Подтверждение

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

Создание вызова

Чтобы получить с бэка вызов (challenge) фронт должен каким-то образом сообщить бэку идентификатор аттестата, с которым ассоциирован публичный ключ пользователя (в примере я сохраняю идентификатор в localStorage):

{"attestationId": "DDn8LhxnQB8g7qNKngMy-noDzSDIOyUMGg2soOeS6XA"}
  • Demo_Front_Ui_Route_Sign_In

  • Fl32_Auth_Front_Mod_PubKey.assertChallenge

  • Fl32_Auth_Back_Web_Api_Assert_Challenge

Бэк генерирует вызов и связывает его с публичным ключом, привязанным к идентификатору аттестата, после чего возвращает вызов фронту.

{
  "attestationId": "DDn8LhxnQB8g7qNKngMy-noDzSDIOyUMGg2soOeS6XA",
  "challenge": "K5YhDdqmaBUVHfJFAi50EcmcLW2n08mLcvxMlsDVEGI"
}
  • Fl32_Auth_Back_Web_Api_Assert_Challenge

  • Fl32_Auth_Back_Mod_PubKey.assertChallengeCreate

Генерация подписи

Фронт запрашивает генерацию подписи у Аутентификатора, указывая в опциях, идентификатор аттестата:

/** @type {PublicKeyCredential} */
const assertion = await navigator.credentials.get({publicKey});

Типовая структура опций:

{
  "publicKey": {
    "challenge": {/* binary */},
    "allowCredentials": [
      {
        "id": {/* binary */},
        "type": "public-key",
        "transports": [
          "internal"
        ]
      }
    ]
  }
}
  • Demo_Front_Ui_Route_Sign_In

  • Fl32_Auth_Front_Mod_PubKey.composeOptPkGet

Аутентификатор производит аутентификацию пользователя (по отпечатку, FaceID и т.п.), после чего при помощи закрытого ключа генерирует цифровую подпись и возвращает её в браузер в виде бинарных данных:

AssertionObject
AssertionObject

Прошу обратить внимание, что идентификатор подтверждения (assertion.id) совпадает с идентификатором аттестата (attestation.id) - "DDn8LhxnQB8g7qNKngMy-noDzSDIOyUMGg2soOeS6XA".

Проверка подписи

Бинарные данные подтверждения аутентификации кодируются в текстовый формат и отправляются на бэк:

{
    "authenticatorData": "f6szqCHzhqRvtjZCcGybmpvrF7EKK4PpOd3KgOFc2VIFAAAAAg",
    "clientData": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiSXNpTGNpMlZaR1FHVE1KVlZVZTlONFI3WWt3bFd2WDFwZ1FaZDFaOTZoWSIsIm9yaWdpbiI6Imh0dHBzOi8vcGsuYXV0aC5kZW1vLnRlcWZ3LmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
    "signature": "MEQCIBJjnmwRNzbE66R_CAdFiu2yklp4-Sindxxjxt8BUdL4AiB-0Mf7hd4t5jCk3ZDjAbcw-1DhLQQ0KHhhC0PSQaJQsA"
  }
  • Demo_Front_Ui_Route_Sign_In

  • Fl32_Auth_Front_Mod_PubKey.validate

  • Fl32_Auth_Back_Web_Api_Assert_Validate

На бэке данные преобразовываются обратно в бинарный формат, из них извлекаются клиентские данные, содержащие вызов:

{
  "type": "webauthn.get",
  "challenge": "R92jR_9v-33od9Yiea0RBWABjICbLjeQ1CXVBRo7X7M",
  "origin": "https://pk.auth.demo.teqfw.com"
}

Через вызов в базе находится публичный ключ пользователя и проверяется электронная подпись.

  • Fl32_Auth_Back_Web_Api_Assert_Validate

  • Fl32_Auth_Back_Mod_PubKey.assertValidate

  • Fl32_Auth_Back_Util_WebAuthn.decodeClientDataJSON

  • Fl32_Auth_Back_Util_WebAuthn.asn1toRaw

Резюме

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

Источник: https://habr.com/ru/articles/740000/


Интересные статьи

Интересные статьи

Когда вы пишете библиотеку, которая в дальнейшем будет задействована во множестве других проектов, крайне важно продумать, как именно разработчики будут работать с ней своем коде.Один из лучших способ...
Слово креативность слышал каждый первый работник сферы IT и маркетинга. И ладно бы слышал! Часто эту самую креативность заставляют проявлять. Что делать, если ты не очень креативный человек? Как приду...
Наша биг дата проанализировала Telegram-чаты, форумы и разговоры в кулуарах IT-мероприятий и пометила объектные хранилища как инструмент, который ещё не все осмеливаются использовать в своих проекта...
Расскажу про типичные подходы к вычислению данных, для построения графиков. Про "лайфхаки" ускорения вычислений и про работу различных типов данных в JavaScript, а главно...
Всем доброго здравия! В преддверии Нового года хочу продолжить рассказывать про использование С++ на микроконтроллерах, на этот раз попытаюсь рассказать про использование шаблона Наблюдатель (...