firehed/u2f

此包已被废弃,不再维护。没有建议的替代包。

提供U2F身份验证的库

1.2.0 2021-10-26 17:28 UTC

This package is auto-updated.

Last update: 2024-04-25 20:09:48 UTC


README

此仓库已被firehed/webauthn-php取代,不再维护。替代品不再支持已废弃的U2F协议,这允许使用更现代和灵活的API。它确实支持U2F硬件密钥,但仅通过WebAuthn协议。

U2F

FIDO U2F身份验证标准的PHP实现。现在也支持Web身份验证!

Lint Static analysis Test codecov

介绍

Web身份验证(通常称为WebAuthn)是一套技术,用于在Web应用程序中安全地验证用户。它最常用作第二因素——生物识别或硬件设备——以补充密码登录。它允许网站用基于硬件的第二因素替换伴侣应用(如Google Authenticator)或通信协议(例如短信)的需求。

这个库的根源在于WebAuthn从其演变而来的U2F(通用第二因素)协议,并支持这两个标准。请注意,浏览器开始放弃对原始U2F协议的支持,转而支持WebAuthn;因此,这个库在下一个主要版本中也将这样做。

这个库旨在允许轻松地将U2F协议集成到现有的用户身份验证方案中。它处理解析和验证所有原始消息格式,并将它们转换为标准的PHP对象。

请注意,本文件中使用的“密钥”一词应理解为“FIDO U2F令牌”。这些通常是USB“密钥”,但也可以是NFC或蓝牙设备。

您需要了解两个主要操作才能成功集成:注册和身份验证。注册是将用户所拥有的密钥与其现有账户关联的行为;身份验证是使用该密钥从您的应用程序中签名消息,以验证该密钥的拥有权。

其他资源

演示

您可以在https://u2f.ericstern.com尝试所有这些,并在https://github.com/Firehed/u2f-php-examples查看相应的代码。

示例代码仅设计用于展示API之间的交互,故意省略了最佳实践,如使用路由器和依赖注入容器,以尽可能简化示例。请参阅其README以获取更多信息。

安装

composer require firehed/u2f

注意:您**不得**使用已废弃的mbstring.func_overload功能,这可能会完全破坏二进制数据的工作。如果启用该功能,库将立即抛出异常。

用法

用法将分为三个部分进行描述:设置、注册和身份验证。设置中的代码应在注册和身份验证之前使用。

API设计为“大声失败”;也就是说,失败将抛出异常,确保返回值始终是成功操作的结果。这减少了在用法期间进行复杂错误检查和处理的需要,因为整个操作可以简单地用try/catch块包裹,并假设如果没有捕获到异常,则一切顺利。

本指南涵盖了现代Web身份验证(“WebAuthn”)的用法和数据格式。有关旧版U2F协议的更多信息,请参阅本README的v1.1.0及以前的版本。

设置

所有操作都由U2F服务器类执行,因此需要实例化和配置它。

use Firehed\U2F\Server;
$server = new Server('u2f.example.com');
$server->setTrustedCAs(glob('path/to/certs/*.pem'));

