WebAuthn 在 php 中的实践

  • A+
所属分类:PHP

文章目录

前言

偶然发现 https://store.typecho.work/tepass/signin 中的“使用通行密钥登陆”按钮。

查询资料后知道这其实就是 WebAuthn,便打算在自己的用户系统中加入此功能,顺便写了这篇文章记录一下。

至于为什么是 php,纯粹是因为用户系统是拿 php 开发的。

效果

WebAuthn 在 php 中的实践

WebAuthn 在 php 中的实践

WebAuthn 在 php 中的实践

WebAuthn 在 php 中的实践

介绍

基本信息

WebAuthn,全称 Web Authentication,是由 FIDO 联盟(Fast IDentity Online Alliance)和 W3C(World Wide Web Consortium)联合制定的一套新的身份认证标准,旨在为网络身份验证提供一种更强大、更安全的方式,使用户能够使用他们的设备(如手机、USB 密钥或生物识别器)来进行身份验证,而无需使用密码。该标准于 2019 年 3 月 4 日正式成为 W3C 的推荐标准。目前主流的浏览器已经支持 WebAuthn,包括 Chrome、Firefox、Edge 和 Safari,更详细的支持情况可以通过 https://webauthn.me/browser-support 查看。

相关资料

当然光是纸上谈兵是不够的,我们需要动手实践。

然而国内文档少的可怜,官网 webauthn.io 上也没有 php 的库(可能是因为 php 已死)。自己在网上搜了下找到了这个库:https://github.com/web-auth/webauthn-framework ,虽然文档也写的不清不楚的,看的人麻了。

最后找到这篇还不错的文章,详细介绍了认证的流程:https://flyhigher.top/develop/2160.html ,虽然只有前端部分,后端 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

动手编写

先建立认证器的数据表

类中认证器参数是小驼峰命名的,数据库中我为了命名统一,以下划线命名

WebAuthn 在 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

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();
    }
}

新建认证器-前端

按照大佬文章中的说明,先编写两个转换函数

function bufferDecode(str){
  return Uint8Array.from(str, c=>c.charCodeAt(0));
}

function array2b64String(a) {
  return window.btoa(String.fromCharCode(...a));
}

然后新建部分

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 控制器中引入所需类

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;

然后编写一个私有方法用于创建服务器信息

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 方法

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);
}
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()
       ]);
    }
}

认证器登录-前端

<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>
$('#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 控制器中再编写两个函数

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);
}
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()
       ]);
    }
}
  • 我的微信
  • 这是我的微信扫一扫
  • weinxin
  • 我的微信公众号
  • 我的微信公众号扫一扫
  • weinxin

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: