Merge pull request #405 from ex-hentai/passkey

support login with Passkeys
This commit is contained in:
xiaomlove
2025-12-20 13:17:43 +07:00
committed by GitHub
15 changed files with 1463 additions and 701 deletions

View File

@@ -40,7 +40,7 @@ Welcome to participate in internationalization work, click [here](https://github
- Section H&R
- TGBot
## System Requirements
- PHP: 8.2|8.3|8.4, must have extensions: bcmath, ctype, curl, fileinfo, json, mbstring, openssl, pdo_mysql, tokenizer, xml, mysqli, gd, redis, pcntl, sockets, posix, gmp, zend opcache, zip, intl, pdo_sqlite, sqlite3
- PHP: 8.2|8.3|8.4|8.5, must have extensions: bcmath, ctype, curl, fileinfo, json, mbstring, openssl, pdo_mysql, tokenizer, xml, mysqli, gd, redis, pcntl, sockets, posix, gmp, zend opcache, zip, intl, pdo_sqlite, sqlite3
- Mysql: 5.7 latest version or above
- Redis4.0.0 or above
- Others: supervisor, rsync

View File

@@ -40,7 +40,7 @@
- TGBot
## 系统要求
- PHP: 8.2|8.3|8.4必须扩展bcmath, ctype, curl, fileinfo, json, mbstring, openssl, pdo_mysql, tokenizer, xml, mysqli, gd, redis, pcntl, sockets, posix, gmp, zend opcache, zip, intl, pdo_sqlite, sqlite3
- PHP: 8.2|8.3|8.4|8.5必须扩展bcmath, ctype, curl, fileinfo, json, mbstring, openssl, pdo_mysql, tokenizer, xml, mysqli, gd, redis, pcntl, sockets, posix, gmp, zend opcache, zip, intl, pdo_sqlite, sqlite3
- Mysql: 5.7 最新版或以上版本
- Redis4.0.0 或以上版本
- 其他supervisor, rsync

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Filament\Resources\User;
use App\Filament\Resources\User\UserPasskeyResource\Pages;
use App\Models\Passkey;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class UserPasskeyResource extends Resource
{
protected static ?string $model = Passkey::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'User';
protected static ?int $navigationSort = 12;
public static function getNavigationLabel(): string
{
return __('passkey.passkey');
}
public static function getBreadcrumb(): string
{
return self::getNavigationLabel();
}
public static function form(Form $form): Form
{
return $form
->schema([
//
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable()
,
Tables\Columns\TextColumn::make('user_id')
->formatStateUsing(fn($state) => username_for_admin($state))
->label(__('label.username'))
,
Tables\Columns\TextColumn::make('AAGUID')
->label("AAGUID")
,
Tables\Columns\TextColumn::make('credential_id')
->label(__('passkey.fields.credential_id'))
,
Tables\Columns\TextColumn::make('counter')
->label(__('passkey.fields.counter'))
,
Tables\Columns\TextColumn::make('created_at')
->label(__('label.created_at'))
,
])
->defaultSort('id', 'desc')
->filters([
Tables\Filters\Filter::make('user_id')
->form([
Forms\Components\TextInput::make('uid')
->label(__('label.username'))
->placeholder('UID')
,
])->query(function (Builder $query, array $data) {
return $query->when($data['uid'], fn(Builder $query, $value) => $query->where("user_id", $value));
})
,
Tables\Filters\Filter::make('credential_id')
->form([
Forms\Components\TextInput::make('credential_id')
->label(__('passkey.fields.credential_id'))
->placeholder('Credential ID')
,
])->query(function (Builder $query, array $data) {
return $query->when($data['credential_id'], fn(Builder $query, $value) => $query->where("credential_id", $value));
})
,
])
->actions([
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageUserPasskey::route('/'),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\User\UserPasskeyResource\Pages;
use App\Filament\PageListSingle;
use App\Filament\Resources\User\UserPasskeyResource;
class ManageUserPasskey extends PageListSingle
{
protected static string $resource = UserPasskeyResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

32
app/Models/Passkey.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
class Passkey extends NexusModel
{
protected $table = 'user_passkeys';
public $timestamps = true;
protected $fillable = [
'id', 'user_id', 'AAGUID', 'credential_id', 'public_key', 'counter',
];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function AAGUID() {
$guid = $this->AAGUID;
return sprintf(
'%s-%s-%s-%s-%s',
substr($guid, 0, 8),
substr($guid, 8, 4),
substr($guid, 12, 4),
substr($guid, 16, 4),
substr($guid, 20, 12)
);
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Repositories;
use App\Models\Passkey;
use Exception;
use lbuchs\WebAuthn\WebAuthn;
use Nexus\Database\NexusDB;
use Nexus\Nexus;
use RuntimeException;
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);
}
public static function getCreateArgs($userId, $userName)
{
$WebAuthn = self::createWebAuthn();
$passkey = Passkey::query()->where('user_id', '=', $userId)->get();
$credentialIds = array_map(function ($item) {
return hex2bin($item['credential_id']);
}, $passkey->toArray());
$createArgs = $WebAuthn->getCreateArgs(bin2hex($userId), $userName, $userName, 60 * 4, true, 'preferred', null, $credentialIds);
NexusDB::cache_put("{$userId}_passkey_challenge", $WebAuthn->getChallenge(), 60 * 4);
return $createArgs;
}
public static function processCreate($userId, $clientDataJSON, $attestationObject)
{
$WebAuthn = self::createWebAuthn();
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
$attestationObject = !empty($attestationObject) ? base64_decode($attestationObject) : null;
$challenge = NexusDB::cache_get("{$userId}_passkey_challenge") ?? null;
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false);
self::insertUserPasskey(
$userId,
bin2hex($data->AAGUID),
bin2hex($data->credentialId),
$data->credentialPublicKey,
$data->signatureCounter
);
return true;
}
public static function getGetArgs()
{
$WebAuthn = self::createWebAuthn();
$getArgs = $WebAuthn->getGetArgs(null, 60 * 4, true, true, true, true, true, 'preferred');
return $getArgs;
}
public static function insertUserPasskey($userId, $AAGUID, $credentialId, $publicKey, $counter)
{
$params = [
'user_id' => $userId,
'AAGUID' => $AAGUID,
'credential_id' => $credentialId,
'public_key' => $publicKey,
'counter' => $counter,
];
Passkey::query()->create($params);
}
public static function processGet($challenge, $id, $clientDataJSON, $authenticatorData, $signature, $userHandle)
{
$WebAuthn = self::createWebAuthn();
$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;
$passkey = Passkey::query()->where('credential_id', '=', bin2hex($id))->first();
$credentialPublicKey = $passkey->public_key;
if ($credentialPublicKey === null) {
throw new RuntimeException(nexus_trans('passkey.passkey_unknown'));
}
if ($userHandle !== bin2hex($passkey->user_id)) {
throw new RuntimeException(nexus_trans('passkey.passkey_invalid'));
}
try {
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, 'preferred');
} catch (Exception $e) {
throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage());
}
$user = $passkey->user;
if (!$user) {
throw new RuntimeException(nexus_trans('passkey.passkey_user_not_found'));
}
$user->checkIsNormal();
$ip = getip();
$userRep = new UserRepository();
$userRep->saveLoginLog($user->id, $ip, 'Web', true);
logincookie($user->id, $user->auth_key);
return true;
}
public static function delete($userId, $credentialId)
{
return Passkey::query()->where('user_id', '=', $userId)->where('credential_id', '=', $credentialId)->delete();
}
public static function getList($userId)
{
return Passkey::query()->where('user_id', '=', $userId)->get();
}
public static function getAaguids()
{
return NexusDB::remember("aaguids", 60 * 60 * 24 * 14, function () {
return json_decode(file_get_contents("https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json"), true);
});
}
private static $passkeyvg = 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20216%20216%22%20xml%3Aspace%3D%22preserve%22%3E%3Cg%2F%3E%3Cpath%20style%3D%22fill%3Anone%22%20d%3D%22M0%200h216v216H0z%22%2F%3E%3Cpath%20d%3D%22M172.32%2096.79c0%2013.78-8.48%2025.5-20.29%2029.78l7.14%2011.83-10.57%2013%2010.57%2012.71-17.04%2022.87-12.01-12.82V125.7c-10.68-4.85-18.15-15.97-18.15-28.91%200-17.4%2013.51-31.51%2030.18-31.51%2016.66%200%2030.17%2014.11%2030.17%2031.51m-30.18%204.82c4.02%200%207.28-3.4%207.28-7.6s-3.26-7.61-7.28-7.61-7.28%203.4-7.28%207.61c-.01%204.2%203.26%207.6%207.28%207.6%22%20style%3D%22fill-rule%3Aevenodd%3Bclip-rule%3Aevenodd%3Bfill%3A%23353535%22%2F%3E%3Cpath%20d%3D%22M172.41%2096.88c0%2013.62-8.25%2025.23-19.83%2029.67l6.58%2011.84-9.73%2013%209.73%2012.71-17.03%2023.05v-85.54c4.02%200%207.28-3.41%207.28-7.6%200-4.2-3.26-7.61-7.28-7.61V65.28c16.73%200%2030.28%2014.15%2030.28%2031.6m-52.17%2034.55c-9.75-8-16.3-20.3-17.2-34.27H50.8c-10.96%200-19.84%209.01-19.84%2020.13v25.17c0%205.56%204.44%2010.07%209.92%2010.07h69.44c5.48%200%209.92-4.51%209.92-10.07z%22%20style%3D%22fill-rule%3Aevenodd%3Bclip-rule%3Aevenodd%22%2F%3E%3Cpath%20d%3D%22M73.16%2091.13c-2.42-.46-4.82-.89-7.11-1.86-8.65-3.63-13.69-10.32-15.32-19.77-1.12-6.47-.59-12.87%202.03-18.92%203.72-8.6%2010.39-13.26%2019.15-14.84%205.24-.94%2010.46-.73%2015.5%201.15%207.59%202.82%2012.68%208.26%2015.03%2016.24%202.38%208.05%202.03%2016.1-1.56%2023.72-3.72%207.96-10.21%2012.23-18.42%2013.9-.68.14-1.37.27-2.05.41-2.41-.03-4.83-.03-7.25-.03%22%20style%3D%22fill%3A%23141313%22%2F%3E%3C%2Fsvg%3E';
public static function renderLogin()
{
printf('<p id="passkey_box"><button type="button" id="passkey_login"><img style="width:32px" src="%s" alt="%s"><br>%s</button></p>', self::$passkeyvg, nexus_trans('passkey.passkey'), nexus_trans('passkey.passkey'));
?>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (Passkey.conditionalSupported()) {
Passkey.isCMA().then(async (isCMA) => {
if (isCMA) startPasskeyLogin(true);
});
}
document.getElementById('passkey_login').addEventListener('click', () => {
if (!Passkey.supported()) {
layer.alert('<?php echo nexus_trans('passkey.passkey_not_supported'); ?>');
} else {
startPasskeyLogin(false);
}
})
});
const startPasskeyLogin = (conditional) => {
Passkey.checkRegistration(conditional, () => {
layer.load(2, {shade: 0.3});
}).then(() => {
if (location.search) {
const searchParams = new URLSearchParams(location.search);
location.href = searchParams.get('returnto') || '/index.php';
} else {
location.href = '/index.php';
}
}).catch((e) => {
if (e.name === 'NotAllowedError' || e.name === 'AbortError') {
return;
}
layer.alert(e.message);
}).finally(() => {
layer.closeAll('loading');
});
}
</script>
<?php
}
public static function renderList($id)
{
$passkeys = self::getList($id);
printf('<button type="button" id="passkey_create">%s</button><br>%s', nexus_trans('passkey.passkey_create'), nexus_trans('passkey.passkey_desc'));
?>
<table>
<?php
if (empty($passkeys)) {
printf('<tr><td>%s</td></tr>', nexus_trans('passkey.passkey_empty'));
} else {
$AAGUIDS = self::getAaguids();
foreach ($passkeys as $passkey) {
?>
<tr>
<td>
<div style="display:flex;align-items:center;padding:4px">
<?php
$meta = $AAGUIDS[$passkey->AAGUID()];
if (isset($meta)) {
printf('<img style="width: 32px" src="%s" alt="%s" /><div style="margin-right:4px"><b>%s</b> (%s)', $meta['icon_dark'], $meta['name'], $meta['name'], $passkey->credential_id);
} else {
printf('<img style="width: 32px" src="%s" alt="%s" /><div style="margin-right:4px"><b>%s</b>', self::$passkeyvg, $passkey->credential_id, $passkey->credential_id);
}
printf('<br><b>%s</b>%s</div>', nexus_trans('passkey.passkey_created_at'), gettime($passkey->created_at));
printf('<button type="button" style="margin-left:auto" data-passkey-id="%s">%s</button>', $passkey->credential_id, nexus_trans('passkey.passkey_delete'))
?>
</div>
</td>
</tr>
<?php
}
} ?>
</table>
<script>
document.addEventListener("DOMContentLoaded", function () {
document.getElementById('passkey_create').addEventListener('click', () => {
if (!Passkey.supported()) {
layer.alert('<?php echo nexus_trans('passkey.passkey_not_supported'); ?>');
} else {
layer.load(2, {shade: 0.3});
Passkey.createRegistration().then(() => {
location.reload();
}).catch((e) => {
layer.alert(e.message);
}).finally(() => {
layer.closeAll('loading');
});
}
})
document.querySelectorAll('button[data-passkey-id]').forEach((button) => {
button.addEventListener('click', () => {
const credentialId = button.getAttribute('data-passkey-id');
layer.confirm('<?php echo nexus_trans('passkey.passkey_delete_confirm'); ?>', {}, function () {
layer.load(2, {shade: 0.3});
Passkey.deleteRegistration(credentialId).then(() => {
location.reload();
}).catch((e) => {
layer.alert(e.message);
}).finally(() => {
layer.closeAll('loading');
});
});
});
});
});
</script>
<?php
Nexus::js('js/passkey.js', 'footer', true);
}
}

