前言
偶然发现 store.typecho.work/tepass/sign... 中的"使用通行密钥登陆"按钮。
查询资料后知道这其实就是 WebAuthn,便打算在自己的用户系统中加入此功能,顺便写了这篇文章记录一下。
至于为什么是 php,纯粹是因为用户系统是拿 php 开发的。
效果
介绍
基本信息
WebAuthn,全称 Web Authentication,是由 FIDO 联盟(Fast IDentity Online Alliance)和 W3C(World Wide Web Consortium)联合制定的一套新的身份认证标准 ,旨在为网络身份验证提供一种更强大、更安全的方式,使用户能够使用他们的设备(如手机、USB 密钥或生物识别器)来进行身份验证,而无需使用密码 。该标准于 2019 年 3 月 4 日正式成为 W3C 的推荐标准。目前主流的浏览器已经支持 WebAuthn,包括 Chrome、Firefox、Edge 和 Safari,更详细的支持情况可以通过 webauthn.me/browser-sup... 查看。
相关资料
- 认识 WebAuthn - Ruilin (rui0.cn)
- WebAuthn: 真正的无密码身份认证 - 知乎 (zhihu.com)
- 什么是WebAuthn:在Web上使用Touch ID和Windows Hello登录 - 掘金 (juejin.cn)
当然光是纸上谈兵是不够的,我们需要动手实践。
然而国内文档少的可怜,官网 webauthn.io 上也没有 php 的库(可能是因为 php 已死)。自己在网上搜了下找到了这个库:github.com/web-auth/we... ,虽然文档也写的不清不楚的,看的人麻了。
最后找到这篇还不错的文章,详细介绍了认证的流程:flyhigher.top/develop/216... ,虽然只有前端部分,后端 php 部分还是得自己摸索。
不过这位大佬还开发了 WP-WebAuthn 这个 Wordpress 的插件,虽然代码一言难尽(可能是我没开发过wp插件的缘故?)所以我放弃参照他的插件代码了。
我阅读了这个库v3.3版本文档中 《The Easy Way》 这一节,逐渐摸索出代码。
文档地址:The Easy Way - Webauthn Framework
开发环境
- php 7.4.3
- mysql 5.7.26
- nginx
依赖包
- thinkphp6.1.0
- web-auth/webauthn-lib
- nyholm/psr7
- nyholm/psr7-server
注意点
- php7 只能使用 3.X 版本的 webauthn-lib,4.X系列需要php8,且配置项有所变动
- 前端我这里直接使用 jQuery 操作
- 使用域名时需要 https 协议,调试时可以使用 localhost
动手编写
先建立认证器的数据表
类中认证器参数是小驼峰命名的,数据库中我为了命名统一,以下划线命名
php
<?php
namespace app\sso\model;
class Authenticator extends \think\Model
{
protected $pk = 'id';
protected $name = 'authenticators';
protected $json = ['transports', 'trust_path'];
protected $jsonAssoc = true;
public function user()
{
return $this->belongsTo(User::class);
}
}
准备工作
我们需要先在后端写一个 PublicKeyCredentialSourceRepository 用于管理认证器
这个 PublicKeyCredentialSourceRepository 需要 implements Webauthn\PublicKeyCredentialSourceRepository
php
<?php
namespace app\common;
use app\sso\model\Authenticator;
use app\sso\model\User;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository as PublicKeyCredentialSourceRepositoryInterface;
use Webauthn\PublicKeyCredentialUserEntity;
class PublicKeyCredentialSourceRepository implements PublicKeyCredentialSourceRepositoryInterface
{
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
{
if ($authenticator = Authenticator::where('row_id', base64_encode($publicKeyCredentialId))->find()) {
$data = [
'publicKeyCredentialId' => $authenticator->public_key_credential_id,
'type' => $authenticator->type,
'transports' => $authenticator->transports,
'attestationType' => $authenticator->attestation_type,
'trustPath' => $authenticator->trust_path,
'aaguid' => $authenticator->aaguid,
'credentialPublicKey' => $authenticator->credential_public_key,
'counter' => $authenticator->counter,
'otherUI' => $authenticator->other_ui,
'userHandle' => base64_encode($authenticator->user_id),
];
return PublicKeyCredentialSource::createFromArray($data);
}
return null;
}
/**
* @return PublicKeyCredentialSource[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
{
$user_id = base64_decode($publicKeyCredentialUserEntity->getId());
$authenticators = Authenticator::where('user_id', $user_id)->select();
$sources = [];
foreach($authenticators as $authenticator)
{
$data = [
'publicKeyCredentialId' => $authenticator->public_key_credential_id,
'type' => $authenticator->type,
'transports' => $authenticator->transports,
'attestationType' => $authenticator->attestation_type,
'trustPath' => $authenticator->trust_path,
'aaguid' => $authenticator->aaguid,
'credentialPublicKey' => $authenticator->credential_public_key,
'counter' => $authenticator->counter,
'otherUI' => $authenticator->other_ui,
'userHandle' => base64_encode($authenticator->user_id),
];
$source = PublicKeyCredentialSource::createFromArray($data);
$sources[] = $source;
}
return $sources;
}
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
{
$row_id = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
if (Authenticator::where('row_id', $row_id)->find()) return;
$authenticator = new Authenticator();
$data = $publicKeyCredentialSource->jsonSerialize();
$authenticator->display_name = strtoupper(substr(md5(time() . mt_rand(0, 1000)), 0, 8)); // 随机生成一个认证器名称
$authenticator->create_time = date('Y-m-d H:i:s');
$authenticator->row_id = $row_id;
$authenticator->public_key_credential_id = $data['publicKeyCredentialId'];
$authenticator->type = $data['type'];
$authenticator->transports = $data['transports'];
$authenticator->attestation_type = $data['attestationType'];
$authenticator->trust_path = $data['trustPath'];
$authenticator->aaguid = $data['aaguid'];
$authenticator->credential_public_key = $data['credentialPublicKey'];
$authenticator->counter = $data['counter'];
$authenticator->other_ui = $data['otherUI'];
$authenticator->user_id = base64_decode($data['userHandle']); // userHandle 实际上就是 base64_encode 后的 user_id,这里为了关联数据表,decode 了
$authenticator->save();
}
}
新建认证器-前端
按照大佬文章中的说明,先编写两个转换函数
javascript
function bufferDecode(str){
return Uint8Array.from(str, c=>c.charCodeAt(0));
}
function array2b64String(a) {
return window.btoa(String.fromCharCode(...a));
}
然后新建部分
javascript
try {
if (!window.PublicKeyCredential) {
notify('错误', '您的浏览器不支持 WebAuthn');
}
$.get('/sso/webauthn/new', function (data) {
data.user.id = bufferDecode(data.user.id);
data.challenge = bufferDecode(data.challenge);
if (data.excludeCredentials) {
data.excludeCredentials = data.excludeCredentials.map((item) => {
item.id = bufferDecode(item.id);
return item;
});
}
navigator.credentials.create({publicKey: data}).then((credentialInfo) => {
return {
id: credentialInfo.id,
type: credentialInfo.type,
rawId: array2b64String(new Uint8Array(credentialInfo.rawId)),
response: {
clientDataJSON: array2b64String(new Uint8Array(credentialInfo.response.clientDataJSON)),
attestationObject: array2b64String(new Uint8Array(credentialInfo.response.attestationObject))
}
};
}).then((authenticatorResponse) => {
$.post('/sso/webauthn/save', authenticatorResponse, function (res) {
if (res.error === 0) {
notify('成功', '验证器已创建');
setTimeout(function () {
window.location.reload();
}, 1500);
} else {
notify('错误', res.msg);
btn.attr('disabled', false);
btn.text('新建验证器');
}
}, 'json');
// 可以发送了
}).catch((error) => {
console.warn(error); // 捕获错误
notify('错误', '新建失败');
btn.attr('disabled', false);
btn.text('新建验证器');
});
}, 'json');
} catch (e) {
notify('错误', '新建失败');
btn.attr('disabled', false);
btn.text('新建验证器');
}
新建认证器-后端
WebAuthn 控制器中引入所需类
php
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\Server;
然后编写一个私有方法用于创建服务器信息
php
private function server()
{
// RP Entity
$rpEntity = new PublicKeyCredentialRpEntity(
'FoskyTech ID', // 名称
'localhost', // ID,需与域名相同或者包含关系,调试可用 localhost
null // 图标
);
$publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository();
$server = new Server(
$rpEntity,
$publicKeyCredentialSourceRepository
);
$server->setSecuredRelyingPartyId(['localhost']); // ID 为 localhost 时加上这行
return $server;
}
我们在 WebAuthn 控制器中分别编写 new 和 save 方法
php
public function new()
{
if (!session('?userId')) return json([
'error' => 1,
'msg' => '请先登录'
]);
$this->user = User::find(session('userId'));
if (!$this->user) return json([
'error' => 1,
'msg' => '请先登录'
]);
$userEntity = new PublicKeyCredentialUserEntity(
$this->user['username'], // 用户名
$this->user->user_id, // 用户 ID
$this->user->profile->nickname, // 展示名称
$this->user->profile->avatar // 头像
);
$server = $this->server();
$credentialSourceRepository = new PublicKeyCredentialSourceRepository();
$credentialSources = $credentialSourceRepository->findAllForUserEntity($userEntity);
$excludeCredentials = array_map(function (PublicKeyCredentialSource $credential) {
return $credential->getPublicKeyCredentialDescriptor();
}, $credentialSources);
$authenticator_type = AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE;
$user_verification = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED;
$resident_key = true;
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
$authenticator_type,
$resident_key,
$user_verification
); // 这里是为了免用户名登录,但似乎安卓对免用户名登录还不适配?
$publicKeyCredentialCreationOptions = $server->generatePublicKeyCredentialCreationOptions(
$userEntity,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
$excludeCredentials,
$authenticatorSelectionCriteria
);
$options = $publicKeyCredentialCreationOptions->jsonSerialize();
$options['challenge'] = base64_encode($options['challenge']);
// 这里是因为我发现前端创建后传回的 challenge 值被 base64 了,我没有很好的解决办法,只能把 session 中的 challenge 先给 base64 处理了
session('webauthn.options', json_encode($options));
return json($publicKeyCredentialCreationOptions);
}
php
public function save()
{
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator(
$psr17Factory, // ServerRequestFactory
$psr17Factory, // UriFactory
$psr17Factory, // UploadedFileFactory
$psr17Factory // StreamFactory
);
$serverRequest = $creator->fromGlobals();
if (!session('?userId')) return json([
'error' => 1,
'msg' => '请先登录'
]);
$this->user = User::find(session('userId'));
if (!$this->user) return json([
'error' => 1,
'msg' => '请先登录'
]);
$userEntity = new PublicKeyCredentialUserEntity(
$this->user['username'], //Name
AES::encrypt($this->user->user_id), //ID
$this->user->profile->nickname, //Display name
$this->user->profile->avatar //Icon
);
try {
$server = $this->server();
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromString(session('webauthn.options'));
session('webauthn.options', null);
// 这里验证
$publicKeyCredentialSource = $server->loadAndCheckAttestationResponse(
json_encode(input('post.')),
$publicKeyCredentialCreationOptions, // The options you stored during the previous step
$serverRequest // The PSR-7 request
);
// 验证通过就保存认证器
$publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository();
$publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource);
// If you create a new user account, you should also save the user entity
// $userEntityRepository->save($userEntity);
return json([
'error' => 0,
'msg' => '新建成功'
]);
} catch (\Throwable $exception) {
// Something went wrong!
return json([
'error' => 1,
'msg' => $exception->getMessage()
]);
}
}
认证器登录-前端
html
<form method="post" id="login-form">
...
<div class="sso-form-item">
<button type="submit" class="btn btn-primary btn-block" id="login-submit">登录</button>
</div>
<div class="sso-form-item">
<button type="button" class="btn btn-success btn-block" id="login-webauthn">使用验证器</button>
</div>
...
</form>
javascript
$('#login-webauthn').click(function () {
let btn = $(this);
btn.html('<i class="fa fa-circle-o-notch fa-spin"></i>');
btn.attr('disabled', true);
$('#login-form').find('input').attr('disabled', true);
$('#login-submit').attr('disabled', true);
try {
if (!window.PublicKeyCredential) {
notify('错误', '您的浏览器不支持 WebAuthn');
}
$.get('/sso/webauthn/options', async function (data) {
// data.user.id = bufferDecode(data.user.id);
data.challenge = bufferDecode(data.challenge);
if (data.excludeCredentials) {
data.excludeCredentials = data.excludeCredentials.map((item) => {
item.id = bufferDecode(item.id);
return item;
});
}
navigator.credentials.get({publicKey: data}).then((credentialInfo) => {
return {
authenticatorAttachment: credentialInfo.authenticatorAttachment,
id: credentialInfo.id,
type: credentialInfo.type,
rawId: array2b64String(new Uint8Array(credentialInfo.rawId)),
response: {
clientDataJSON: array2b64String(new Uint8Array(credentialInfo.response.clientDataJSON)),
signature: array2b64String(new Uint8Array(credentialInfo.response.signature)),
authenticatorData: array2b64String(new Uint8Array(credentialInfo.response.authenticatorData)),
userHandle: array2b64String(new Uint8Array(credentialInfo.response.userHandle))
}
};
}).then((authenticatorResponse) => {
$.post('/sso/webauthn/login', authenticatorResponse, function (res) {
if (res.error === 0) {
layer.msg('验证成功');
btn.text('验证成功');
setTimeout(function () {
window.location.href = '{php}echo $redirectTo;{/php}';
}, 1500);
} else {
layer.msg(res.msg);
btn.attr('disabled', false);
btn.text('使用验证器');
$('#login-form').find('input').attr('disabled', false);
$('#login-submit').attr('disabled', false);
}
}, 'json');
}).catch((error) => {
console.warn(error); // 捕获错误
layer.msg('超时或用户取消');
btn.attr('disabled', false);
btn.text('使用验证器');
$('#login-form').find('input').attr('disabled', false);
$('#login-submit').attr('disabled', false);
});
}, 'json');
} catch (e) {
layer.msg('发起请求错误');
btn.attr('disabled', false);
btn.text('使用验证器');
$('#login-form').find('input').attr('disabled', false);
$('#login-submit').attr('disabled', false);
}
});
认证器登录-后端
在原先的 WebAuthn 控制器中再编写两个函数
php
public function options()
{
$server = $this->server();
$publicKeyCredentialRequestOptions = $server->generatePublicKeyCredentialRequestOptions(
PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED
); // 用于生成免用户名登录的参数
$options = $publicKeyCredentialRequestOptions->jsonSerialize();
$options['challenge'] = base64_encode($options['challenge']);
session('webauthn.login', json_encode($options));
return json($publicKeyCredentialRequestOptions);
}
php
public function login()
{
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator(
$psr17Factory, // ServerRequestFactory
$psr17Factory, // UriFactory
$psr17Factory, // UploadedFileFactory
$psr17Factory // StreamFactory
);
$serverRequest = $creator->fromGlobals();
try {
$server = $this->server();
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString(session('webauthn.login'));
session('webauthn.login', null);
if (!input('?post.response.userHandle') || !input('?post.rawId')) {
return json([
'error' => 1,
'msg' => '错误'
]);
}
$user_id = base64_decode(base64_decode(input('post.response.userHandle')));
if (!$user = User::find($user_id)) {
return json([
'error' => 1,
'msg' => '错误'
]);
}
$userEntity = new PublicKeyCredentialUserEntity(
$user->username,
$user->user_id,
$user->profile->nickname,
$user->profile->avatar
);
$post = input('post.');
$post['response']['userHandle'] = base64_decode($post['response']['userHandle']); // 还是 base64 导致值不一致的问题,我不知道问题出在哪,只能这样
$server->loadAndCheckAssertionResponse(
json_encode($post),
$publicKeyCredentialRequestOptions,
$userEntity,
$serverRequest
); // 需要注意这里是 assertion,而前面是 attestation,不要弄混
// 验证完毕后存储 session
session('userId', $user['user_id']);
return json([
'error' => 0,
'msg' => '认证成功'
]);
} catch (\Throwable $exception) {
// Something went wrong!
return json([
'error' => 1,
'msg' => $exception->getMessage()
]);
}
}
[1] blog.fosky.top/usr/uploads...
[2] blog.fosky.top/usr/uploads...
[3] blog.fosky.top/usr/uploads...