Spec-Zone.ru › Swift

Переход на безпарольную авторизацию с Passkeys

В этом руководстве мы рассмотрим Passkeys. Более конкретно, мы изучим, как интегрировать библиотеку Swift WebAuthn Swift WebAuthn library в приложение Swift на стороне сервера. Процесс регистрации и аутентификации с использованием Passkeys довольно простой, но требует обмена данными между клиентом и сервером. Поэтому это руководство разделено на две части: регистрация Passkey и аутентификация Passkey.

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

Сегодня я покажу вам пример реализации самостоятельного входа с Passkey, однако также возможно интегрировать webauthn-swift в существующую систему входа на основе паролей для аппаратной двухфакторной аутентификации (2FA).

Что такое Passkeys? Другие уже хорошо объяснили это, так зачем изобретать велосипед? Вот цитата с passkeys.com:

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

Для получения дополнительной информации о Passkeys и их работе я рекомендую следующие два ресурса:

  • Введение: https://webauthn.guide
  • Подробности: https://w3c.github.io/webauthn
  • Документация Apple Developer: https://developer.apple.com/passkeys

Основы

Passkeys интегрированы в наши браузеры, которые предоставляют JavaScript API, который можно использовать для запуска запросов Passkey.

Предложение Passkey в Safari: Screenshot of Safari browser prompting for a Passkey

Еще один пример — предложение 1Password: Screenshot of Safari browser prompting for a Passkey through the 1Password extension

Эти два предложения являются результатом вызова navigator.credentials.create(...) и navigator.credentials.get(...).

Для лучшего понимания давайте быстро поиграем с этим API. Откройте Swift.org в новой вкладке, откройте панель разработчика своего браузера и переключитесь на консоль JavaScript. Создайте следующую переменную:

const publicKeyCredentialCreationOptions = {
    challenge: Uint8Array.from(
        "randomStringFromServer", c => c.charCodeAt(0)),
    rp: {
        name: "Swift",
        id: "swift.org",
    },
    user: {
        id: Uint8Array.from(
            "UZSL85T9AFC", c => c.charCodeAt(0)),
        name: "me@example.com",
        displayName: "FooBar",
    },
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform",
    },
    timeout: 60000,
    attestation: "direct"
};

Не беспокойтесь, вам не нужно понимать содержимое. На самом деле библиотека Swift WebAuthn создаст это автоматически. Теперь вызовите API Passkeys с нашей только что созданной publicKeyCredentialCreationOptions, чтобы получить запрос на создание нового Passkey:

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

Шаг 1 — Настройка

Настройка доверенной стороны

Если вы еще не скачали демо-проект, сделайте это сейчас. Есть проект starter и final. Откройте стартовый проект и добавьте библиотеку Swift WebAuthn в свой Package.swift:

dependencies: [
    // ...
    .package(url: "https://github.com/swift-server/webauthn-swift.git", from: "1.0.0-alpha")
],

// ...

