WebAuthn 在 php 中的实践

前言

偶然发现 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.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...](https://link.juejin.cn?target=https%3A%2F%2Fblog.fosky.top%2Fusr%2Fuploads%2F2023%2F11%2F4160159154.png "https://blog.fosky.top/usr/uploads/2023/11/4160159154.png") \[2\] [blog.fosky.top/usr/uploads...](https://link.juejin.cn?target=https%3A%2F%2Fblog.fosky.top%2Fusr%2Fuploads%2F2023%2F11%2F892259591.png "https://blog.fosky.top/usr/uploads/2023/11/892259591.png") \[3\] [blog.fosky.top/usr/uploads...](https://link.juejin.cn?target=https%3A%2F%2Fblog.fosky.top%2Fusr%2Fuploads%2F2023%2F11%2F1330496042.png "https://blog.fosky.top/usr/uploads/2023/11/1330496042.png") \[4\] [blog.fosky.top/usr/uploads...](https://link.juejin.cn?target=https%3A%2F%2Fblog.fosky.top%2Fusr%2Fuploads%2F2023%2F11%2F1848554416.png "https://blog.fosky.top/usr/uploads/2023/11/1848554416.png") \[5\] [blog.fosky.top/usr/uploads...](https://link.juejin.cn?target=https%3A%2F%2Fblog.fosky.top%2Fusr%2Fuploads%2F2023%2F11%2F4168763102.png "https://blog.fosky.top/usr/uploads/2023/11/4168763102.png")

相关推荐
brzhang3 分钟前
代码越写越乱?掌握这 5 种架构模式,小白也能搭出清晰系统!
前端·后端·架构
Asthenia04125 分钟前
为什么MySQL关联查询要“小表驱动大表”?深入解析与模拟面试复盘
后端
南雨北斗7 分钟前
分布式系统中如何保证数据一致性
后端
Asthenia041211 分钟前
Feign结构与请求链路详解及面试重点解析
后端
左灯右行的爱情14 分钟前
缓存并发更新的挑战
jvm·数据库·redis·后端·缓存
brzhang18 分钟前
告别『上线裸奔』!一文带你配齐生产级 Web 应用的 10 大核心组件
前端·后端·架构
shepherd11119 分钟前
Kafka生产环境实战经验深度总结,让你少走弯路
后端·面试·kafka
袋鱼不重32 分钟前
Cursor 最简易上手体验:谷歌浏览器插件开发3s搞定!
前端·后端·cursor
嘻嘻哈哈开森34 分钟前
Agent 系统技术分享
后端
用户40993225021235 分钟前
异步IO与Tortoise-ORM的数据库
后端·ai编程·trae