一文接入 passKey

tsvico Lv5

书接上回 php 接入单点 , 之前文章中说了接入 passKey,但是看起来挺复杂,就先接入了单点登录,今天又查了些文章,学习了下,把 passKey 也啃了下来。

1723992394254.png

背景介绍

什么是 FIDO2?

FIDO2(Fast IDentity Online 2.0)是一个由 FIDO 联盟推出的开放认证标准,旨在通过公钥加密技术实现更安全、更便捷的无密码身份验证。FIDO2 标准由两个关键组件组成:

  • WebAuthn:由 W3C 定义的 Web 认证标准,允许 Web 应用程序通过浏览器和平台级 API 进行安全的用户身份验证。
  • CTAP(客户端到认证器协议):定义了浏览器或操作系统与认证设备(如安全密钥或生物识别传感器)之间的通信方式。

什么是 WebAuthn?

WebAuthn 是 FIDO2 标准的核心部分之一。它是一个由 W3C 标准化的 Web API,允许网站和应用程序通过浏览器注册和验证用户的公钥,并通过使用硬件认证器(如生物识别设备或安全密钥)进行身份验证。WebAuthn 消除了传统密码的使用,提高了安全性,并且使用户体验更加顺畅。

什么是 Passkey

Passkey 是 FIDO2 和 WebAuthn 技术的具体实现形式,它代表了一种无密码的用户身份验证方式。Passkey 本质上是基于 FIDO2 标准的公钥凭证,但它被设计得更加用户友好,通常与设备集成,比如手机或笔记本中的生物识别传感器。用户可以通过指纹、面部识别或设备 PIN 码来完成身份验证,无需记忆或输入复杂的密码。

三者的关系

  • FIDO2 是整个无密码认证体系的框架和标准,规定了如何使用公钥加密进行身份验证。
  • WebAuthn 是 FIDO2 框架中的 Web API,定义了浏览器与服务器之间的交互方式,支持多种认证方式。
  • Passkey 是基于 FIDO2 和 WebAuthn 的具体应用形式,它让用户可以通过生物识别等方式轻松、安全地进行无密码登录。

总结来说,Passkey 是 FIDO2 和 WebAuthn 在实际应用中的一种具象化的表现,它利用了这些标准所提供的技术,旨在提供更安全、更便捷的身份验证体验。

mindmap
  root((FIDO2))
    WebAuthn
      W3C标准
      Web API
      浏览器与服务器交互
    CTAP
      客户端到认证器协议
      设备与认证器通信
    Passkey
      基于FIDO2标准
      结合WebAuthn和CTAP
      无密码身份验证
      支持生物识别和设备集成

查阅资料

相关资料较少,先是看了一文搞懂 Passkey, 让我对 passkey 有了初步的认识,然后看了 Passkey 开发指南,感觉还是懂了点什么,但是还是有点模糊,于是我找到了 lbuchs/WebAuthn这款 PHP WebAuthn (FIDO2) 服务器库。通过阅读示例代码,开始着手进行开发。

开发过程一波 N 折,由于资料较少,认证流程理不清,最后看到了这篇文章 WebAuthn 在 php 中的实践,对我启发很大,现在终于明白了整体流程。
简单来说就是:

  • 认证器注册
    • 通过接口 a1 获取注册凭证,拿凭证调用浏览器 navigator.credentials.create
    • 拿认证结果调用 a2a2 校验并保存凭证信息,以供后续使用
  • 认证器登录
    • 通过接口 b1 获取登录凭证,拿凭证调用浏览器 navigator.credentials.get
    • 拿结果调用 b2b2 校验凭证并进行登录

开始开发

接入 WebAuthn,首先需要安装一个 PHP WebAuthn 库,我选择的是 lbuchs/WebAuthn。该库提供了简单易用的 API,支持 FIDO2 标准的无密码认证。

  1. 安装依赖
    使用 Composer 安装 lbuchs/webauthn 库:
1
composer require lbuchs/webauthn

安装完成后,可以在项目中引用这个库,进行 WebAuthn 的开发。

  1. 配置 WebAuthn 服务器
    在服务器端,我们需要配置 WebAuthn 服务来处理认证请求。以下是初始化 WebAuthn 对象的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
require __DIR__ . '/../../vendor/autoload.php';