targets: [
    .target(
        name: "App",
        dependencies: [
            // ...
            .product(name: "WebAuthn", package: "webauthn-swift")
// ...
]

Сначала вам нужно создать экземпляр WebAuthnManager — ядра библиотеки Swift WebAuthn. Библиотека WebAuthn работает с любым фреймворком Swift на стороне сервера, но в этом руководстве мы будем использовать Vapor. С Vapor вы можете расширить Request свойством webAuthn, что позволит нам легко получить к нему доступ в обработчиках маршрутов. Добавьте это в новый файл под названием Request+webAuthn.swift:

import Vapor
import WebAuthn

extension Request {
    var webAuthn: WebAuthnManager {
        WebAuthnManager(
            config: WebAuthnManager.Config(
                // 1
                relyingPartyID: "localhost",
                // 2
                relyingPartyName: "Vapor Passkey Tutorial",
                // 3
                relyingPartyOrigin: "http://localhost:8080"
            )
        )
    }
}

Здесь мы настраиваем три вещи:

  1. relyingPartyID идентифицирует ваше приложение исключительно по домену (без схемы, порта или пути), к которому оно доступно. Все созданные Passkeys будут ограничены этим идентификатором. Это означает, что Passkey, созданный на example.org, можно использовать только на том же домене. Указание поддомена, например, auth.example.org, также позволит использовать Passkeys с, например, dev.auth.example.org, но не с login.example.org. Это предотвращает доступ к случайным Passkeys с других веб-сайтов. Однако это также означает, что если вы хотите изменить свой домен, всем пользователям нужно будет пересоздать свои Passkeys!
  2. relyingPartyName — просто дружественное имя, отображаемое пользователю при регистрации или входе.
  3. relyingPartyOrigin работает аналогично идентификатору доверенной стороны, но служит дополнительным уровнем защиты. Здесь нам нужно указать весь origin. В нашем случае это схема https:// + идентификатор доверенной стороны + порт :8080

🚨 Важно, чтобы вы запускали приложение на localhost, а не на 127.0.0.1, так как некоторые реализации WebAuthn в браузерах, менеджерах паролей и аутентификаторах работают только с «валидными» доменами. С Vapor вы можете добиться этого с помощью --hostname localhost:

swift run App serve --hostname localhost

Отлично, этого достаточно для начала.

Шаг 2 — Регистрация

С точки зрения пользовательского интерфейса нам нужны только три компонента: две кнопки и текстовое поле для ввода имени пользователя! Поле для пароля не нужно… именно поэтому мы здесь!

Давайте начнём с создания быстрого регистрационной формы в HTML. Вставьте следующую форму в Resources/Views/index.leaf сразу после <!-- Form -->:

<form id="registerForm">
    <input id="username" type="text" />
    <button type="submit">Register</button>
</form>

Приложение теперь должно отобразить пустую HTML-форму по адресу http://localhost:8080/.

Планирование на будущее

Прежде чем перейти к бизнес-логике, давайте запишем, что нам нужно:

  1. При нажатии пользователем кнопки «Зарегистрироваться» мы сообщим нашему серверу о новой попытке регистрации.
  2. Сервер соберет несколько фрагментов информации и отправит их обратно клиенту (браузеру).
  3. Клиент получит эту информацию и передаст её в JavaScript-функцию create(parseCreationOptionsFromJSON(...)), которая запустит запрос Passkey. Возвращаемое значение этой функции — наш новый Passkey! Отлично!
  4. Наконец, мы отправим новый Passkey на сервер, проверим его и сохраним в базе данных.

Звучит много работы, но на самом деле всё довольно просто.

Оживление формы <form>

Итак, давайте начнём с первого шага. Добавьте это после закрывающей </form> тега из предыдущего шага:

<script type="module">
  // import WebAuthn wrapper
  import { create, parseCreationOptionsFromJSON } from 'https://cdn.jsdelivr.net/npm/@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js';

  // Get a reference to our registration form
  const registerForm = document.getElementById("registerForm");

  // Listen for the form's "submit" event
  registerForm.addEventListener("submit", async function(event) {
    event.preventDefault();

    // Get the username
    const username = document.getElementById("username").value;

    // Send request to server
    const registerResponse = await fetch('/register?username=' + username);

    // Parse response as json and pass into wrapped WebAuthn API
    const registerResponseJSON = await registerResponse.json();
    const passkey = await create(parseCreationOptionsFromJSON(registerResponseJSON));
  });
</script>

Сначала мы добавим сторонний скрипт, разработанный GitHub, который добавляет удобные оболочки поверх оригинальных API WebAuthn navigator.credentials.create и navigator.credentials.get. Это просто для удобства и не является обязательным! Если вы не хотите его использовать, вам придётся десериализовать некоторые свойства registrationOptions, поскольку оригинальный API ожидает несколько «сырых» массивов байтов. Используя оболочку, мы можем просто передать JSON-ответ от нашего сервера — удобно! Официальное API WebAuthn поддержит это в будущем, но пока мы зависим от библиотеки «webauthn-json» GitHub.

Наш скрипт будет прослушивать событие submit формы. При отправке он отправит запрос /register на наш бэкенд и передаст JSON-ответ в create(parseCreationOptionsFromJSON(...)), тем самым запустив запрос Passkey в браузере.

Если пользователь успешно ответит на запрос, мы получим новый Passkey в const passkey. Позже мы отправим этот Passkey на сервер и проверим его. На стороне сервера нам всё ещё нужно добавить конечную точку, которую мы только что вызывали в JavaScript-коде. В приложении Vapor вам необходимо зарегистрировать новый маршрут в routes.swift:

app.get("register") { req in
    // Create and login user
    let username = try req.query.get(String.self, at: "username")
    let user = User(username: username)
    try await user.create(on: req.db)
    req.auth.login(user)

    // Generate registration options
    let options = req.webAuthn.beginRegistration(user:
        .init(
            id: try [UInt8](user.requireID().uuidString.utf8),
            name: user.username,
            displayName: user.username
        )
    )

    // Also pass along challenge because we need it later
    req.session.data["registrationChallenge"] = Data(options.challenge).base64EncodedString()

    return CreateCredentialOptions(publicKey: options)
}

В /register это создаёт нового пользователя и вызывает функцию beginRegistration с только что созданным пользователем. Это даст нам набор параметров, которые мы отправим клиенту. Кроме того, мы сохраним challenge в cookie, поскольку нам понадобится он позже при проверке нового Passkey. Если вы посмотрите на возвращённые параметры, вы заметите, что это те параметры, которые вы вручную ввели в консоли JavaScript браузера в начале этого руководства!

API WebAuthn ожидает параметры внутри свойства под названием publicKey. Поэтому мы возвращаем экземпляр CreateCredentialOptions — тип, которого ещё нет. Давайте создадим его и сделаем совместимым с AsyncResponseEncodable, чтобы мы могли легко возвращать его в обработчике маршрута Vapor:

struct CreateCredentialOptions: Encodable, AsyncResponseEncodable {
    let publicKey: PublicKeyCredentialCreationOptions

    func encodeResponse(for request: Request) async throws -> Response {
        var headers = HTTPHeaders()
        headers.contentType = .json
        return try Response(status: .ok, headers: headers, body: .init(data: JSONEncoder().encode(self)))
    }
}

Давайте попробуем: ввод имени пользователя и нажатие «Зарегистрировать» должно запустить запрос, в котором вас попросят создать новый Passkey! Однако после этого ничего не произойдёт. Давайте это исправим!

Проверка и сохранение Passkey

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

Сначала давайте отправим Passkey на сервер. В нашем JavaScript-коде добавьте это сразу под const passkey = await create(parseCreationOptionsFromJSON(registerResponseJSON)); в обработчике события registerForm:

const createPasskeyResponse = await fetch('/passkeys', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(passkey)
});

На сервере мы сначала получаем пользователя, для которого хотим зарегистрировать ключ Passkey. Затем мы декодируем ключ Passkey из тела запроса и проверяем его. Если все прошло хорошо, мы можем сохранить ключ Passkey в нашей базе данных. Добавьте эту логику в новый POST /register конечную точку:

// Example implementation for a Vapor app
app.post("register", use: { req in
    // Obtain the user we're registering a credential for
    let user = try req.auth.require(User.self)

    // Obtain the challenge we stored for this session
    guard let challengeEncoded = req.session.data["registrationChallenge"],
        let challenge = Data(base64Encoded: challengeEncoded) else {
        throw Abort(.badRequest, reason: "Missing registration challenge")
    }

    // Delete the challenge to prevent attackers from reusing it
    req.session.data["registrationChallenge"] = nil

    // Verify the credential the client sent us
    let credential = try await req.webAuthn.finishRegistration(
        challenge: [UInt8](challenge),
        credentialCreationData: req.content.decode(RegistrationCredential.self),
        confirmCredentialIDNotRegisteredYet: { _ in true}
    )

    try await Passkey(
        id: credential.id,
        publicKey: credential.publicKey.base64URLEncodedString().asString(),
        currentSignCount: credential.signCount,
        userID: user.requireID()
    ).save(on: req.db)

    return HTTPStatus.ok
})

Поздравляем, вы только что создали регистрацию ключа Passkey! Ввод имени пользователя и нажатие «Зарегистрировать» теперь должны перенаправить вас на частную страницу. Ключ Passkey также должен появиться в вашей базе данных (в таблице passkeys) сейчас.

Действие 2 — Вход

Теперь, когда у нас есть ключ Passkey, мы можем использовать его для входа. Процесс очень похож на процесс регистрации, за исключением того, что нам не нужно поле ввода для имени пользователя. Давайте начнем с фронта. Добавьте новую форму HTML ниже регистрации в Resources/Views/index.leaf:

</form>
<!-- End of registration form -->

<form id="loginForm">
    <button type="submit">Login</button>
</form>

Далее нам нужно импортировать две дополнительные вспомогательные функции из обертки GitHub WebAuthn. Обновите оператор импорта в теге <script>, чтобы включить get и parseRequestOptionsFromJSON:

import { create, get, parseCreationOptionsFromJSON, parseRequestOptionsFromJSON } from 'https://cdn.jsdelivr.net.....

В конце скрипта добавьте следующий код:

// ...
//     location.href = "/private";
// });

// Get a reference to our login form
const loginForm = document.getElementById("loginForm");

// Listen for the form's "submit" event
loginForm.addEventListener("submit", async function(event) {
  event.preventDefault();
  // Send request to Vapor app
  const loginResponse = await fetch('/login');
  // Parse response as json and pass into wrapped WebAuthn API
  const loginResponseJSON = await loginResponse.json();
  const loginAttempt = await get(parseRequestOptionsFromJSON(loginResponseJSON));
});

Подобно регистрации, мы прослушиваем событие submit формы. При отправке мы отправляем запрос /login на наш бэкенд. Ответ содержит несколько опций и случайный сгенерированный вызов. При передаче этих данных в get(parseRequestOptionsFromJSON(...)) браузер запросит у пользователя вход с помощью ключа Passkey. В случае успеха вызов будет подписан ключом Passkey. Этот подписанный вызов — то, что мы отправляем обратно на сервер во втором запросе. Добавьте это сразу после const loginAttempt = await get(parseRequestOptionsFromJSON(loginResponseJSON));:

// Send passkey to Vapor app
const loginAttemptResponse = await fetch('/login', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify(loginAttempt)
});

