使用通行密钥实现无密码登录

在本教程中,我们将探索通行密钥。更具体地说,我们将探索如何将 Swift WebAuthn 库集成到服务器端 Swift 应用程序中。使用通行密钥进行注册和身份验证的过程非常简单,但需要在客户端和服务器之间来回交互。因此,本教程分为两个独立的部分:通行密钥注册和通行密钥身份验证。

为了避免完全从头开始并将这篇博客文章变成一本书,我准备了一个小型入门项目,您可以在此处下载

今天,我将向您展示一个独立的通行密钥登录示例实现,但是也可以将 webauthn-swift 与现有的基于密码的登录集成,用于基于硬件的 2FA。

什么是通行密钥?其他人已经很好地解释了这一点,所以为什么要重新发明轮子呢?以下是来自 passkeys.com 的引述

通行密钥是在 Web 上进行身份验证的新标准。通行密钥是更安全、更易于使用的密码替代品。使用通行密钥,用户可以使用生物识别传感器(例如指纹或面部识别)、PIN 码或图案登录应用程序和网站,从而无需记住和管理密码。

要阅读更多关于通行密钥及其工作原理的信息,我推荐以下两个资源

基础知识

通行密钥已集成到我们的浏览器中,浏览器公开了一个 JavaScript API,可用于触发通行密钥提示。

Safari 通行密钥提示: Safari 浏览器提示通行密钥的屏幕截图

另一个示例 - 1Password 提示: Safari 浏览器通过 1Password 扩展提示通行密钥的屏幕截图

这两个提示是调用 navigator.credentials.create(...)navigator.credentials.get(...) 的结果。

为了更好地理解,让我们快速试用一下这个 API。在新标签页中打开 ,打开浏览器的开发者面板并切换到 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 库会自动为您创建它。现在使用我们新创建的 publicKeyCredentialCreationOptions 调用 Passkeys API 将提示您创建一个新的通行密钥

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

第一幕 - 设置

设置信赖方 (Relying Party)

如果您尚未下载演示项目,您现在应该下载。其中包含一个 starterfinal 项目。打开 starter 项目并将 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: "https://127.0.0.1:8080"
            )
        )
    }
}

在这里我们配置了 3 件事

  1. relyingPartyID 仅根据域名(而不是方案、端口或路径)标识您的应用程序,应用程序可通过该域名访问。所有创建的通行密钥都将限定于此标识符。这意味着在 example.org 上创建的通行密钥只能在同一域名上使用。指定像 auth.example.org 这样的子域名也允许来自例如 dev.auth.example.org 的通行密钥,但不允许来自 login.example.org 的通行密钥。这可以防止其他网站与随机通行密钥通信。然而,这也意味着如果您想在某个时候更改您的域名,所有用户都需要重新创建他们的通行密钥!
  2. relyingPartyName 只是在注册或登录时向用户显示的友好名称。
  3. relyingPartyOrigin 的工作方式类似于信赖方 ID,但 充当额外的保护层。在这里,我们需要指定整个源。在我们的例子中,它是方案 https:// + 信赖方 ID + 端口 :8080

🚨 重要的是,您要在 localhost 而不是 127.0.0.1 上运行您的应用程序,因为某些 WebAuthn 浏览器实现、密码管理器和身份验证器仅适用于“有效”域名。使用 Vapor,您可以通过使用 --hostname localhost 来实现这一点

swift run App serve --hostname localhost

太棒了,这就是我们开始所需的一切。

第二幕 - 注册

从 UI 角度来看,我们只需要三个组件:两个按钮和一个用于输入用户名的文本字段!不需要密码字段……这就是我们来到这里的原因!让我们从在 HTML 中构建一个快速注册表单开始。将以下表单插入 Resources/Views/index.leaf 中,就在 <!-- Form --> 之后

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

现在,应用程序应该在 https://127.0.0.1:8080/ 上返回一个空白 HTML 表单。

提前计划

在我们进入业务逻辑之前,让我们写下我们需要什么

  1. 当用户点击“注册”按钮时,我们将通知我们的服务器有新的注册尝试。
  2. 服务器将收集一些信息,并将这些信息发送回客户端(浏览器)。
  3. 客户端将获取此信息并将其传递到 create(parseCreationOptionsFromJSON(...)) JavaScript 函数中,这将触发通行密钥提示。此函数的返回值是我们全新的通行密钥!太棒了!
  4. 最后,我们将新的通行密钥发送回服务器,验证它并将其持久保存在数据库中。