use lbuchs\WebAuthn\WebAuthnException;

// 配置项
$rpName = '工具牛';
$rpId = 'localhost';
$origin = 'http://localhost';
$user_name = "tsvico";
$user_mail = "xxxx@xx.com";
$user_nick = "tsvico";

function getWebAuthnInstance($rpName, $rpId, $origin)
{
return new lbuchs\WebAuthn\WebAuthn($rpName, $rpId, $origin);
}

WebAuthn 构造函数的参数分别是:应用名称、RP ID(也就是你的域名)和 origin(通常是网站的 URL),由于我的登录仅仅我自己使用,就将用户写死在代码里了。

  1. 注册流程 (后台页面)
    在用户注册阶段,我们需要让用户的浏览器生成一个新的认证器凭证,然后将其发送到服务器进行验证和保存。

    • 生成注册信息

      首先,我们需要生成一个 challenge 并返回给客户端。challenge 是一个临时生成的随机字符串,用于防止重放攻击。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      function register($rpName, $rpId, $origin) {
      global $user_name, $user_mail, $user_nick;

      $webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);

      $createArgs = $webAuthn->getCreateArgs($user_name, $user_mail, $user_nick);

      // 注意顺序,必须先获取参数,再请求getChallenge
      $challenge = $webAuthn->getChallenge();
      $_SESSION['webauthn_challenge'] = $challenge;

      echo json_encode($createArgs);
      }
    • 处理注册请求
      用户在前端使用 navigator.credentials.create 方法生成凭证后,浏览器会返回凭证信息。我们需要将这些信息发送到服务器进行验证和保存:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      function verifyRegistration($rpName, $rpId, $origin)
      {
      $webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);
      $data = json_decode(trim(file_get_contents('php://input')), null, 512, JSON_THROW_ON_ERROR);
      $challenge = $_SESSION['webauthn_challenge'];
      global $user_name, $user_mail, $user_nick;

      try {
      $clientDataJSON = !empty($data->clientDataJSON) ? base64_decode($data->clientDataJSON) : null;
      $attestationObject = !empty($data->attestationObject) ? base64_decode($data->attestationObject) : null;

      $data = $webAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, true, true, false);

      $data->userMail = $user_mail;
      $data->userName = $user_name;
      $data->userNick = $user_nick;
      saveAuthenticator($data);

      echo json_encode(['error' => 0, 'msg' => '注册成功']);
      } catch (WebAuthnException $e) {
      echo json_encode(['error' => 1, 'msg' => '注册失败: ' . $e->getMessage()]);
      }
      }

    在这里,processCreate 方法用于验证浏览器传递过来的凭证数据,并返回认证器信息。接着我们可以将这些信息保存到数据库中,以便用户后续使用。

  2. 登录流程(登录页)
    在用户登录时,我们需要从服务器获取一个登录挑战并发送到客户端,然后使用 navigator.credentials.get 方法进行认证。

  • 生成登录挑战
    和注册过程类似,我们需要先生成一个 challenge 并返回给客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function authenticate($rpName, $rpId, $origin)
{
$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);

$authenticators = loadAuthenticators();

$ids = [];

global $user_name;

if (isset($authenticators) && is_array($authenticators)) {
foreach ($authenticators as $reg) {
if ($reg->userName === $user_name) {
$ids[] = $reg->credentialId;
}
}
}
if (count($ids) === 0) {
throw new Exception('no registrations in session for userId ' . $user_name);
}


// 支持 usb、nfc、ble、
$getArgs = $webAuthn->getGetArgs($ids, 60 * 4, true, true, true, true, true, true);

$challenge = $webAuthn->getChallenge();
$_SESSION['webauthn_challenge'] = $challenge;

echo json_encode($getArgs);
}
  • 处理登录请求
    用户使用浏览器生成凭证后,将凭证信息发送到服务器进行验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function verifyAuthentication($rpName, $rpId, $origin)
{
$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);
$data = json_decode(trim(file_get_contents('php://input')), null, 512, JSON_THROW_ON_ERROR);
$challenge = $_SESSION['webauthn_challenge'];

