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

В довольно длинном и скучном посте описывается пример аутентификации пользователя в веб-приложениях при помощи биометрических средств (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«у, логину, …). 

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

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

{
    "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».

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

AttestationObject

AttestationObject

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

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

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

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

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

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

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

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

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

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

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

{"attestationId": "DDn8LhxnQB8g7qNKngMy-noDzSDIOyUMGg2soOeS6XA"}

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

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

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

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

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

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

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

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

AssertionObject

AssertionObject

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

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

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

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

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

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

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

Резюме

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

© Habrahabr.ru