受信任的CA是白名单供应商,必须是一个PEM格式CA证书的绝对路径数组(作为字符串)。一些供应商证书存储在仓库根目录下的CACerts/目录中;在部署的项目中,这些证书应通过$PROJECT_ROOT/vendor/firehed/u2f/CACerts/*.pem访问。

您还可以选择禁用CA验证,通过调用->disableCAVerification()而不是setTrustedCAs()来实现。这将移除对硬件供应商的信任,但确保新供应商发行的令牌与您的网站具有向前兼容性。

提供给构造函数的URI必须是您的网站的HTTPS域名部分。有关更多信息,请参阅FIDO U2F AppID和Facet规范

注册

将令牌注册到用户账户是一个两步过程:生成挑战,并验证对该挑战的响应。

生成挑战

首先生成一个挑战。您需要临时存储它(例如,在会话中),然后将其发送给用户。

$challenge = $server->generateChallenge();
$_SESSION['registration_challenge'] = $challenge;

header('Content-type: application/json');
echo json_encode($challenge);

客户端注册

创建一个PublicKeyCredentialCreationOptions数据结构,并将其提供给WebAuthn API。

const userId = "some value from your application"
const challenge = "challenge string from above"
const options = {
  rp: {
    name: "Example Site",
  },
  user: {
    id: Uint8Array.from(userId, c => c.charCodeAt(0)),
    name: "user@example.com",
    displayName: "User Name",
  },
  challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
  pubKeyCredParams: [{alg: -7, type: "public-key"}],
  timeout: 60000, // 60 seconds
  authenticatorSelection: {
    authenticatorAttachment: "cross-platform",
    userVerification: "preferred",
  },
  attestation: "direct"
}

// If the user completes registration, this value will hold the data to POST to your application
const credential = await navigator.credentials.create({
  publicKey: options
})

// Format the user's `credential` and POST it to your application:

const dataToSend = {
  rawId: new Uint8Array(credential.rawId),
  type: credential.type,
  response: {
    attestationObject: new Uint8Array(credential.response.attestationObject),
    clientDataJSON: new Uint8Array(credential.response.clientDataJSON),
  },
}

// Pseudocode:
// POST will send JSON.stringify(dataToSend) with an application/json Content-type header
const response = POST('/verifyRegisterChallenge.php', dataToSend)

解析和验证响应

使用之前生成的注册请求,让服务器验证POST数据。如果验证成功,您将获得一个可以与用户关联的注册对象。

// You should validate that the inbound request has an 'application/json' Content-type header
$rawPostBody = trim(file_get_contents('php://input'));
$data = json_decode($rawPostBody, true);
$response = \Firehed\U2F\WebAuthn\RegistrationResponse::fromDecodedJson($data);

$challenge = $_SESSION['registration_challenge'];
$registration = $server->validateRegistration($challenge, $response);

持久化$registration

由于用户可能拥有多个密钥,并且可能希望将它们全部与账户关联(例如,备份密钥存储在配偶的钥匙链上),因此注册应作为与用户的一对多关系持久化。建议使用(user_id, key_handle)作为唯一的复合标识符。

-- A schema with roughly this format is ideal
CREATE TABLE token_registrations (
    id INTEGER PRIMARY KEY,
    user_id INTEGER,
    counter INTEGER,
    key_handle TEXT,
    public_key TEXT,
    attestation_certificate TEXT,
    FOREIGN KEY (user_id) REFERENCES users(id),
    UNIQUE(user_id, key_handle)
)
// This assumes you are connecting to your database with PDO
$query = <<<SQL
INSERT INTO token_registrations (
    user_id,
    counter,
    key_handle,
    public_key,
    attestation_certificate
) VALUES (
    :user_id,
    :counter,
    :key_handle,
    :public_key,
    :attestation_certificate
)
SQL;
$stmt = $pdo->prepare($query);
// Note: you may want to base64- or hex-encode the binary values below.
// Doing so is entirely optional.
$stmt->execute([
    ':user_id' => $_SESSION['user_id'],
    ':counter' => $registration->getCounter(),
    ':key_handle' => $registration->getKeyHandleBinary(),
    ':public_key' => $registration->getPublicKey()->getBinary(),
    ':attestation_certificate' => $registration->getAttestationCertificate()->getBinary(),
]);

完成此操作后,应在用户上添加某种类型的标志以指示已启用双因素认证,并确保他们已通过第二个因素进行身份验证。由于这完全是应用程序特定的,因此不会在此处介绍。

身份验证

身份验证的过程与注册类似:为每个用户的注册生成签名挑战,并在收到响应时验证它们。

生成挑战

首先生成签名请求。与注册类似,您需要临时存储它们以供验证。完成后,将它们发送给用户。

$registrations = $user->getU2FRegistrations(); // this must be an array of Registration objects

$challenge = $server->generateChallenge();
$_SESSION['login_challenge'] = $challenge;

// WebAuthn expects a single challenge for all key handles, and the Server generates the requests accordingly.
header('Content-type: application/json');
echo json_encode([
    'challenge' => $challenge,
    'key_handles' => array_map(function (\Firehed\U2F\RegistrationInterface $reg) {
        return $reg->getKeyHandleWeb();
    }, $registrations),
]);

客户端身份验证

创建一个PublicKeyCredentialRequestOptions数据结构,并将其提供给WebAuthn API。

// This is a basic decoder for the above `getKeyHandleWeb()` format
const fromBase64Web = s => atob(s.replace(/\-/g,'+').replace(/_/g,'/'))

// postedData is the decoded JSON from the above snippet
const challenge = postedData.challenge
const keyHandles = postedData.key_handles
const options = {
  challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
  allowCredentials: keyHandles.map(kh => ({
    id: Uint8Array.from(fromBase64Web(kh), c => c.charCodeAt(0)),
    type: 'public-key',
    transports: ['usb', 'ble', 'nfc'],
  })),
  timeout: 60000,
}
// If the user authenticates, this value will hold the data to POST to your application
const assertion = await navigator.credentials.get({
  publicKey: options
});

// Format the user's `assertion` and POST it to your application:

const dataToSend = {
  rawId: new Uint8Array(assertion.rawId),
  type: assertion.type,
  response: {
    authenticatorData: new Uint8Array(assertion.response.authenticatorData),
    clientDataJSON: new Uint8Array(assertion.response.clientDataJSON),
    signature: new Uint8Array(assertion.response.signature),
  },
}

// Pseudocode, same as above
const response = await POST('/verifyLoginChallenge.php', dataToSend)

解析和验证响应

将POST数据解析为LoginResponseInterface

// You should validate that the inbound request has an 'application/json' Content-type header
$rawPostBody = trim(file_get_contents('php://input'));
$data = json_decode($rawPostBody, true);
$response = \Firehed\U2F\WebAuthn\LoginResponse::fromDecodedJson($data);

$registrations = $user->getU2FRegistrations(); // Registration[]
$registration = $server->validateLogin(
    $_SESSION['login_challenge'],
    $response,
    $registrations
);

持久化更新后的$registration

如果没有抛出异常,则$registration将是一个具有更新后的counter的注册对象;您必须将此更新后的计数器持久化到存储注册的地方。不这样做是不安全的,并使您的应用程序容易受到令牌克隆攻击。

// Again, assumes a PDO connection
$query = <<<SQL
UPDATE token_registrations
SET counter = :counter
WHERE user_id = :user_id
    AND key_handle = :key_handle
SQL;
$stmt = $pdo->prepare($query);
$stmt->execute([
    ':counter' => $registration->getCounter(),
    ':user_id' => $_SESSION['user_id'],
    ':key_handle' => $registration->getKeyHandleBinary(), // if you are storing base64- or hex- encoded above, do so here as well
]);

如果您达到这个点,则用户已成功通过第二个因素进行身份验证。更新会话以表明这一点,并允许他们继续。

测试

所有测试都在tests/目录中,可以使用vendor/bin/phpunit运行。

许可证

MIT