惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
A
About on SuperTechFans
Apple Machine Learning Research
Apple Machine Learning Research
The Register - Security
The Register - Security
Vercel News
Vercel News
H
Hackread – Cybersecurity News, Data Breaches, AI and More
人人都是产品经理
人人都是产品经理
MyScale Blog
MyScale Blog
云风的 BLOG
云风的 BLOG
博客园_首页
U
Unit 42
T
Tailwind CSS Blog
G
GRAHAM CLULEY
F
Full Disclosure
V
Vulnerabilities – Threatpost
T
Tenable Blog
月光博客
月光博客
P
Privacy & Cybersecurity Law Blog
P
Privacy International News Feed
K
Kaspersky official blog
Scott Helme
Scott Helme
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
N
News and Events Feed by Topic
T
The Exploit Database - CXSecurity.com
N
News and Events Feed by Topic
有赞技术团队
有赞技术团队
Recent Commits to openclaw:main
Recent Commits to openclaw:main
L
LINUX DO - 最新话题
Recorded Future
Recorded Future
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Help Net Security
Help Net Security
The GitHub Blog
The GitHub Blog
Cisco Talos Blog
Cisco Talos Blog
SecWiki News
SecWiki News
P
Proofpoint News Feed
Security Latest
Security Latest
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
罗磊的独立博客
S
Security Affairs
M
MIT News - Artificial intelligence
L
LINUX DO - 热门话题
美团技术团队
Simon Willison's Weblog
Simon Willison's Weblog
T
Threat Research - Cisco Blogs
Stack Overflow Blog
Stack Overflow Blog
Forbes - Security
Forbes - Security
Hugging Face - Blog
Hugging Face - Blog
博客园 - Franky
V
Visual Studio Blog

zodream梦想开源/个人编程日记