// Redirect to private page
location.href = "/private";

Это отправит попытку входа с подписанным вызовом на наш сервер и перенаправит пользователя на частную страницу, если все прошло хорошо. Теперь давайте реализуем серверную часть. Сначала добавьте конечную точку, которая обрабатывает запрос GET /login, возвращая опции и случайно сгенерированный вызов:

app.get("login") { req in
    // Generate registration options
    let options = try req.webAuthn.beginAuthentication()
    // Also pass along challenge because we need it later
    req.session.data["authChallenge"] = Data(options.challenge).base64EncodedString()
    return RequestCredentialOptions(publicKey: options)
}

Кроме того, мы сохраняем вызов в cookie, потому что нам понадобится он позже при проверке ключа Passkey. Запуск сервера и нажатие «Вход» теперь должны вызвать запрос Passkey. Если вы ранее зарегистрировались, он также должен показать вам имя пользователя (или список имен пользователей, если вы зарегистрировали более одной учетной записи). Однако, если вы попытаетесь подтвердить запрос, ничего не произойдет.

Последним шагом будет проверка попыток входа в конечной точке POST /login. Начните с добавления конечной точки и извлечения вызова из сеанса пользователя:

app.post("login") { req in
    // Obtain the challenge we stored on the server for this session
    guard let challengeEncoded = req.session.data["authChallenge"],
        let challenge = Data(base64Encoded: challengeEncoded) else {
        throw Abort(.badRequest, reason: "Missing authentication challenge")
    }

    req.session.data["authChallenge"] = nil
}

