mirror of
https://github.com/lkddi/nexusphp.git
synced 2026-04-03 14:10:57 +08:00
fix possible replay attack with passkey login
This commit is contained in:
@@ -15,7 +15,25 @@ class UserPasskeyRepository extends BaseRepository
|
||||
public static function createWebAuthn()
|
||||
{
|
||||
$formats = ['android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm', 'none'];
|
||||
return new WebAuthn(get_setting('basic.SITENAME'), nexus()->getRequestHost(), $formats);
|
||||
$rpId = explode(':', nexus()->getRequestHost())[0];
|
||||
return new WebAuthn(get_setting('basic.SITENAME'), $rpId, $formats);
|
||||
}
|
||||
|
||||
private static function putChallenge($challenge): string
|
||||
{
|
||||
$challengeId = bin2hex(random_bytes(32));
|
||||
NexusDB::cache_put("passkey_challenge_{$challengeId}", $challenge, 120);
|
||||
return $challengeId;
|
||||
}
|
||||
|
||||
private static function getChallenge($challengeId)
|
||||
{
|
||||
$challenge = NexusDB::cache_get("passkey_challenge_{$challengeId}") ?? null;
|
||||
if ($challenge == null) {
|
||||
throw new RuntimeException(nexus_trans('passkey.passkey_timeout'));
|
||||
}
|
||||
NexusDB::cache_del("passkey_challenge_{$challengeId}");
|
||||
return $challenge;
|
||||
}
|
||||
|
||||
public static function getCreateArgs($userId, $userName)
|
||||
@@ -28,20 +46,22 @@ class UserPasskeyRepository extends BaseRepository
|
||||
return hex2bin($item['credential_id']);
|
||||
}, $passkey->toArray());
|
||||
|
||||
$createArgs = $WebAuthn->getCreateArgs(bin2hex($userId), $userName, $userName, 60 * 4, true, 'preferred', null, $credentialIds);
|
||||
$createArgs = $WebAuthn->getCreateArgs(bin2hex($userId), $userName, $userName, 120, true, 'preferred', null, $credentialIds);
|
||||
$challengeId = self::putChallenge($WebAuthn->getChallenge());
|
||||
|
||||
NexusDB::cache_put("{$userId}_passkey_challenge", $WebAuthn->getChallenge(), 60 * 4);
|
||||
|
||||
return $createArgs;
|
||||
return [
|
||||
'challengeId' => $challengeId,
|
||||
'options' => $createArgs,
|
||||
];
|
||||
}
|
||||
|
||||
public static function processCreate($userId, $clientDataJSON, $attestationObject)
|
||||
public static function processCreate($userId, $challengeId, $clientDataJSON, $attestationObject)
|
||||
{
|
||||
$WebAuthn = self::createWebAuthn();
|
||||
|
||||
$challenge = self::getChallenge($challengeId);
|
||||
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
|
||||
$attestationObject = !empty($attestationObject) ? base64_decode($attestationObject) : null;
|
||||
$challenge = NexusDB::cache_get("{$userId}_passkey_challenge") ?? null;
|
||||
|
||||
$WebAuthn = self::createWebAuthn();
|
||||
|
||||
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false);
|
||||
|
||||
@@ -60,9 +80,13 @@ class UserPasskeyRepository extends BaseRepository
|
||||
{
|
||||
$WebAuthn = self::createWebAuthn();
|
||||
|
||||
$getArgs = $WebAuthn->getGetArgs(null, 60 * 4, true, true, true, true, true, 'preferred');
|
||||
$getArgs = $WebAuthn->getGetArgs(null, 120, true, true, true, true, true, 'preferred');
|
||||
$challengeId = self::putChallenge($WebAuthn->getChallenge());
|
||||
|
||||
return $getArgs;
|
||||
return [
|
||||
'challengeId' => $challengeId,
|
||||
'options' => $getArgs,
|
||||
];
|
||||
}
|
||||
|
||||
public static function insertUserPasskey($userId, $AAGUID, $credentialId, $publicKey, $counter)
|
||||
@@ -77,21 +101,19 @@ class UserPasskeyRepository extends BaseRepository
|
||||
Passkey::query()->create($params);
|
||||
}
|
||||
|
||||
public static function processGet($challenge, $id, $clientDataJSON, $authenticatorData, $signature, $userHandle)
|
||||
public static function processGet($challengeId, $id, $clientDataJSON, $authenticatorData, $signature, $userHandle)
|
||||
{
|
||||
$WebAuthn = self::createWebAuthn();
|
||||
|
||||
$challenge = self::getChallenge($challengeId);
|
||||
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
|
||||
$id = !empty($id) ? base64_decode($id) : null;
|
||||
$authenticatorData = !empty($authenticatorData) ? base64_decode($authenticatorData) : null;
|
||||
$signature = !empty($signature) ? base64_decode($signature) : null;
|
||||
$userHandle = !empty($userHandle) ? base64_decode($userHandle) : null;
|
||||
$challenge = !empty($challenge) ? base64_decode($challenge) : null;
|
||||
|
||||
$WebAuthn = self::createWebAuthn();
|
||||
|
||||
$passkey = Passkey::query()->where('credential_id', '=', bin2hex($id))->first();
|
||||
$credentialPublicKey = $passkey->public_key;
|
||||
|
||||
if ($credentialPublicKey === null) {
|
||||
if ($passkey === null) {
|
||||
throw new RuntimeException(nexus_trans('passkey.passkey_unknown'));
|
||||
}
|
||||
|
||||
@@ -100,7 +122,7 @@ class UserPasskeyRepository extends BaseRepository
|
||||
}
|
||||
|
||||
try {
|
||||
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, 'preferred');
|
||||
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $passkey->public_key, $challenge, null, false, true);
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ class AjaxInterface{
|
||||
{
|
||||
global $CURUSER;
|
||||
$rep = new \App\Repositories\UserPasskeyRepository();
|
||||
return $rep->processCreate($CURUSER['id'], $params['clientDataJSON'], $params['attestationObject']);
|
||||
return $rep->processCreate($CURUSER['id'], $params['challengeId'], $params['clientDataJSON'], $params['attestationObject']);
|
||||
}
|
||||
|
||||
public static function deletePasskey($params)
|
||||
@@ -222,7 +222,7 @@ class AjaxInterface{
|
||||
{
|
||||
global $CURUSER;
|
||||
$rep = new \App\Repositories\UserPasskeyRepository();
|
||||
return $rep->processGet($params['challenge'], $params['id'], $params['clientDataJSON'], $params['authenticatorData'], $params['signature'], $params['userHandle']);
|
||||
return $rep->processGet($params['challengeId'], $params['id'], $params['clientDataJSON'], $params['authenticatorData'], $params['signature'], $params['userHandle']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
public/js/passkey.js
vendored
47
public/js/passkey.js
vendored
@@ -13,9 +13,9 @@ const Passkey = (() => {
|
||||
return await PublicKeyCredential.isConditionalMediationAvailable();
|
||||
}
|
||||
|
||||
const getCreateArgs = async () => {
|
||||
const getArgs = async (type) => {
|
||||
const getArgsParams = new URLSearchParams();
|
||||
getArgsParams.set('action', 'getPasskeyCreateArgs');
|
||||
getArgsParams.set('action', type === 'create' ? 'getPasskeyCreateArgs' : 'getPasskeyGetArgs');
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
@@ -26,18 +26,22 @@ const Passkey = (() => {
|
||||
throw new Error(data.msg);
|
||||
}
|
||||
|
||||
const createArgs = data.data;
|
||||
recursiveBase64StrToArrayBuffer(createArgs);
|
||||
return createArgs;
|
||||
const options = data.data.options;
|
||||
recursiveBase64StrToArrayBuffer(options);
|
||||
return {
|
||||
challengeId: data.data.challengeId,
|
||||
options: options,
|
||||
};
|
||||
}
|
||||
|
||||
const createRegistration = async () => {
|
||||
const createArgs = await getCreateArgs();
|
||||
const args = await getArgs('create');
|
||||
|
||||
const cred = await navigator.credentials.create(createArgs);
|
||||
const cred = await navigator.credentials.create(args.options);
|
||||
|
||||
const processCreateParams = new URLSearchParams();
|
||||
processCreateParams.set('action', 'processPasskeyCreate');
|
||||
processCreateParams.set('params[challengeId]', args.challengeId);
|
||||
processCreateParams.set('params[transports]', cred.response.getTransports ? cred.response.getTransports() : null)
|
||||
processCreateParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null);
|
||||
processCreateParams.set('params[attestationObject]', cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null);
|
||||
@@ -52,24 +56,6 @@ const Passkey = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
const getGetArgs = async () => {
|
||||
const getArgsParams = new URLSearchParams();
|
||||
getArgsParams.set('action', 'getPasskeyGetArgs');
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
body: getArgsParams,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.ret !== 0) {
|
||||
throw new Error(data.msg);
|
||||
}
|
||||
|
||||
const getArgs = data.data;
|
||||
recursiveBase64StrToArrayBuffer(getArgs);
|
||||
return getArgs;
|
||||
}
|
||||
|
||||
let abortController;
|
||||
|
||||
const checkRegistration = async (conditional, showLoading) => {
|
||||
@@ -78,20 +64,21 @@ const Passkey = (() => {
|
||||
abortController = null;
|
||||
}
|
||||
if (!conditional) showLoading();
|
||||
const getArgs = await getGetArgs();
|
||||
const args = await getArgs('get');
|
||||
const options = args.options;
|
||||
if (conditional) {
|
||||
abortController = new AbortController();
|
||||
getArgs.signal = abortController.signal;
|
||||
getArgs.mediation = 'conditional';
|
||||
options.signal = abortController.signal;
|
||||
options.mediation = 'conditional';
|
||||
}
|
||||
|
||||
const cred = await navigator.credentials.get(getArgs);
|
||||
const cred = await navigator.credentials.get(options);
|
||||
|
||||
if (conditional) showLoading();
|
||||
|
||||
const processGetParams = new URLSearchParams();
|
||||
processGetParams.set('action', 'processPasskeyGet');
|
||||
processGetParams.set('params[challenge]', arrayBufferToBase64(getArgs['publicKey']['challenge']));
|
||||
processGetParams.set('params[challengeId]', args.challengeId);
|
||||
processGetParams.set('params[id]', cred.rawId ? arrayBufferToBase64(cred.rawId) : null);
|
||||
processGetParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null);
|
||||
processGetParams.set('params[authenticatorData]', cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null);
|
||||
|
||||
@@ -11,6 +11,7 @@ return [
|
||||
'passkey_delete' => 'Delete',
|
||||
'passkey_unknown' => 'An error occurred while processing your request.',
|
||||
'passkey_invalid' => 'Invalid passkey data.',
|
||||
'passkey_timeout' => 'Operation timeout, please try again.',
|
||||
'passkey_error' => 'An error occurred while processing your request. Please try again later.',
|
||||
'passkey_user_not_found' => 'User not found.',
|
||||
'passkey_not_supported' => 'Your browser does not support passkey. Please use a modern browser to create and manage your passkey.',
|
||||
|
||||
@@ -11,6 +11,7 @@ return [
|
||||
'passkey_delete' => '删除',
|
||||
'passkey_unknown' => '处理您的请求时出错。',
|
||||
'passkey_invalid' => '通行密钥数据无效。',
|
||||
'passkey_timeout' => '操作超时,请重试。',
|
||||
'passkey_error' => '处理您的请求时出错。请稍后重试。',
|
||||
'passkey_user_not_found' => '未找到用户。',
|
||||
'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。',
|
||||
|
||||
Reference in New Issue
Block a user