firehed/webauthn

支持密钥和Web认证

dev-main 2024-07-16 18:31 UTC

README

超越密码的方法

Test Static analysis Lint codecov

在您的PHP应用程序中支持密钥和WebAuthn

这个库将帮助您的PHP应用程序准备好支持密钥和WebAuthn。它处理客户端数据的处理和密码学验证,并协助凭证存储和检索。

还需要执行大量的客户端工作。提供了许多示例,但您需要熟悉WebAuthn规范和浏览器API。

提示

想要托管选项? SnapAuth 将在几分钟内让您上线。客户端和服务器集成都在几行代码内完成。

什么是Web认证?

Web认证,通常称为 WebAuthn,是一套技术和API,用于使用现代密码学提供用户认证。与密码和散列不同,WebAuthn 允许用户生成加密密钥对,将公钥提供给服务器,并使用永远不在他们手中的私钥通过签名服务器生成的挑战来进行认证。

这意味着服务器 永远不接触敏感数据,并且在发生泄露时无法泄露认证信息。这也意味着用户不必管理个人网站的密码,而可以依靠操作系统、浏览器和硬件安全密钥提供的工具。

使用此库:快速入门

这将涵盖将此库集成到您的Web应用程序中的基本工作流程。

注意

在此文档中,关键词“MUST”,“MUST NOT”,“REQUIRED”,“SHALL”,“SHALL NOT”,“SHOULD”,“SHOULD NOT”,“RECOMMENDED”,“NOT RECOMMENDED”,“MAY”和“OPTIONAL”的词义应按照BCP 14 [RFC2119] [RFC8174]的描述进行解释,并且仅在它们全部大写,如所示时才适用。

示例代码

examples 目录中有一套完整的工作示例。为了突出最重要的工作流程步骤,应用程序逻辑被保持在最低限度。

安装

composer require firehed/webauthn

设置

创建一个 RelyingPartyInterface 实例。有关选择实现的更多信息,请参阅 Relying Party

$rp = new \Firehed\WebAuthn\SingleOriginRelyingParty('https://www.example.com');

还要创建一个 ChallengeManagerInterface。这将存储和验证WebAuthn协议中至关重要的一次性使用挑战。有关更多信息,请参阅下面的 Challenge Management 部分。

session_start();
$challengeManager = new \Firehed\WebAuthn\SessionChallengeManager();

重要

WebAuthn 只能在“安全上下文”中工作。这意味着域名必须通过 https 运行,唯一的例外是 localhost。有关更多信息,请参阅 https://mdn.org.cn/en-US/docs/Web/Security/Secure_Contexts

将WebAuthn凭证注册到用户

此步骤发生在用户首次注册时,或稍后以补充或替换其密码。

  1. 创建一个端点,该端点将返回一个新的、随机的挑战。将其作为base64发送给用户。
<?php

// Generate and manage challenge
$challenge = \Firehed\WebAuthn\ExpiringChallenge::withLifetime(300);
$challengeManager->manageChallenge($challenge);

// Send to user
header('Content-type: application/json');
echo json_encode($challenge->getBase64());
  1. 在客户端JavaScript代码中,读取挑战并将其提供给WebAuthn API。您还需要注册用户的标识符和某种类型的用户名。
// See https://www.w3.org/TR/webauthn-2/#sctn-sample-registration for a more annotated example

if (!window.PublicKeyCredential) {
    // Browser does not support WebAuthn. Exit and fall back to another flow.
    return
}

// This comes from your app/database, fetch call, etc. Depending on your app's
// workflow, the user may or may not have a password (which isn't relevant to WebAuthn).
const userInfo = {
    name: 'Username', // chosen name or email, doesn't really matter
    id: 'abc123', // any unique id is fine; uuid or PK is preferable
}

const response = await fetch('/readmeRegisterStep1.php')
const challengeB64 = await response.json()
const challenge = atob(challengeB64) // base64-decode

const createOptions = {
    publicKey: {
        rp: {
            name: 'My website',
        },
        user: {
            name: userInfo.name,
            displayName: 'User Name',
            id: Uint8Array.from(userInfo.id, c => c.charCodeAt(0)),
        },
        challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
        pubKeyCredParams: [
            {
                alg: -7, // ES256
                type: "public-key",
            },
        ],
    },
    attestation: 'direct',
}

// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.create(createOptions)

// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.

const dataForResponseParser = {
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
    clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
    transports: credential.response.getTransports(),
}

// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeRegisterStep3.php', {
    body: JSON.stringify(dataForResponseParser),
    headers: {
        'Content-type': 'application/json',
    },
    method: 'POST',
})
const result = await fetch(request)
// handle result, update user with status if desired.
  1. 解析和验证响应,如果成功,则将其与用户关联。

