php 接入 WebAuthn 登录
字节数据传输(关键问题)
解决方法:使用 base64 编解码;
接下来 需要解决 base64 算法不一致问题,
例如:浏览器自带的 js
例如:php 自带的
实际,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);
}
}
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);
}
准备工作
一个 php 的 CBOR 解码库
一个 php 的 pem 转换库, 因为从浏览器获取的公钥是没法直接通过 openssl_get_publickey
加载的
这些都可以通过一个库解决,不过依赖库比较多
步骤
- 第一步,注册 Passkey, 在已登录的情况下,点击一个按钮进行注册
$('.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);
- 注册需要的数据
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
]
];
- 保存注册成功的公钥
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 保存公钥
}
- 登录页添加,使用 Passkey 登录的按钮
$('.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);
- 登录需要的数据
passkey/login_option
return [
'challenge' => $challenge, // 防止重复操作的随机的字符串
'timeout' => $timeout * 1000,
'rpId' => request()->host(),
'allowCredentials' => [],
'userVerification' => 'preferred'
];
- 验证登录数据,登录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);
}
源码
转载请保留原文链接: https://www.zodream.cn/blog/id/246.html