try {
// Extract the necessary fields from the input data
$clientDataJSON = !empty($data->clientDataJSON) ? base64_decode($data->clientDataJSON) : null;
$authenticatorData = !empty($data->authenticatorData) ? base64_decode($data->authenticatorData) : null;
$signature = !empty($data->signature) ? base64_decode($data->signature) : null;
$userHandle = !empty($data->userHandle) ? base64_decode($data->userHandle) : null;
$id = !empty($data->id) ? base64_decode($data->id) : null;

$credentialPublicKey = null;

$authenticators = loadAuthenticators();

if (isset($authenticators)) {
foreach ($authenticators as $reg) {
if ($reg->credentialId === $id) {
$credentialPublicKey = $reg->credentialPublicKey;
break;
}
}
}


if ($userHandle !== $reg->userName) {
throw new Exception('userId doesnt match (is ' . $userHandle . ' but expect ' . $reg->userName . ')');
}

if ($credentialPublicKey === null) {
throw new Exception('Public Key for credential ID not found!');
}

$webAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, true);

$user = ["nick" => $reg->userName, "email" => $reg->userMail];
$_SESSION['user'] = $user;
echo json_encode(['error' => 0, 'msg' => '认证成功']);
} catch (Exception $e) {
echo json_encode(['error' => 1, 'msg' => '认证失败: ' . $e->getMessage()]);
}
}

在这里,processGet 方法用于验证用户的登录凭证。如果验证成功,我们就可以让用户登录系统。

完整代码

为了省事,全放一个文件里了 webauthn.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
<?php
require __DIR__ . '/../../vendor/autoload.php';

use lbuchs\WebAuthn\WebAuthnException;

header('Content-type:application/json;charset=utf-8');

session_start();

// 配置项
$rpName = '工具牛';
$rpId = 'localhost';
$origin = 'http://localhost';
$user_name = "tsvico";
$user_mail = "xx@xx.com";
$user_nick = "tsvico";

/**
* 获取实例
*/
function getWebAuthnInstance($rpName, $rpId, $origin)
{
return new lbuchs\WebAuthn\WebAuthn($rpName, $rpId, $origin);
}

function serializeFieldsToBase64($objects, $fieldNames)
{
if (!is_array($objects)) {
throw new InvalidArgumentException('Expected an array of objects.');
}

foreach ($objects as $object) {

foreach ($fieldNames as $fieldName) {
if (isset($object->$fieldName)) {
$object->$fieldName = base64_encode(serialize($object->$fieldName));
}
}
}

return $objects;
}


function unserializeFieldsFromBase64($objects, $fieldNames)
{
if (!is_array($objects)) {
throw new InvalidArgumentException('Expected an array of objects.');
}

foreach ($objects as &$object) {
$object = (object) $object;
foreach ($fieldNames as $fieldName) {
if (isset($object->$fieldName)) {
$object->$fieldName = unserialize(base64_decode($object->$fieldName));
}
}
}

return $objects;
}



/**
* 加载本地存储的认证信息
*/
function loadAuthenticators()
{
$fieldNames = ['credentialId', 'AAGUID'];

$authenticatorsFile = __DIR__ . '/aaa.db';
if (file_exists($authenticatorsFile)) {
return unserializeFieldsFromBase64(json_decode(file_get_contents($authenticatorsFile), true), $fieldNames);
}
return [];
}
/**
* 保存认证信息到本地存储
*/
function saveAuthenticator($authenticator)
{
$fieldNames = ['credentialId', 'AAGUID'];

$authenticators = loadAuthenticators();

$authenticators[] = $authenticator;
$result = file_put_contents(__DIR__ . '/aaa.db', json_encode(serializeFieldsToBase64($authenticators, $fieldNames), JSON_PRETTY_PRINT));
if ($result === false) {
echo "Failed to write file. Error: " . print_r(error_get_last(), true);
}
}

function findAuthenticator($credId)
{
$authenticators = loadAuthenticators();
foreach ($authenticators as $authenticator) {
if ($authenticator['userName'] === $credId) {
return $authenticator;
}
}
return null;
}

// 处理不同的接口请求
$requestMethod = $_SERVER['REQUEST_METHOD'];
$requestUri = $_SERVER['REQUEST_URI'];
$requestPath = parse_url($requestUri, PHP_URL_QUERY);

/**
* 获取注册信息
*/
if ($requestMethod === 'POST' && $requestPath === '/register') {
if (!isset($_SESSION['user'])) {
echo json_encode(['error' => 1, 'msg' => '请先登录']);
die;
}
register($rpName, $rpId, $origin);
} elseif ($requestMethod === 'POST' && $requestPath === '/verifyRegistration') {
/**
* 保存注册信息
*/
if (!isset($_SESSION['user'])) {
echo json_encode(['error' => 1, 'msg' => '请先登录']);
die;
}
verifyRegistration($rpName, $rpId, $origin);
} elseif ($requestMethod === 'POST' && $requestPath === '/authenticate') {
/**
* 获取验证信息
*/
authenticate($rpName, $rpId, $origin);
} elseif ($requestMethod === 'POST' && $requestPath === '/verifyAuthentication') {
/**
* 验证信息
*/
verifyAuthentication($rpName, $rpId, $origin);
} else {
http_response_code(404);
echo json_encode(['error' => 1, 'msg' => '接口不存在,当前地址' . $requestPath]);
}

/**
* 获取注册信息
*/
function register($rpName, $rpId, $origin)
{
global $user_name, $user_mail, $user_nick;

$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);

$createArgs = $webAuthn->getCreateArgs($user_name, $user_mail, $user_nick);

// 注意顺序,必须先获取参数,再请求getChallenge
$challenge = $webAuthn->getChallenge();
$_SESSION['webauthn_challenge'] = $challenge;

echo json_encode($createArgs);
}

/**
* 验证保存注册信息
*/
function verifyRegistration($rpName, $rpId, $origin)
{
$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);
$data = json_decode(trim(file_get_contents('php://input')), null, 512, JSON_THROW_ON_ERROR);
$challenge = $_SESSION['webauthn_challenge'];
global $user_name, $user_mail, $user_nick;

try {
$clientDataJSON = !empty($data->clientDataJSON) ? base64_decode($data->clientDataJSON) : null;
$attestationObject = !empty($data->attestationObject) ? base64_decode($data->attestationObject) : null;

$data = $webAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, true, true, false);

$data->userMail = $user_mail;
$data->userName = $user_name;
$data->userNick = $user_nick;
saveAuthenticator($data);

echo json_encode(['error' => 0, 'msg' => '注册成功']);
} catch (WebAuthnException $e) {
echo json_encode(['error' => 1, 'msg' => '注册失败: ' . $e->getMessage()]);
}
}

/**
* 获取验证信息
*/
function authenticate($rpName, $rpId, $origin)
{
$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);

$authenticators = loadAuthenticators();

$ids = [];

global $user_name;

if (isset($authenticators) && is_array($authenticators)) {
foreach ($authenticators as $reg) {
if ($reg->userName === $user_name) {
$ids[] = $reg->credentialId;
}
}
}
if (count($ids) === 0) {
throw new Exception('no registrations in session for userId ' . $user_name);
}


// 支持 usb、nfc、ble、
$getArgs = $webAuthn->getGetArgs($ids, 60 * 4, true, true, true, true, true, true);

$challenge = $webAuthn->getChallenge();
$_SESSION['webauthn_challenge'] = $challenge;

echo json_encode($getArgs);
}

/**
* 验证信息
*/
function verifyAuthentication($rpName, $rpId, $origin)
{
$webAuthn = getWebAuthnInstance($rpName, $rpId, $origin);
$data = json_decode(trim(file_get_contents('php://input')), null, 512, JSON_THROW_ON_ERROR);
$challenge = $_SESSION['webauthn_challenge'];

try {
// Extract the necessary fields from the input data
$clientDataJSON = !empty($data->clientDataJSON) ? base64_decode($data->clientDataJSON) : null;
$authenticatorData = !empty($data->authenticatorData) ? base64_decode($data->authenticatorData) : null;
$signature = !empty($data->signature) ? base64_decode($data->signature) : null;
$userHandle = !empty($data->userHandle) ? base64_decode($data->userHandle) : null;
$id = !empty($data->id) ? base64_decode($data->id) : null;

$credentialPublicKey = null;

$authenticators = loadAuthenticators();

if (isset($authenticators)) {
foreach ($authenticators as $reg) {
if ($reg->credentialId === $id) {
$credentialPublicKey = $reg->credentialPublicKey;
break;
}
}
}


if ($userHandle !== $reg->userName) {
throw new Exception('userId doesnt match (is ' . $userHandle . ' but expect ' . $reg->userName . ')');
}

if ($credentialPublicKey === null) {
throw new Exception('Public Key for credential ID not found!');
}

$webAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, true);

$user = ["nick" => $reg->userName, "email" => $reg->userMail];
$_SESSION['user'] = $user;
echo json_encode(['error' => 0, 'msg' => '认证成功']);
} catch (Exception $e) {
echo json_encode(['error' => 1, 'msg' => '认证失败: ' . $e->getMessage()]);
}
}

