firehed / u2f
提供U2F身份验证的库
Requires
- php: >=7.2
- firehed/cbor: ^0.1
Requires (Dev)
- phpstan/phpstan: ^0.12
- phpstan/phpstan-phpunit: ^0.12
- phpunit/phpunit: ^8.5 || ^9.0
- squizlabs/php_codesniffer: ^3.2
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身份验证!
介绍
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