文件解析笔记-zodream梦想开源/个人编程日记 密码本开发笔记之读写与保存-zodream梦想开源/个人编程日记 SkiaSharp 把 pixel byte[] 转成 SKBitmap-zodream梦想开源/个人编程日记 nas 使用 Docker 安装 gogs-zodream梦想开源/个人编程日记 复制 android 手机中的文件到电脑-zodream梦想开源/个人编程日记 周报:寻找优质的周刊-zodream梦想开源/个人编程日记 开发日志:对Markdown的代码块新增引用来源支持-zodream梦想开源/个人编程日记 周报:怎么写技术类的教程文章-zodream梦想开源/个人编程日记 css display:flex 布局尺寸超出问题-zodream梦想开源/个人编程日记 周报:SEO优化的思考-zodream梦想开源/个人编程日记 Edge 浏览器不适用 Edge Image Viewer 打开图片 -zodream梦想开源/个人编程日记 SEO 学习笔记(一) 内容来源-zodream梦想开源/个人编程日记 PHP 实现双因素身份认证(2FA)-zodream梦想开源/个人编程日记 WPF MVVM 获取List 多选数据-zodream梦想开源/个人编程日记 Burp Suite 抓包-zodream梦想开源/个人编程日记 使用 indexnow 注意事项-zodream梦想开源/个人编程日记 Godot 使用字体图标 例如: Iconfont、FontAwesome-zodream梦想开源/个人编程日记 angular 15 对指定页面进行访问限制-zodream梦想开源/个人编程日记 CSS 使用 column-count 实现瀑布流出现内容分割的解决办法-zodream梦想开源/个人编程日记 input 确认按键事件在手机端不生效-zodream梦想开源/个人编程日记 C# 使用socket 进行通讯-zodream梦想开源/个人编程日记 Maui开发中Windows应用开启管理员权限-zodream梦想开源/个人编程日记 Maui 中自定义控件-zodream梦想开源/个人编程日记 angular 14 使用 ng-template 实现tree 结构显示-zodream梦想开源/个人编程日记 c# 动态安装和卸载dll-zodream梦想开源/个人编程日记 慎用 CompositionTarget.Rendering-zodream梦想开源/个人编程日记 c# 重写 c++ 程序笔记:数据初始化-zodream梦想开源/个人编程日记 源码编译 aseprite-zodream梦想开源/个人编程日记 记录一下字符串分隔split各语言之间的不同-zodream梦想开源/个人编程日记 c# Gzip解码无头内容-zodream梦想开源/个人编程日记 Windows 10 查看内存占用-zodream梦想开源/个人编程日记 UWP 使用 win2d:加阴影-zodream梦想开源/个人编程日记 清除 PowerShell 历史记录-zodream梦想开源/个人编程日记 c# 重写 c++ 程序笔记:遍历-zodream梦想开源/个人编程日记 Net Core 与 UWP 共用类开发-zodream梦想开源/个人编程日记 hashcat(二)找回rar解压密码-zodream梦想开源/个人编程日记 Godot 学习笔记(一)-zodream梦想开源/个人编程日记 angular 12 显示数学公式-zodream梦想开源/个人编程日记 js 监听按键事件-zodream梦想开源/个人编程日记 angular 12 ng-deep 使用注意事项-zodream梦想开源/个人编程日记 angular 12 动画执行完成事件-zodream梦想开源/个人编程日记 angular 12 中单例 Service 的使用-zodream梦想开源/个人编程日记 js 实现一个正则替换-zodream梦想开源/个人编程日记 uwp win2d 使用-zodream梦想开源/个人编程日记 UWP 读取应用内资源-zodream梦想开源/个人编程日记 gin 使用笔记(二)出错点-zodream梦想开源/个人编程日记 gin 使用笔记(一)基础-zodream梦想开源/个人编程日记 angular 关于自定义组件事件传递-zodream梦想开源/个人编程日记 apache 使用gzip 压缩 js、css-zodream梦想开源/个人编程日记 关于内容中的 @用户 加 话题 的一些想法-zodream梦想开源/个人编程日记 Github Host 更改-zodream梦想开源/个人编程日记 OBS-Studio 等录屏软件录制显示器内容的黑屏的解决方法-zodream梦想开源/个人编程日记 angular 11 ngrx/effects 使用理解-zodream梦想开源/个人编程日记 angular 11 ngrx/store 使用理解-zodream梦想开源/个人编程日记 angular 10 直接获取表单值-zodream梦想开源/个人编程日记 htaccess 搭配 angular 10 放在二级目录-zodream梦想开源/个人编程日记 微信小程序跨页面传值-zodream梦想开源/个人编程日记 js 对 FileList 进行文件过滤上传-zodream梦想开源/个人编程日记 angular自定义表单组件支持 formControlName-zodream梦想开源/个人编程日记 flutter CupertinoPicker 使用不显示-zodream梦想开源/个人编程日记 CC协议-zodream梦想开源/个人编程日记 flutter margin 负值实现-zodream梦想开源/个人编程日记 win10添加删除开机自启项-zodream梦想开源/个人编程日记 微信小程序开发记录(一)真机无法进入页面-zodream梦想开源/个人编程日记 flutter 跳转页面操作上一页-zodream梦想开源/个人编程日记 Regex Generator 使用指南-zodream梦想开源/个人编程日记 go init函数-zodream梦想开源/个人编程日记 angular 9 升级 angular 10-zodream梦想开源/个人编程日记 kotlin AndroidManifest 注意事项-zodream梦想开源/个人编程日记 对于zodream 框架的优化的思考-zodream梦想开源/个人编程日记 flutter 页面滚动条-zodream梦想开源/个人编程日记 flutter swiper 使用-zodream梦想开源/个人编程日记 flutter API请求-zodream梦想开源/个人编程日记 网站体检之xss攻击-zodream梦想开源/个人编程日记 GitHub 下载慢怎么办-zodream梦想开源/个人编程日记 小程序商城开发总结-zodream梦想开源/个人编程日记 gulp-vue2mini 更新日志-zodream梦想开源/个人编程日记 UWP TextBlock 换行-zodream梦想开源/个人编程日记 js 数组排序 sort-zodream梦想开源/个人编程日记 抛弃create.js 改用pixi.js-zodream梦想开源/个人编程日记 后台列表页进阶之路-zodream梦想开源/个人编程日记 局域网文件传输工具-zodream梦想开源/个人编程日记 angular9教程之PullToRefresh-zodream梦想开源/个人编程日记 c# 添加防火墙例外端口-zodream梦想开源/个人编程日记 c# 获取局域网ip-zodream梦想开源/个人编程日记 angular9教程之自定义部件及获取页面元素-zodream梦想开源/个人编程日记 netcore 依赖注入 AddTransient、AddScoped、AddSingleton-zodream梦想开源/个人编程日记 createjs 实现封装 drawImage-zodream梦想开源/个人编程日记 笔记(200521)游戏及玩家分类-zodream梦想开源/个人编程日记 关于 MasterDetail 模式页面收集-zodream梦想开源/个人编程日记 怎么写日记(一)格式规范-zodream梦想开源/个人编程日记 angular9教程之路由-zodream梦想开源/个人编程日记 PHP 常用框架-zodream梦想开源/个人编程日记 angular9教程之监听子路由变化-zodream梦想开源/个人编程日记 angular9教程之添加启动加载动画-zodream梦想开源/个人编程日记 angular9教程之分模块开发-zodream梦想开源/个人编程日记 angular9教程之使用-zodream梦想开源/个人编程日记 create.js 开发小游戏(二)蒙版-zodream梦想开源/个人编程日记 createjs 游戏结束后重新开始 Tween 补间动画无效-zodream梦想开源/个人编程日记 SEO(二)关键词优化试验-zodream梦想开源/个人编程日记
php 接入 WebAuthn 登录-zodream梦想开源/个人编程日记
zodream · 2023-10-14 · via zodream梦想开源/个人编程日记