注意

可以在认证过程中查找和使用publicKey.user.id字段。

<?php

use Firehed\WebAuthn\{
    Codecs,
    ArrayBufferResponseParser,
};

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ArrayBufferResponseParser();
$createResponse = $parser->parseCreateResponse($data);

try {
    // $challengeManager and $rp are the values from the setup step
    $credential = $createResponse->verify($challengeManager, $rp);
} catch (Throwable) {
    // Verification failed. Send an error to the user?
    header('HTTP/1.1 403 Unauthorized');
    return;
}

// Store the credential associated with the authenticated user. See
// "Registration & Credential Storage" in the README for more info.

$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($credential);
$pdo = getDatabaseConnection();
$stmt = $pdo->prepare('INSERT INTO credentials (storage_id, user_id, credential) VALUES (:storage_id, :user_id, :encoded);');
$result = $stmt->execute([
    'storage_id' => $credential->getStorageId(),
    'user_id' => $user->getId(), // $user comes from your authn process
    'encoded' => $encodedCredential,
]);

// Continue with normal application flow, error handling, etc.
header('HTTP/1.1 200 OK');
  1. 没有第4步。已验证的凭据现在已存储并关联到用户!

使用现有WebAuthn凭据认证用户

开始之前,您需要收集尝试认证的用户的用户名或id,并从存储中检索用户信息。这假设与之前的注册示例相同的架构。

  1. 创建一个端点,它将返回挑战和与正在认证的用户关联的任何凭据。
<?php

use Firehed\WebAuthn\Codecs;

session_start();

$pdo = getDatabaseConnection();
$user = getUserByName($pdo, $_POST['username']);
if ($user === null) {
    header('HTTP/1.1 404 Not Found');
    return;
}
$_SESSION['authenticating_user_id'] = $user['id'];

// See examples/functions.php for how this works
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);

// Generate and manage challenge
$challenge = \Firehed\WebAuthn\ExpiringChallenge::withLifetime(300);
$challengeManager->manageChallenge($challenge);

// Send to user
header('Content-type: application/json');
echo json_encode([
    'challengeB64' => $challenge->getBase64(),
    'credential_ids' => $credentialContainer->getBase64Ids(),
]);
  1. 在客户端JavaScript代码中,读取上述数据并将其提供给WebAuthn API。
// Get this from a form, etc.
const username = document.getElementById('username').value

// This can be any format you want, as long as it works with the above code
const response = await fetch('/readmeLoginStep1.php', {
    method: 'POST',
    body: 'username=' + username,
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
    },
})

const data = await response.json()

// Format for WebAuthn API
const getOptions = {
    publicKey: {
        challenge: Uint8Array.from(atob(data.challengeB64), c => c.charCodeAt(0)),
        allowCredentials: data.credential_ids.map(id => ({
            id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
            type: 'public-key',
        }))
    },
}

// Similar to registration step 2

// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.get(getOptions)

// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.
const dataForResponseParser = {
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
    clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
    signature: Array.from(new Uint8Array(credential.response.signature)),
    userHandle: Array.from(new Uint8Array(credential.response.userHandle)),
}

// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeLoginStep3.php', {
    body: JSON.stringify(dataForResponseParser),
    headers: {
        'Content-type': 'application/json',
    },
    method: 'POST',
})
const result = await fetch(request)
// handle result - if it went ok, perform any client needs to finish auth process
  1. 解析和验证响应。如果成功,则更新凭据并完成应用程序登录过程。
<?php

use Firehed\WebAuthn\{
    Codecs,
    ArrayBufferResponseParser,
};

session_start();

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ArrayBufferResponseParser();
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
if ($userHandle !== null && $userHandle !== $_SESSION['authenticating_user_id']) {
    throw new Exception('User handle does not match authentcating user');
}

try {
    // $challengeManager and $rp are the values from the setup step
    $updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
} catch (Throwable) {
    // Verification failed. Send an error to the user?
    header('HTTP/1.1 403 Unauthorized');
    return;
}
// Update the credential
$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($updatedCredential);
$stmt = $pdo->prepare('UPDATE credentials SET credential = :encoded WHERE storage_id = :storage_id AND user_id = :user_id');
$result = $stmt->execute([
    'storage_id' => $updatedCredential->getStorageId(),
    'user_id' => $_SESSION['authenticating_user_id'],
    'encoded' => $encodedCredential,
]);

header('HTTP/1.1 200 OK');
// Send back whatever your webapp needs to finish authentication