前端代码
login.html,按钮 ID 为 login-webauthn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
if (!window.PublicKeyCredential) {
$("#login-webauthn").remove();
}

$("#login-webauthn").click(function () {
let btn = $(this);
btn.attr("disabled", true);

// 请求认证参数
fetch("../oidc/webauthn?/authenticate", {
method: "POST",
})
.then((res) => res.json())
.then((getArgs) => {
recursiveBase64StrToArrayBuffer(getArgs);

navigator.credentials
.get(getArgs)
.then((assertion) => {
// 认证成功,发送到后端验证
let clientDataJSON = arrayBufferToBase64(
assertion.response.clientDataJSON
);
let authenticatorData = arrayBufferToBase64(
assertion.response.authenticatorData
);
let signature = arrayBufferToBase64(assertion.response.signature);
let rawId = arrayBufferToBase64(assertion.rawId);
let userHandle = arrayBufferToBase64(assertion.response.userHandle);

fetch("../oidc/webauthn?/verifyAuthentication", {
method: "POST",
body: JSON.stringify({
id: rawId,
clientDataJSON: clientDataJSON,
authenticatorData: authenticatorData,
signature: signature,
userHandle: userHandle,
}),
cache: "no-cache",
})
.then((res) => res.json())
.then((res) => {
if (res.error === 0) {
notify("认证成功");
window.location.href = "./";
} else {
notify(res.msg);
}
btn.attr("disabled", false);
btn.text("使用验证器");
});
})
.catch((error) => {
console.error(error);
notify("认证失败");
btn.attr("disabled", false);
btn.text("Passkey");
});
})
.catch((error) => {
console.error(error);
notify("认证失败");
btn.attr("disabled", false);
btn.text("Passkey");
});
});

后台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function neWebauthn() {
if (!window.PublicKeyCredential) {
notify("您的浏览器不支持 WebAuthn");
}
fetch("../oidc/webauthn?/register", {
method: "POST",
})
.then((res) => res.json())
.then((getArgs) => {
recursiveBase64StrToArrayBuffer(getArgs);
navigator.credentials
.create(getArgs)
.then((cred) => {
const authenticatorAttestationResponse = {
transports: cred.response.getTransports
? cred.response.getTransports()
: null,
clientDataJSON: cred.response.clientDataJSON
? arrayBufferToBase64(cred.response.clientDataJSON)
: null,
attestationObject: cred.response.attestationObject
? arrayBufferToBase64(cred.response.attestationObject)
: null,
};
fetch("../oidc/webauthn?/verifyRegistration", {
method: "POST",
body: JSON.stringify(authenticatorAttestationResponse),
cache: "no-cache",
})
.then((res) => res.json())
.then((res) => {
if (res.error === 0) {
notify("认证成功");
} else {
notify(res.msg);
}
});
})
.catch((error) => {
console.error(error);
notify("认证失败");
});
})
.catch((error) => {
console.error(error);
notify("认证失败");
});
}

总结

通过 WebAuthn 和 FIDO2 的支持,我们可以轻松实现无密码的身份认证。本文介绍了从注册到登录的完整流程,并提供了相关的 PHP 代码示例。通过这些步骤,你可以在自己的项目中接入 Passkey,提高系统的安全性和用户体验。

完整代码参考 lbuchs/WebAuthn 官方仓库。如果你对 WebAuthn 或 FIDO2 标准有进一步的兴趣,可以查看 W3C 的 WebAuthn 标准文档。

  • 标题: 一文接入 passKey
  • 作者: tsvico
  • 创建于 : 2024-08-18 21:59:23
  • 更新于 : 2024-08-20 15:08:28
  • 链接: https://blog.tbox.fun/2024/4152515392.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论