php 接入 WebAuthn 登录

字节数据传输(关键问题)

解决方法:使用 base64 编解码;

接下来 需要解决 base64 算法不一致问题,

例如:浏览器自带的 js

window.btoa(val: string): string // base64 编码
window.atob(val: string): string // base64 解码

12

例如:php 自带的

base64_encode(val: string): string // base64 编码
base64_decode(val: string): string // base64 解码

12

实际,webAuthn 一些数据是 ArrayBuffer 所以默认的就不行了

js base64 处理

class Base64 {

    private static chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
    private static lookup = new Uint8Array(256);
    private static booted = false;

    private static ready() {
        if (this.booted) {
            return;
        }
        this.booted = true;
        for (let i = 0; i < this.chars.length; i++) {
            this.lookup[this.chars.charCodeAt(i)] = i;
        }
    }

    public static encode(arraybuffer: ArrayBuffer): string {
        const bytes = new Uint8Array(arraybuffer);
        const len = bytes.length;
        let base64 = '';

        for (let i = 0; i < len; i += 3) {
            base64 += this.chars[bytes[i] >> 2];
            base64 += this.chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
            base64 += this.chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
            base64 += this.chars[bytes[i + 2] & 63];
        }

        if (len % 3 === 2) {
            base64 = base64.substring(0, base64.length - 1);
        } else if (len % 3 === 1) {
            base64 = base64.substring(0, base64.length - 2);
        }

        return base64;
    }

    public static decode(base64: string): ArrayBuffer {
        const len = base64.length;
        const bufferLength = len * 0.75;
        const arraybuffer = new ArrayBuffer(bufferLength);
        const bytes = new Uint8Array(arraybuffer);

        let p = 0;
        for (let i = 0; i < len; i += 4) {
            const encoded1 = this.lookup[base64.charCodeAt(i)];
            const encoded2 = this.lookup[base64.charCodeAt(i + 1)];
            const encoded3 = this.lookup[base64.charCodeAt(i + 2)];
            const encoded4 = this.lookup[base64.charCodeAt(i + 3)];

            bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
            bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
            bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
        }

        return arraybuffer;
    }

    public static toBuffer(val: string): ArrayBuffer {
        const items: number[] = [];
        for (let i = 0; i < val.length; i++) {
            items.push(val.charCodeAt(i));
        }
        return new Uint8Array(items);
    }
}

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566