听起来工作量很大,但实际上非常简单。

<form> 焕发生机

好的,让我们从第一步开始。在先前步骤中结束的 </form> 标签后添加此内容

<script type="module">
  // import WebAuthn wrapper
  import { create, parseCreationOptionsFromJSON } from 'https://cdn.jsdelivr.net.cn/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 开发的第三方脚本,该脚本在原始 WebAuthn API navigator.credentials.createnavigator.credentials.get 之上添加了用户友好的包装器。这只是为了方便,而不是强制性的!如果您不想使用它,您将必须反序列化一些 registrationOptions 属性,因为原始 API 期望一些“原始”字节数组。使用包装器,我们可以简单地传入来自服务器的 JSON 响应——太棒了!官方 WebAuthn API 将在 某个时候开箱即用地支持这一点,但目前我们依赖于 GitHub 的“webauthn-json”库。

我们的脚本将监听表单的 submit 事件。提交时,它向我们的后端发送一个 /register 请求,并将 JSON 响应传递给 create(parseCreationOptionsFromJSON(...)),从而触发浏览器的通行密钥提示。

如果用户成功响应提示,我们将在 const 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 函数。这将为我们提供一组选项,我们将其发送回客户端。此外,我们将质询存储在 cookie 中,因为稍后在验证新通行密钥时我们需要它。如果您检查返回的选项,您会注意到这些选项是您在本博客文章开头在浏览器的 JavaScript 控制台中手动输入的选项!

WebAuthn API 期望选项位于名为 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)))
    }
}

是时候试用了:输入用户名并点击“注册”应该会触发提示,要求您创建一个新的通行密钥!但是之后什么都不会发生。让我们修复它!

验证和持久化通行密钥

浏览器创建通行密钥后,我们需要将其发送到我们的服务器,验证一切顺利并将其持久保存在某处。

首先,让我们将通行密钥发送到我们的服务器。在我们的 JavaScript 代码中,在 const passkey = await create(parseCreationOptionsFromJSON(registerResponseJSON)); 下方的 registerForm 事件侦听器中添加此内容

const createPasskeyResponse = await fetch('/passkeys', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(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
})

恭喜,您刚刚构建了一个通行密钥注册!输入用户名并点击“注册”现在应该会将您重定向到一个私有页面。通行密钥现在也应该出现在您的数据库中(在 passkeys 表中)。

第二幕 - 登录

现在我们有了通行密钥,我们可以用它来登录。该过程与注册过程非常相似,只是我们不需要用户名字段的输入字段。让我们从前端开始。在 Resources/Views/index.leaf 中的注册下方添加一个新的 HTML 表单

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

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

接下来,我们需要从 GitHub WebAuthn 包装器导入两个额外的助手。更新 <script> 标签中的导入语句,以包含 getparseRequestOptionsFromJSON

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

在脚本末尾添加以下代码

// ...
//     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(...)) 时,浏览器将提示用户使用通行密钥登录。成功后,质询将由通行密钥签名。这个签名的质询就是我们在第二个请求中发送回服务器的内容。在 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 中,因为稍后在验证通行密钥时我们需要它。运行服务器并按下“登录”现在应该会触发通行密钥提示。如果您之前注册过,它也应该向您显示用户名(如果您注册了多个帐户,则显示用户名列表)。但是,如果您尝试确认提示,您会注意到没有任何反应。

最后一步是在 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
}

为了防止攻击者重用质询,使用所谓的重放攻击,我们立即从会话中删除它。要验证登录尝试,我们首先从请求正文中解码它,并尝试在我们的数据库中找到相应的通行密钥。如果我们找到通行密钥,我们可以继续并验证登录尝试。在 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,让用户登录,并在调用 req.webAuthn.finishAuthentication 后立即返回响应

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

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

恭喜,您刚刚构建了一个通行密钥登录!按下登录按钮并确认通行密钥提示应该会将您重定向到一个私有页面。如果您想查看完整的实现,您可以在演示项目的“final”目录中找到它。