mirror of
https://github.com/lkddi/nexusphp.git
synced 2026-04-14 12:30:49 +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()
|
public static function createWebAuthn()
|
||||||
{
|
{
|
||||||
$formats = ['android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm', 'none'];
|
$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)
|
public static function getCreateArgs($userId, $userName)
|
||||||
@@ -28,20 +46,22 @@ class UserPasskeyRepository extends BaseRepository
|
|||||||
return hex2bin($item['credential_id']);
|
return hex2bin($item['credential_id']);
|
||||||
}, $passkey->toArray());
|
}, $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 [
|
||||||
|
'challengeId' => $challengeId,
|
||||||
return $createArgs;
|
'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;
|
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
|
||||||
$attestationObject = !empty($attestationObject) ? base64_decode($attestationObject) : 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);
|
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false);
|
||||||
|
|
||||||
@@ -60,9 +80,13 @@ class UserPasskeyRepository extends BaseRepository
|
|||||||
{
|
{
|
||||||
$WebAuthn = self::createWebAuthn();
|
$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)
|
public static function insertUserPasskey($userId, $AAGUID, $credentialId, $publicKey, $counter)
|
||||||
@@ -77,21 +101,19 @@ class UserPasskeyRepository extends BaseRepository
|
|||||||
Passkey::query()->create($params);
|
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;
|
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
|
||||||
$id = !empty($id) ? base64_decode($id) : null;
|
$id = !empty($id) ? base64_decode($id) : null;
|
||||||
$authenticatorData = !empty($authenticatorData) ? base64_decode($authenticatorData) : null;
|
$authenticatorData = !empty($authenticatorData) ? base64_decode($authenticatorData) : null;
|
||||||
$signature = !empty($signature) ? base64_decode($signature) : null;
|
$signature = !empty($signature) ? base64_decode($signature) : null;
|
||||||
$userHandle = !empty($userHandle) ? base64_decode($userHandle) : 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();
|
$passkey = Passkey::query()->where('credential_id', '=', bin2hex($id))->first();
|
||||||
$credentialPublicKey = $passkey->public_key;
|
if ($passkey === null) {
|
||||||
|
|
||||||
if ($credentialPublicKey === null) {
|
|
||||||
throw new RuntimeException(nexus_trans('passkey.passkey_unknown'));
|
throw new RuntimeException(nexus_trans('passkey.passkey_unknown'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +122,7 @@ class UserPasskeyRepository extends BaseRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (Exception $e) {
|
||||||
throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage());
|
throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class AjaxInterface{
|
|||||||
{
|
{
|
||||||
global $CURUSER;
|
global $CURUSER;
|
||||||
$rep = new \App\Repositories\UserPasskeyRepository();
|
$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)
|
public static function deletePasskey($params)
|
||||||
@@ -222,7 +222,7 @@ class AjaxInterface{
|
|||||||
{
|
{
|
||||||
global $CURUSER;
|
global $CURUSER;
|
||||||
$rep = new \App\Repositories\UserPasskeyRepository();
|
$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();
|
return await PublicKeyCredential.isConditionalMediationAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCreateArgs = async () => {
|
const getArgs = async (type) => {
|
||||||
const getArgsParams = new URLSearchParams();
|
const getArgsParams = new URLSearchParams();
|
||||||
getArgsParams.set('action', 'getPasskeyCreateArgs');
|
getArgsParams.set('action', type === 'create' ? 'getPasskeyCreateArgs' : 'getPasskeyGetArgs');
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -26,18 +26,22 @@ const Passkey = (() => {
|
|||||||
throw new Error(data.msg);
|
throw new Error(data.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createArgs = data.data;
|
const options = data.data.options;
|
||||||
recursiveBase64StrToArrayBuffer(createArgs);
|
recursiveBase64StrToArrayBuffer(options);
|
||||||
return createArgs;
|
return {
|
||||||
|
challengeId: data.data.challengeId,
|
||||||
|
options: options,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRegistration = async () => {
|
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();
|
const processCreateParams = new URLSearchParams();
|
||||||
processCreateParams.set('action', 'processPasskeyCreate');
|
processCreateParams.set('action', 'processPasskeyCreate');
|
||||||
|
processCreateParams.set('params[challengeId]', args.challengeId);
|
||||||
processCreateParams.set('params[transports]', cred.response.getTransports ? cred.response.getTransports() : null)
|
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[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null);
|
||||||
processCreateParams.set('params[attestationObject]', cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : 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;
|
let abortController;
|
||||||
|
|
||||||
const checkRegistration = async (conditional, showLoading) => {
|
const checkRegistration = async (conditional, showLoading) => {
|
||||||
@@ -78,20 +64,21 @@ const Passkey = (() => {
|
|||||||
abortController = null;
|
abortController = null;
|
||||||
}
|
}
|
||||||
if (!conditional) showLoading();
|
if (!conditional) showLoading();
|
||||||
const getArgs = await getGetArgs();
|
const args = await getArgs('get');
|
||||||
|
const options = args.options;
|
||||||
if (conditional) {
|
if (conditional) {
|
||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
getArgs.signal = abortController.signal;
|
options.signal = abortController.signal;
|
||||||
getArgs.mediation = 'conditional';
|
options.mediation = 'conditional';
|
||||||
}
|
}
|
||||||
|
|
||||||
const cred = await navigator.credentials.get(getArgs);
|
const cred = await navigator.credentials.get(options);
|
||||||
|
|
||||||
if (conditional) showLoading();
|
if (conditional) showLoading();
|
||||||
|
|
||||||
const processGetParams = new URLSearchParams();
|
const processGetParams = new URLSearchParams();
|
||||||
processGetParams.set('action', 'processPasskeyGet');
|
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[id]', cred.rawId ? arrayBufferToBase64(cred.rawId) : null);
|
||||||
processGetParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : 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);
|
processGetParams.set('params[authenticatorData]', cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ return [
|
|||||||
'passkey_delete' => 'Delete',
|
'passkey_delete' => 'Delete',
|
||||||
'passkey_unknown' => 'An error occurred while processing your request.',
|
'passkey_unknown' => 'An error occurred while processing your request.',
|
||||||
'passkey_invalid' => 'Invalid passkey data.',
|
'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_error' => 'An error occurred while processing your request. Please try again later.',
|
||||||
'passkey_user_not_found' => 'User not found.',
|
'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.',
|
'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_delete' => '删除',
|
||||||
'passkey_unknown' => '处理您的请求时出错。',
|
'passkey_unknown' => '处理您的请求时出错。',
|
||||||
'passkey_invalid' => '通行密钥数据无效。',
|
'passkey_invalid' => '通行密钥数据无效。',
|
||||||
|
'passkey_timeout' => '操作超时,请重试。',
|
||||||
'passkey_error' => '处理您的请求时出错。请稍后重试。',
|
'passkey_error' => '处理您的请求时出错。请稍后重试。',
|
||||||
'passkey_user_not_found' => '未找到用户。',
|
'passkey_user_not_found' => '未找到用户。',
|
||||||
'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。',
|
'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。',
|
||||||
|
|||||||
Reference in New Issue
Block a user