php 实际只要实现一个 base64 解码就行了,其他的通过字符串传给前端,前端在转成 ArrayBuffer


    public static function decodeBase64(string $base64): string {
        static $lookup = [];
        if (empty($lookup)) {
            $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
            for ($i = 0; $i < strlen($chars); $i ++) {
                $lookup[ord(substr($chars, $i, 1))] = $i;
            }
        }
        $len = strlen($base64);
        $maxLen = (int)floor($len * .75);
        $buffer = [];
        for ($i = 0; $i < $len; $i += 4) {
            $encoded1 = $lookup[ord(substr($base64, $i, 1))];
            $encoded2 = $lookup[ord(substr($base64, $i + 1, 1))];
            $encoded3 = $lookup[ord(substr($base64, $i + 2, 1))];
            $encoded4 = $lookup[ord(substr($base64, $i + 3, 1))];

            $buffer[] = chr(($encoded1 << 2) | ($encoded2 >> 4));
            $buffer[] = chr((($encoded2 & 15) << 4) | ($encoded3 >> 2));
            $buffer[] = chr((($encoded3 & 3) << 6) | ($encoded4 & 63));
        }
        if (count($buffer) > $maxLen) {
            array_splice($buffer, $maxLen);
        }
        return implode('', $buffer);
    }

123456789101112131415161718192021222324252627

准备工作

一个 php 的 CBOR 解码库

composer install spomky-labs/cbor-php

1

一个 php 的 pem 转换库, 因为从浏览器获取的公钥是没法直接通过 openssl_get_publickey 加载的

这些都可以通过一个库解决,不过依赖库比较多

composer install web-auth/webauthn-framework

1

步骤

  1. 第一步,注册 Passkey, 在已登录的情况下,点击一个按钮进行注册
<button class="register-webauth">注册Passkey</button>

1

$('.register-webauth').on('click',function() {
    if (!navigator.credentials) {
        return;
    }
    // 从后台获取注册需要数据,包含当前登录账户的id
    $.getJSON(baseUri + '/passkey/register_option', res => {
        const data = res.data;
        data.challenge = Base64.toBuffer(data.challenge);
        data.user.id = Base64.toBuffer(data.user.id);
        navigator.credentials.create({
            publicKey: data
        })
        .then((credential: any) => {
            const response = credential.response as AuthenticatorAttestationResponse;
            // 保存注册成功的 credentialId 和 公钥
            $.post(baseUri + '/passkey/register', {credential: {
                id: credential.id,
                clientDataJSON: Base64.encode(response.clientDataJSON),
                attestationObject: Base64.encode(response.attestationObject),
                publicKeyAlgorithm: response.getPublicKeyAlgorithm(),
            }}, res => {}, 'json');
        })
        .catch(console.error);
    });

}).toggle(!!navigator.credentials);

1234567891011121314151617181920212223242526

  1. 注册需要的数据

passkey/register_option

return [
    'challenge' => $challenge, // 随机的字符串,防止重复操作,需要保存,获取到 公钥后需要验证
    'rp' => [
        'name' => Option::value('site_title'),
        'id' => request()->host()
    ],
    'user' => [
        'id' => (string)$user->getIdentity(), // 用户id
        'name' => $user->email,               // 用户邮箱
        'displayName' => $user->name          // 显示的名
    ],
    'pubKeyCredParams' => [[
        'alg' => -7,                          // ES256 公钥类型
        'type' => 'public-key'
    ], [
        'type' => 'public-key',
        'alg' => -257                         // RS256 公钥类型,这个选项好像是必须的,不然可能不成功
    ]],
    'timeout' => $timeout * 1000,
    'excludeCredentials' => [],
    'attestation' => 'none',
    'authenticatorSelection' => [
        'authenticatorAttachment' => "platform",
        "residentKey" => "preferred",
        'requireResidentKey' => false,
        'userVerification' => 'preferred'
    ],
    'extensions' => [
        'credProps' => true
    ]
];

12345678910111213141516171819202122232425262728293031

  1. 保存注册成功的公钥