Чтобы предотвратить повторное использование вызова злоумышленниками, с помощью так называемой атаки повтора, мы сразу удаляем его из сеанса. Для проверки попытки входа мы сначала декодируем её из тела запроса и пытаемся найти соответствующий ключ Passkey в нашей базе данных. Если мы найдём ключ Passkey, мы можем продолжить и проверить попытку входа. Добавьте это ниже req.session.data["authChallenge"] = nil:

let authenticationCredential = try req.content.decode(AuthenticationCredential.self)

guard let credential = try await Passkey.query(on: req.db)
    .filter(\.$id == authenticationCredential.id.urlDecoded.asString())
    .with(\.$user)
    .first() else {
    throw Abort(.unauthorized)
}

let verifiedAuthentication = try req.webAuthn.finishAuthentication(
    credential: authenticationCredential,
    expectedChallenge: [UInt8](challenge),
    credentialPublicKey: [UInt8](URLEncodedBase64(credential.publicKey).urlDecoded.decoded!),
    credentialCurrentSignCount: credential.currentSignCount
)

Наконец, если webAuthn.finishAuthentication возвращается без выброса ошибки, мы знаем, что попытка входа была успешной. Теперь мы можем обновить currentSignCount ключа Passkey, войти в систему пользователю и вернуть ответ сразу после вызова req.webAuthn.finishAuthentication:

credential.currentSignCount = verifiedAuthentication.newSignCount
try await credential.save(on: req.db)

req.auth.login(credential.user)
return HTTPStatus.ok

Поздравляем, вы только что создали вход с ключом Passkey! Нажатие кнопки входа и подтверждение запроса Passkey должны перенаправить вас на частную страницу. Если вы хотите увидеть полную реализацию, вы можете найти её в директории «final» демонстрационного проекта .

The Swift Programming Language, Copyright © 2014-2025 Apple Inc.
Swift and the Swift logo are trademarks of Apple Inc.

Documentation for Swift 6.0.3


https://www.swift.org/documentation/server/guides/passkeys.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API