View File

@@ -19,7 +19,7 @@
"files": []
},
"require": {
"php": ">=8.2 <8.5",
"php": ">=8.2 <8.6",
"ext-bcmath": "*",
"ext-curl": "*",
"ext-gd": "*",
@@ -49,6 +49,7 @@
"laravel/passport": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.5",
"lbuchs/webauthn": "^2.2",
"league/flysystem-sftp-v3": "^3.0",
"meilisearch/meilisearch-php": "^1.0",
"orangehill/iseed": "^3.0",

1465
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (Schema::hasTable('user_passkeys')) return;
Schema::create('user_passkeys', function (Blueprint $table) {
$table->id();
$table->unsignedMediumInteger('user_id');
$table->text('AAGUID')->nullable();
$table->text('credential_id');
$table->text('public_key');
$table->unsignedInteger('counter')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_passkeys');
}
};

View File

@@ -1,11 +1,14 @@
<?php
require "../include/bittorrent.php";
dbconn();
loggedinorreturn();
$action = $_POST['action'] ?? '';
$params = $_POST['params'] ?? [];
if ($action != 'getPasskeyGetArgs' && $action != 'processPasskeyGet') {
loggedinorreturn();
}
class AjaxInterface{
public static function toggleUserMedalStatus($params)
@@ -179,6 +182,48 @@ class AjaxInterface{
$user->tokens()->where('id', $params['id'])->delete();
return true;
}
public static function getPasskeyCreateArgs($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->getCreateArgs($CURUSER['id'], $CURUSER['username']);
}
public static function processPasskeyCreate($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->processCreate($CURUSER['id'], $params['clientDataJSON'], $params['attestationObject']);
}
public static function deletePasskey($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->delete($CURUSER['id'], $params['credentialId']);
}
public static function getPasskeyList($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->getList($CURUSER['id']);
}
public static function getPasskeyGetArgs($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->getGetArgs();
}
public static function processPasskeyGet($params)
{
global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository();
return $rep->processGet($params['challenge'], $params['id'], $params['clientDataJSON'], $params['authenticatorData'], $params['signature'], $params['userHandle']);
}
}
$class = 'AjaxInterface';

169
public/js/passkey.js vendored Normal file
View File

@@ -0,0 +1,169 @@
const Passkey = (() => {
const apiUrl = '/ajax.php';
const supported = () => {
return window.PublicKeyCredential;
}
const conditionalSupported = () => {
return supported() && PublicKeyCredential.isConditionalMediationAvailable;
}
const isCMA = async () => {
return await PublicKeyCredential.isConditionalMediationAvailable();
}
const getCreateArgs = async () => {
const getArgsParams = new URLSearchParams();
getArgsParams.set('action', 'getPasskeyCreateArgs');
const response = await fetch(apiUrl, {
method: 'POST',
body: getArgsParams,
});
const data = await response.json();
if (data.ret !== 0) {
throw new Error(data.msg);
}
const createArgs = data.data;
recursiveBase64StrToArrayBuffer(createArgs);
return createArgs;
}
const createRegistration = async () => {
const createArgs = await getCreateArgs();
const cred = await navigator.credentials.create(createArgs);
const processCreateParams = new URLSearchParams();
processCreateParams.set('action', 'processPasskeyCreate');
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);
const response = await fetch(apiUrl, {
method: 'POST',
body: processCreateParams,
});
const data = await response.json();
if (data.ret !== 0) {
throw new Error(data.msg);
}
}
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) => {
if (abortController) {
abortController.abort()
abortController = null;
}
if (!conditional) showLoading();
const getArgs = await getGetArgs();
if (conditional) {
abortController = new AbortController();
getArgs.signal = abortController.signal;
getArgs.mediation = 'conditional';
}
const cred = await navigator.credentials.get(getArgs);
if (conditional) showLoading();
const processGetParams = new URLSearchParams();
processGetParams.set('action', 'processPasskeyGet');
processGetParams.set('params[challenge]', arrayBufferToBase64(getArgs['publicKey']['challenge']));
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);
processGetParams.set('params[signature]', cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null);
processGetParams.set('params[userHandle]', cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null);
const response = await fetch(apiUrl, {
method: 'POST',
body: processGetParams,
});
const data = await response.json();
if (data.ret !== 0) {
throw new Error(data.msg);
}
}
const deleteRegistration = async (credentialId) => {
const deleteParams = new URLSearchParams();
deleteParams.set('action', 'deletePasskey');
deleteParams.set('params[credentialId]', credentialId);
const response = await fetch(apiUrl, {
method: 'POST',
body: deleteParams,
});
const data = await response.json();
if (data.ret !== 0) {
throw new Error(data.msg);
}
}
const recursiveBase64StrToArrayBuffer = (obj) => {
let prefix = '=?BINARY?B?';
let suffix = '?=';
if (typeof obj === 'object') {
for (let key in obj) {
if (typeof obj[key] === 'string') {
let str = obj[key];
if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
str = str.substring(prefix.length, str.length - suffix.length);
let binary_string = window.atob(str);
let len = binary_string.length;
let bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
obj[key] = bytes.buffer;
}
} else {
recursiveBase64StrToArrayBuffer(obj[key]);
}
}
}
}
const arrayBufferToBase64 = (buffer) => {
let binary = '';
let bytes = new Uint8Array(buffer);
let len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
return {
supported: supported,
conditionalSupported: conditionalSupported,
createRegistration: createRegistration,
checkRegistration: checkRegistration,
deleteRegistration: deleteRegistration,
isCMA: isCMA,
}
})();