passkey/register

public static function register(array $credential): void {
    $clientDataJSON = Json::decode(base64_decode($credential['clientDataJSON']));
    if ($clientDataJSON['type'] !== 'webauthn.create') {
        throw new \Exception('type is error');
    }
    $challenge = base64_decode($clientDataJSON['challenge']);
    // TODO 验证临时

    $obj = static::parseAuthenticatorData($credential['attestationObject']);
    // 解码 attestationObject,获取 公钥
    if (empty($obj) || empty($obj['publicKey'])) {
        throw new Exception('attestation is error');
    }
    self::saveCredential($credential['id'], $obj['publicKey'],
        intval($credential['publicKeyAlgorithm']));
    // TODO 保存公钥
}

1234567891011121314151617

  1. 登录页添加,使用 Passkey 登录的按钮
<button class="login-webauth">Passkey 登录</button>

1

$('.login-webauth').on('click',function() {
    if (!navigator.credentials) {
        return;
    }
    // 从后台获取登录需要数据
    $.getJSON(baseUri + '/passkey/login_option', res => {
        const data = res.data;
        data.challenge = Base64.toBuffer(data.challenge);
        navigator.credentials.get({
            publicKey: data
        })
        .then((credential: any) => {
            const response = credential.response as AuthenticatorAssertionResponse;
            // 获取登录结果,验证数据有效,根据id登录
            $.post(baseUri + '/passkey/login', {
                credential: {
                    id: credential.id,
                    clientDataJSON: Base64.encode(response.clientDataJSON),
                    authenticatorData: Base64.encode(response.authenticatorData),
                    userHandle: Base64.encode(response.userHandle),
                    signature: Base64.encode(response.signature)
                },
                redirect_uri: $('[name=redirect_uri]').val()
            }, res => {}, 'json');
        })
        .catch(console.error);
    });

}).toggle(!!navigator.credentials);

1234567891011121314151617181920212223242526272829

  1. 登录需要的数据

passkey/login_option

return [
    'challenge' => $challenge, // 防止重复操作的随机的字符串
    'timeout' => $timeout * 1000,
    'rpId' => request()->host(),
    'allowCredentials' => [],
    'userVerification' => 'preferred'
];

1234567

  1. 验证登录数据,登录id

passkey/login

    public static function login(array $credential): void {
        $clientDataJSON = Json::decode(base64_decode($credential['clientDataJSON']));
        $challenge = base64_decode($clientDataJSON['challenge']);
        $key = sprintf('%s-%s', self::REGISTER_KEY, $challenge);
        if (!cache()->has($key)) {
            throw new \Exception('challenge is expired');
        }
        $userId = base64_decode($credential['userHandle']);
        // 可以验证 credentialId 的 hash 值是否一致 
        $signature = $credential['signature'];
        self::loadCredential(intval($userId), $credential['id'], $signature, $credential);
    }

    /**
     * 验证的登录数据
     * @param int $userId
     * @param string $credentialId
     * @param string $signature
     * @param array $credential
     * @return void
     * @throws \Exception
     */
    protected static function loadCredential(int $userId, string $credentialId,  string $signature, array $credential) {
        // 获取保存的 公钥
        $key = '';
        if (empty($key)) {
            throw new \Exception('验证失败');
        }
        $data = CBOR::decodeBase64($credential['authenticatorData']);
        $pkey = openssl_get_publickey($key);
        if (empty($pkey)) {
            throw new Exception('public key is error');
        }
        if (!openssl_verify($data.self::hash(CBOR::decodeBase64($credential['clientDataJSON'])),
            CBOR::decodeBase64($signature), $pkey, \OPENSSL_ALGO_SHA256)) {
            throw new \Exception('signature is error');
        }
        // TODO 登录
    }

    private static function hash(string $val): string {
        return \hash('sha256', $val, true);
    }

12345678910111213141516171819202122232425262728293031323334353637383940414243

源码

PassKey主文件

CBOR

PEM

转载请保留原文链接: https://zodream.cn/blog/id/246.html