注意

$userHandle值提供了对不同认证流程的灵活性。如果为null,则认证器不支持用户句柄,您必须使用用户提供的值来查找正在认证的用户。如果存在值,它将匹配先前注册的publicKey.user.id值。如果设置了userHandle,应将其用于交叉引用用户提供的id,并可用于查找正在认证的用户。在两种情况下,都必须根据用户名或id获取$credentialContainer中的先前注册的凭据。

有关详细信息,请参阅自动填充辅助请求WebAuthn §7.2 步骤6

其他详细信息

依赖方

用通俗易懂的话说,依赖方是执行认证的服务器。WebAuthn凭据基于特定的依赖方标识符(rpId),并且这用于限制未来可以从中执行认证的来源。

为此,库支持为不同用例配置rpId的多种选项。

注册和认证过程都允许在JavaScript客户端代码中指定rpId。默认值为页面来源,除非明确指定。应用程序可以使用更不具体的宿主作为rpId,只要它是有效的可注册域名。

重要

创建凭据后,它将永久与创建过程中使用的rpId关联。进一步使用该凭据将在协议级别受到限制,只能使用相同的rpId

示例:对于位于https://www.example.com:8443的WebAuthn流程,rpId将默认为www.example.com。它可能被重写为example.com。它不能设置为com(不可注册)、other.example.com(与当前宿主不匹配)、test.www.example.com(比当前宿主更具体)或www.example.co(不同的可注册域名)。

在所有情况下,WebAuthn协议都不允许跨多个域共享凭据。例如,凭据不能在example.co.jpexample.us之间共享。

有关详细信息,请参阅规范

提示:使用MultiOriginRelyingParty与单个宿主可以帮助实现未来兼容性。