View File

@@ -92,6 +92,7 @@ if (isset($returnto)) {
if ($useChallengeResponseAuthentication) {
print('<input type="hidden" name="response" />');
}
\App\Repositories\UserPasskeyRepository::renderLogin();
?>
</form>
<?php
@@ -134,4 +135,5 @@ print("</td></tr></table></form></td></tr></table>");
?>
<?php
render_password_challenge_js("login-form", "username", "password");
\Nexus\Nexus::js('js/passkey.js', 'footer', true);
stdfoot();

View File

@@ -936,6 +936,9 @@ EOD;
$twoStepY .= '</div>';
tr_small($lang_usercp['row_two_step_secret'], $twoStepY, 1);
}
printf('<tr><td class="rowhead" valign="top" align="right">%s</td><td class="rowfollow" valign="top" align="left">', nexus_trans('passkey.passkey'));
\App\Repositories\UserPasskeyRepository::renderList($CURUSER['id']);
printf('</td></tr>');
if ($disableemailchange != 'no' && $smtptype != 'none') //system-wide setting
tr_small($lang_usercp['row_email_address'], "<input type=\"text\" name=\"email\" style=\"width: 200px\" value=\"" . htmlspecialchars($CURUSER["email"]) . "\" /> <br /><font class=small>".$lang_usercp['text_email_address_note']."</font>", 1);

View File

@@ -0,0 +1,21 @@
<?php
return [
'passkey_title' => 'Passkey',
'passkey' => 'Passkey',
'passkey_desc' => 'Passkey are a secure and convenient way to authenticate without the need for passwords. They can be used across multiple devices.',
'passkey_create' => 'Create Passkey',
'passkey_empty' => 'No passkey found.',
'passkey_created_at' => 'Created at:',
'passkey_delete_confirm' => 'Are you sure you want to delete this passkey? This action cannot be undone.',
'passkey_delete' => 'Delete',
'passkey_unknown' => 'An error occurred while processing your request.',
'passkey_invalid' => 'Invalid passkey data.',
'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.',
'fields' => [
'credential_id' => 'Credential ID',
'counter' => 'Counter',
],
];

View File

@@ -0,0 +1,21 @@
<?php
return [
'passkey_title' => '&nbsp;&nbsp;通&nbsp;&nbsp;行&nbsp;&nbsp;密&nbsp;&nbsp;钥&nbsp;&nbsp;',
'passkey' => '通行密钥',
'passkey_desc' => '通行密钥是一种安全、方便的身份验证方式,无需输入密码。通行密钥可在多台设备上使用。',
'passkey_create' => '创建通行密钥',
'passkey_empty' => '没有通行密钥。',
'passkey_created_at' => '创建于:',
'passkey_delete_confirm' => '您确实要删除此通行密钥吗?此操作无法撤消。',
'passkey_delete' => '删除',
'passkey_unknown' => '处理您的请求时出错。',
'passkey_invalid' => '通行密钥数据无效。',
'passkey_error' => '处理您的请求时出错。请稍后重试。',
'passkey_user_not_found' => '未找到用户。',
'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。',
'fields' => [
'credential_id' => '凭据 ID',
'counter' => '计数',
],
];