$rp = new \Firehed\WebAuthn\MultiOriginRelyingParty(['https://www.example.com'], 'example.com');
// registration or authentication flow on www.example.com
const createOptions = {
    publicKey: {
        rp: {
            id: 'example.com',
        },
        // ...

术语

  • origin 指的是方案、宿主和(如有必要)非标准端口的组合。示例:https://www.example.comhttps://example.comhttps://different.example.com:8443http://localhost:8080。在协议标准端口(即https的443)下使用时,不要包含端口。
  • rpId 依赖方标识符。这是URL的宿主部分;例如,域名或子域名。它不包含端口或方案。示例:example.comwww.example.comlocalhost

自动填充辅助请求

WebAuthn 的最简单实现仍然从传统的用户名字段开始。为了使认证体验更加流畅,您可以使用条件调解和自动填充辅助请求。

注册期间

确保 user.id 字段被适当地设置。这应该是一个不可变值,例如(但不限于)数据库中的主键。

认证期间

  • 将生成挑战的过程与查找和提供之前注册的凭证ID的过程分开。这对于所有流程都很有用,但为了支持条件调解,这是必需的,因为您事先不知道用户。

  • 添加对条件调解支持的检查。如果支持,则使用它。

    const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
    if (!isCMA) {
      // Autofill-assisted requests are not supported. Fall back to username flow.
      return
    }
    const challenge = await getChallenge() // existing API call
    const getOptions = {
      publicKey: {
        challenge,
        // Set other options as appropriate
      },
      mediation: 'conditional', // Add this
    }
    const credential = await navigator.credentials.get(getOptions)
    // proceed as usual
  • 调整验证API以使用凭证中的 userHandle。这可以通过以下方式之一完成,以拥有单个认证端点。

    // ...
    $getResponse = $parser->parseGetResponse($data);
    $userHandle = $getResponse->getUserHandle();
    $userId = $_POST['username'] ?? null; // match your existing form/API formats
    if ($userHandle === null) {
      assert($userId !== null);
      $user = findUserById($userId); // ORM lookup, etc
    } else {
      $user = findUserById($userHandle);
      assert($userId === $user->id || $userId === null);
    }
    $credentialContainer = getCredentialsForUser($user);
    // ...

清理任务

  • 导入 PublicKeyInterface
  • 导入 ECPublicKey
  • 将密钥格式化移动到 COSE 密钥/将 COSE 转换为密钥解析器?
  • 明确定义公共作用域的接口和类
    • 公共
      • ResponseParser(接口?)
      • Challenge(DTO / 会话中的序列化安全性)
      • RelyingParty
      • CredentialInterface
      • Responses\AttestationInterface & Responses\AssertionInterface
      • 错误
    • 内部
      • Attestations
      • AuthenticatorData
      • BinaryString
      • Credential
      • Certificate
      • CreateRespose & GetResponse
  • 重新设计 BinaryString 以避免在堆栈跟踪中包含二进制数据
  • 一致地使用 BinaryString
    • COSEKey.decodedCbor
    • Attestations\FidoU2F.data
  • 建立数据存储的必要+最佳实践
    • CredentialInterface + codec?
    • 与用户的关系
    • 保持 signCount 最新(7.2.21)
    • 7.1.22 ~ 使用的凭证
  • 在整个仓库中查找 FIXMEs 和缺少的验证步骤
    • (7.2.21) 中的计数器处理
    • isUserVerificationRequired - 可配置性(7.1.15,7.2.17)
    • 信任锚定(7.1.20;AO.verify 的结果)
    • 如何让客户端应用程序评估信任模糊性(7.1.21)
    • 将 create() 中的匹配算法与 createOptions 匹配(7.1.16)
  • 验证信任路径的 BC 计划
  • 认证声明返回类型/信息
  • BinaryString 更容易比较?
  • 代码审查问题
  • 导入排序

安全/风险

  • 证书链(7.1.20-21)
  • RP 策略对于证书证明类型/证明可信度(7.1.21)
  • 签数字大于或等于存储的值(7.2.21)

被阻止?

  • ClientExtensionResults(7.1.4、7.1.17、7.2.4、7.2.18)所有处理似乎都是可选的。我无法使其非空。
  • TokenBinding(7.1.10、7.2.14)除了 Edge 以外不受支持。在 3 级规范中已删除

命名?

  • Codecs\Credential
  • Codecs - 静态 vs 实例?
  • Credential::getStorageId()
  • ResponseParser -> Codecs?
  • CreateResponse/GetResponse -> 添加接口?
  • Parser -> parseXResponse => parse{Attestation|Assertion}Data
  • Error* -> Errors*

想要的东西/未来范围

  • 重构 FIDO 认证以不需要 AD.getAttestedCredentialData
    • 从 AD 中获取凭证
    • 检查 PK 类型
  • ExpiringChallenge & ChallengeInterface
  • JSON 生成器
    • PublicKeyCredentialCreationOptions
    • PublicKeyCredentialRequestOptions
      • 注意:没有直接将 json 转换为 arraybuffer 的方法?
      • 作为 jsonp 发出?
  • 允许更改 Relying Party ID
  • 重构 COSEKey 以支持其他密钥类型,使用枚举和 ADT 风格的组合
  • GetResponse userHandle
  • Assertion.verify (CredentialI | CredentialContainer)

测试

  • FidoU2F 的愉快路径
  • macOS/Safari WebAuthn 的愉快路径
  • 挑战不匹配(创建+获取)
  • 来源不匹配(CDJ)
  • RPID 不匹配(AuthenticatorData)
  • [s] !userPresent
  • !userVerified & required
  • [s] !userVerified & not required
  • PK 匹配不匹配在验证??
  • 应用程序持久化数据 SerDe
  • 解析器处理错误的输入格式

最佳实践

数据处理

使用上述示例中显示的精确数据格式dataForResponseParser),并使用ResponseParser类来处理它们。这些线格式由语义版本控制,并保证在主版本之外不会发生破坏性变更。

即将到来的.toJSON()支持

浏览器开始支持在WebAuthn PublicKeyCredential响应对象上使用.toJSON()方法。还有一个polyfill可用。随着浏览器对此格式的支持增加,它将成为将响应发送回服务器进行验证的推荐方法。

如果您使用该格式(无论是原生还是通过polyfill),则必须更新两次JS代码

const dataForResponseParser = credential.toJSON()

并在接收到的API上,将ArrayBufferResponseParser替换为JsonResponseParser

挑战管理

挑战是一种加密nonce,确保登录尝试只能工作一次。其一次性特性对WebAuthn协议的安全性至关重要。

您的应用程序应该使用库提供的ChallengeManagerInterface实现之一,以确保正确的行为。

如果提供的选项之一不合适,您可以自己实现该接口或手动管理挑战。如果您发现这是必要的,您应该为库打开一个问题/或拉取请求,指出其不足。

警告

您必须验证挑战是您的服务器最近生成的,并且尚未被使用。**未能做到这一点将损害协议的安全性**!实现不得信任客户端提供的值。内置的ChallengeManagerInterface实现将为您处理此问题。

由您的服务器生成的挑战应在短时间内过期。您可以使用ExpiringChallenge类以方便起见(例如,$challenge = ExpiringChallenge::withLifetime(60);),如果指定的过期窗口已被超过,它将抛出异常。建议您的javascript代码使用timeout设置(以毫秒为单位)并匹配服务器端挑战过期时间,多几秒或少几秒。W3C建议超时时间为5到10分钟。

注意

W3C规范建议的超时范围在15-120秒之间。

错误处理

该库基于“大声失败”的原则构建。在注册和身份验证过程中,如果没有抛出异常,则表示过程成功。请准备好捕获和处理这些异常。库抛出的所有异常都实现了Firehed\WebAuthn\Errors\WebAuthnErrorInterface,因此如果您只想捕获库错误(或在通用错误处理器中进行测试),请使用该接口。

注册和凭证存储

一个示例数据库表可能看起来是这样的

CREATE TABLE credentials (
    id INTEGER PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    storage_id TEXT UNIQUE,
    credential TEXT,
    nickname TEXT
);

您可能还希望有其他元数据,例如插入和/或更新时间、最后使用时间等。此类数据不在本库的范围内。

如果在持久化操作期间任何数据会被截断,应用程序必须检测到这一点并引发错误。通常,这意味着启用PDO::ERRMODE_EXCEPTION并确保数据库实例具有足够严格的运行时设置。

user_id

对您的用户表的引用。用户应该能够将多个凭证与他们的帐户相关联,并且应该有一种机制来添加额外的凭证和删除现有的凭证。这将是特定于您的应用程序的。

storage_id

这是$credential->getStorageId()的输出。它可以作为主键使用(例如,只有一个id字段,并用->getStorageId()填充)。该值始终是纯ASCII。

原始值最多为1,023字节,并以Base64URL的形式导出,因此存储应支持**至少1,364个字符**。

该字段应当有一个UNIQUE索引。如果在存储过程中违反了唯一约束并且与不同的用户相关联,您的应用程序必须处理这种情况,无论是通过返回错误还是将与其他用户解除现有记录的关联。请参阅https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential部分7.1步骤22以获取更多信息。

凭证

这是Firehed\WebAuthn\Codecs\Credential::encode($credential)的输出。在从数据库中检索以用于身份验证时,它应在同一类的互补->decode()方法上进行反序列化。

该字段应当支持至少存储4KiB,并且推荐支持至少存储64KiB(通常为TEXTvarchar(65535))。值将始终是纯ASCII。为了减少存储大小,您可以在编解码器的构造函数中传递storeRegistrationData: false;请注意,这样做将消除将来重新验证凭证的能力。

此格式由语义版本控制所覆盖。

昵称

nickname字段是可选的,如果使用,则存储用户提供的值,他们可以在管理凭证时使用。只有凭证的所有者应该能够看到此昵称。

身份验证

  • 在身份验证过程中调用的verify()方法返回一个更新的凭证。您的应用程序应当在每次发生这种情况时更新持久化值。这样做可以增加安全性,因为它提高了检测和应对重放攻击的能力。

版本控制和向后兼容性

此库遵循语义版本控制。请注意,标记为@internal的类或方法不受相同的保证。任何明确打算供公共使用的任何内容都已被标记为@api。如果有任何不清楚的地方,请提交问题。

有关此内容的最佳实践/数据处理方面有一些附加说明。

支持的算法

支持的标识符

在生成凭证时,客户端将证明其真实性。由于无法生成所有格式的响应,因此并非所有格式都受支持(在不充分测试的情况下实现规范的风险太大)。

默认情况下,$registration->verify()过程将拒绝不确定的信任路径。如果您从库中收到引用7.1.24insufficient attestation trustworthinessRegistrationError,这是由于这个默认设置。

首先,提交一个包含您尝试使用的注册数据的问题(网络上的JSON是可以的;这不包含PII)——这将有助于提高库的兼容性。然后,如果需要,您可以在verify()参数中传递rejectUncertainTrustPaths: false(这通过命名参数最容易实现)。这样做可以在注册过程中提供更多的灵活性,但以牺牲凭证的验证严格性为代价。

库最终旨在具有完整的格式覆盖率,但需要您的帮助才能实现这一点。

完全支持可信度规则是一个难以正确实现的API,因此目前这是唯一的逃生口!

资源和勘误表

此库是对u2f-php的重构,该库是基于一个名为U2F的较早版本的规范构建的,由YubiCo的YubiKey产品开创。WebAuthn继续支持YubiKey(和其他U2F设备),此库也是如此。而不是构建该库的v2版本,发现彻底的突破更容易。

  • 无需处理移动Composer包(u2f名称不再有意义)。
  • 需要重写许多数据存储机制。
  • WebAuthn中的平台可扩展性在先前的结构中表现不佳。
  • 放弃对旧PHP版本的支持并使用新特性简化了很多。

WebAuthn规范

通用快速入门指南

passkeys 简介