Add passkey support

This commit is contained in:
DariusIII
2026-04-24 12:03:49 +02:00
parent fecdd37743
commit c29f5febd5
30 changed files with 2347 additions and 4 deletions
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Passkeys;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
final class ConfigureCeremonyStepManagerFactoryAction extends \Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction
{
public function execute(): CeremonyStepManagerFactory
{
$factory = parent::execute();
$request = request();
$rpId = RelyingPartyIdResolver::resolve($request);
$origins = [$request->getSchemeAndHttpHost()];
// HTTPS origins for normal environments.
$origins[] = "https://{$rpId}";
// Local development allowance for localhost over HTTP.
if ($rpId === 'localhost') {
$origins[] = 'http://localhost';
}
$factory->setAllowedOrigins(array_values(array_unique($origins)));
return $factory;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Actions\Passkeys;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction;
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction as BaseFindPasskeyToAuthenticateAction;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\LaravelPasskeys\Support\Config;
use Spatie\LaravelPasskeys\Support\CredentialRecordConverter;
use Throwable;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
final class FindPasskeyToAuthenticateAction extends BaseFindPasskeyToAuthenticateAction
{
protected function determinePublicKeyCredentialSource(
PublicKeyCredential $publicKeyCredential,
PublicKeyCredentialRequestOptions $passkeyOptions,
Passkey $passkey,
): ?PublicKeyCredentialSource {
$configureCeremonyStepManagerFactoryAction = Config::getAction(
'configure_ceremony_step_manager_factory',
ConfigureCeremonyStepManagerFactoryAction::class
);
$csmFactory = $configureCeremonyStepManagerFactoryAction->execute();
$requestCsm = $csmFactory->requestCeremony();
$host = RelyingPartyIdResolver::resolve();
try {
$validator = AuthenticatorAssertionResponseValidator::create($requestCsm);
$publicKeyCredentialSource = $validator->check(
$passkey->data,
$publicKeyCredential->response,
$passkeyOptions,
$host,
null,
);
} catch (Throwable) {
return null;
}
return CredentialRecordConverter::toPublicKeyCredentialSource($publicKeyCredentialSource);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Passkeys;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyAuthenticationOptionsAction as BaseGeneratePasskeyAuthenticationOptionsAction;
use Spatie\LaravelPasskeys\Support\Serializer;
use Webauthn\PublicKeyCredentialRequestOptions;
final class GeneratePasskeyAuthenticationOptionsAction extends BaseGeneratePasskeyAuthenticationOptionsAction
{
public function execute(): string
{
$rpId = RelyingPartyIdResolver::resolve();
$options = new PublicKeyCredentialRequestOptions(
challenge: Str::random(),
rpId: $rpId,
allowCredentials: [],
);
$options = Serializer::make()->toJson($options);
Session::flash('passkey-authentication-options', $options);
return $options;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Actions\Passkeys;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction as BaseGeneratePasskeyRegisterOptionsAction;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Support\Config;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRpEntity;
final class GeneratePasskeyRegisterOptionsAction extends BaseGeneratePasskeyRegisterOptionsAction
{
public function execute(
HasPasskeys $authenticatable,
bool $asJson = true,
): string|PublicKeyCredentialCreationOptions {
$options = parent::execute($authenticatable, $asJson);
if (! $asJson || ! is_string($options)) {
return $options;
}
$decoded = json_decode($options, true);
if (! is_array($decoded)) {
return $options;
}
$supportedAlgorithms = [
['type' => 'public-key', 'alg' => -7], // ES256
['type' => 'public-key', 'alg' => -257], // RS256
];
// Different serializer/browser integrations can use either shape:
// - options.pubKeyCredParams (WebAuthn JSON)
// - options.publicKey.pubKeyCredParams (navigator.credentials.create payload)
// Enforce valid algorithms for both to prevent "alg undefined" errors.
$decoded['pubKeyCredParams'] = $supportedAlgorithms;
if (isset($decoded['publicKey']) && is_array($decoded['publicKey'])) {
$decoded['publicKey']['pubKeyCredParams'] = $supportedAlgorithms;
}
return json_encode($decoded, JSON_THROW_ON_ERROR);
}
protected function relatedPartyEntity(): PublicKeyCredentialRpEntity
{
$rpId = RelyingPartyIdResolver::resolve();
return new PublicKeyCredentialRpEntity(
name: (string) Config::getRelyingPartyName(),
id: $rpId,
icon: Config::getRelyingPartyIcon(),
);
}
}
@@ -12,9 +12,12 @@ use App\Models\User;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
class AdminUserController extends BasePageController
@@ -148,7 +151,7 @@ class AdminUserController extends BasePageController
// Check if role is changing and get stack preference
$roleChanged = $editedUser->roles_id != $request->input('role');
$stackRole = $request->input('stack_role') ? true : false; // Check if checkbox is checked
$changedBy = auth()->check() ? auth()->id() : null;
$changedBy = Auth::check() ? Auth::id() : null;
// CRITICAL: Capture the ORIGINAL rolechangedate BEFORE any updates
// This is needed for accurate role history tracking
@@ -269,6 +272,8 @@ class AdminUserController extends BasePageController
// Add daily API and download counts
if ($user) {
$user->load('passkeys');
try {
$user->daily_api_count = UserRequest::getApiRequests($user->id);
$user->daily_download_count = UserDownload::getDownloadRequests($user->id);
@@ -349,4 +354,63 @@ class AdminUserController extends BasePageController
return redirect()->back()->with('error', 'User is invalid');
}
public function destroyPasskey(Request $request, Passkey $passkey): RedirectResponse|JsonResponse
{
$targetUser = $passkey->authenticatable;
$targetUserId = $targetUser?->id;
$targetUsername = $targetUser?->username;
$passkeyName = $passkey->name;
$passkey->delete();
Log::channel('admin')->info('Admin deleted user passkey', [
'admin_user_id' => Auth::id(),
'target_user_id' => $targetUserId,
'target_username' => $targetUsername,
'passkey_id' => $passkey->id,
'passkey_name' => $passkeyName,
]);
if ($request->expectsJson()) {
return response()->json(['ok' => true]);
}
$redirectUrl = $targetUserId
? 'admin/user-edit?id='.$targetUserId
: 'admin/user-list';
return redirect()->to($redirectUrl)->with('success', 'Passkey deleted successfully.');
}
public function wipePasskeys(Request $request): RedirectResponse|JsonResponse
{
$validated = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'confirmation' => ['required', 'string', 'in:WIPE'],
]);
$target = User::findOrFail((int) $validated['user_id']);
$wipedCount = $target->passkeys()->count();
$target->passkeys()->delete();
Log::channel('admin')->warning('Admin wiped user passkeys', [
'admin_user_id' => Auth::id(),
'target_user_id' => $target->id,
'target_username' => $target->username,
'wiped_count' => $wipedCount,
]);
$message = "{$wipedCount} passkeys removed from {$target->username}. They can now regain access with their password or an admin-issued reset.";
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'wiped_count' => $wipedCount,
'message' => $message,
]);
}
return redirect()->to('admin/user-edit?id='.$target->id)->with('success', $message);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Events\UserLoggedIn;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\CaptchaHelper;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Validator;
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
use Spatie\LaravelPasskeys\Events\PasskeyUsedToAuthenticateEvent;
use Spatie\LaravelPasskeys\Http\Requests\AuthenticateUsingPasskeysRequest;
use Spatie\LaravelPasskeys\Support\Config;
final class PasskeyLoginController extends Controller
{
public function __invoke(AuthenticateUsingPasskeysRequest $request): RedirectResponse
{
$captchaRules = CaptchaHelper::getValidationRules();
$captchaField = CaptchaHelper::getResponseFieldName();
$captchaValue = $request->input($captchaField);
// Passkey login should not be blocked when captcha widgets rotate/expire
// during WebAuthn prompts. If a captcha token is present, still validate it.
if ($captchaRules !== [] && is_string($captchaValue) && trim($captchaValue) !== '') {
$validator = Validator::make($request->all(), $captchaRules);
if ($validator->fails()) {
Log::channel('failed_login')->error(
'Failed passkey login captcha check from IP address: '.$request->ip()
);
session()->flash('authenticatePasskey::message', $validator->errors()->first());
session()->flash('authenticatePasskey::reason', 'captcha');
return back();
}
}
$findAuthenticatableUsingPasskey = Config::getAction(
'find_passkey',
FindPasskeyToAuthenticateAction::class
);
$passkey = $findAuthenticatableUsingPasskey->execute(
$request->input('start_authentication_response'),
Session::get('passkey-authentication-options'),
);
if (! $passkey || ! $passkey->authenticatable instanceof User) {
Log::channel('failed_login')->error(
'Failed passkey login attempt from IP address: '.$request->ip()
);
session()->flash('authenticatePasskey::message', __('passkeys::passkeys.invalid'));
session()->flash('authenticatePasskey::reason', 'invalid_passkey');
return back();
}
$user = $passkey->authenticatable;
if ($user->trashed()) {
Log::channel('failed_login')->error(
'Failed passkey login for deactivated user: '.$user->username.' from IP address: '.$request->ip()
);
session()->flash(
'authenticatePasskey::message',
'This account has been deactivated. Please contact us through contact form to have your account reactivated.'
);
return back();
}
if (! $user->hasVerifiedEmail()) {
Log::channel('failed_login')->error(
'Failed passkey login for unverified user: '.$user->username.' from IP address: '.$request->ip()
);
session()->flash('authenticatePasskey::message', 'You have not verified your email address!');
return back();
}
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
// Passkey auth is treated as sufficient MFA, so skip additional OTP gate.
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
$userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : '';
event(new UserLoggedIn($user, $userIp));
event(new PasskeyUsedToAuthenticateEvent($passkey, $request));
$url = Session::has('passkeys.redirect')
? (string) Session::pull('passkeys.redirect')
: (string) config('passkeys.redirect_to_after_login', '/');
return redirect($url)->with('info', 'You have been logged in');
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction;
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\LaravelPasskeys\Support\Config;
use Throwable;
final class PasskeyManagementController extends Controller
{
public function options(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
]);
$generatePassKeyOptionsAction = Config::getAction(
'generate_passkey_register_options',
GeneratePasskeyRegisterOptionsAction::class
);
/** @var User $user */
$user = Auth::user();
$options = $generatePassKeyOptionsAction->execute($user);
$request->session()->put('passkey-registration-options', $options);
$request->session()->put('passkey-registration-name', $validated['name']);
return response()->json([
'ok' => true,
'options' => json_decode($options, true, 512, JSON_THROW_ON_ERROR),
]);
}
public function store(Request $request): JsonResponse|RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'passkey' => ['required', 'string'],
]);
$storePasskeyAction = Config::getAction('store_passkey', StorePasskeyAction::class);
try {
/** @var User $user */
$user = Auth::user();
/** @var Passkey $passkey */
$passkey = $storePasskeyAction->execute(
$user,
$validated['passkey'],
$request->session()->pull('passkey-registration-options'),
RelyingPartyIdResolver::resolve($request),
['name' => $validated['name']]
);
} catch (Throwable $exception) {
Log::error('Passkey registration failed', [
'user_id' => Auth::id(),
'host' => $request->getHost(),
'resolved_rp_id' => RelyingPartyIdResolver::resolve($request),
'exception' => $exception::class,
'message' => $exception->getMessage(),
]);
return response()->json([
'ok' => false,
'message' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'),
], 422);
}
return response()->json([
'ok' => true,
'passkey' => [
'id' => $passkey?->id,
'name' => $passkey?->name,
'last_used_at' => $passkey?->last_used_at?->toIso8601String(),
'created_at' => $passkey?->created_at?->toIso8601String(),
],
]);
}
public function destroy(Request $request, int $passkey): JsonResponse|RedirectResponse
{
/** @var User $user */
$user = Auth::user();
$deleted = $user->passkeys()->where('id', $passkey)->delete();
if ($request->expectsJson()) {
return response()->json(['ok' => $deleted > 0]);
}
return back()->with(
$deleted > 0 ? 'success' : 'error',
$deleted > 0 ? 'Passkey deleted.' : 'Passkey not found.'
);
}
}
@@ -112,6 +112,7 @@ class ProfileController extends BasePageController
$action = $request->input('action') ?? 'view';
$userid = $this->userdata->id;
$this->userdata->loadMissing('passkeys');
$errorStr = '';
$success_2fa = $request->session()->get('success');
+21 -1
View File
@@ -34,6 +34,9 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Traits\HasRoles;
@@ -104,6 +107,7 @@ use Spatie\Permission\Traits\HasRoles;
* @property-read Collection<int, UserSerie> $series
* @property-read Collection<int, RolePromotionStat> $promotionStats
* @property-read Collection<int, UserRoleHistory> $roleHistory
* @property-read Collection<int, Passkey> $passkeys
* @property-read Role|null $role
* @property-read PasswordSecurity|null $passwordSecurity
*
@@ -117,10 +121,11 @@ use Spatie\Permission\Traits\HasRoles;
* @method static Builder|User expiringSoon(int $days = 7)
* @method static Builder|User expired()
*/
final class User extends Authenticatable implements MustVerifyEmailContract
final class User extends Authenticatable implements HasPasskeys, MustVerifyEmailContract
{
use HasFactory; // @phpstan-ignore missingType.generics
use HasRoles;
use InteractsWithPasskeys;
use MustVerifyEmailTrait;
use Notifiable;
use SoftDeletes;
@@ -192,6 +197,21 @@ final class User extends Authenticatable implements MustVerifyEmailContract
];
}
public function getPasskeyName(): string
{
return $this->email ?: $this->username;
}
public function getPasskeyId(): string
{
return (string) $this->id;
}
public function getPasskeyDisplayName(): string
{
return $this->full_name ?: $this->username;
}
protected function getDefaultGuardName(): string
{
return 'web';
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Support\Passkeys;
use Illuminate\Http\Request;
final class RelyingPartyIdResolver
{
public static function resolve(?Request $request = null): string
{
$request ??= request();
$candidates = [
self::firstHost((string) $request->header('X-Forwarded-Host', '')),
self::firstHost((string) $request->header('X-Original-Host', '')),
(string) config('passkeys.relying_party.id', ''),
(string) $request->getHost(),
];
foreach ($candidates as $candidate) {
if (self::isValidRelyingPartyId($candidate)) {
return $candidate;
}
}
return 'localhost';
}
private static function firstHost(string $value): string
{
$host = trim(explode(',', $value)[0] ?? '');
if ($host === '') {
return '';
}
// Strip optional port suffix.
return trim((string) preg_replace('/:\d+$/', '', $host));
}
private static function isValidRelyingPartyId(string $value): bool
{
$value = trim($value);
if ($value === '' || filter_var($value, FILTER_VALIDATE_IP) !== false) {
return false;
}
if ($value === 'localhost') {
return true;
}
return str_contains($value, '.');
}
}
+1
View File
@@ -69,6 +69,7 @@
"spatie/laravel-directory-cleanup": "^1.10",
"spatie/laravel-fractal": "^6.3",
"spatie/laravel-image-optimizer": "^1.8",
"spatie/laravel-passkeys": "^1.7",
"spatie/laravel-permission": "^7.0.0",
"stechstudio/laravel-zipstream": "^5.4",
"symfony/process": "^7.0",
Generated
+418 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3a34364f86e7322cb77bf01b515aa6f6",
"content-hash": "07cc99b3bba0a8670515889435ba9f56",
"packages": [
{
"name": "aharen/omdbapi",
@@ -6599,6 +6599,84 @@
],
"time": "2026-02-21T12:49:54+00:00"
},
{
"name": "spatie/laravel-passkeys",
"version": "1.7.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-passkeys.git",
"reference": "2d67943712243c23852c77d4c4679575bad5b9ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/2d67943712243c23852c77d4c4679575bad5b9ec",
"reference": "2d67943712243c23852c77d4c4679575bad5b9ec",
"shasum": ""
},
"require": {
"illuminate/contracts": "^11.0|^12.0|^13.0",
"php": "^8.2|^8.3|^8.4",
"spatie/laravel-package-tools": "^1.16",
"web-auth/webauthn-lib": "^5.0|5.3.x-dev as 5.3.0"
},
"require-dev": {
"larastan/larastan": "^3.4",
"laravel/pint": "^1.14",
"livewire/livewire": "^3.5 || ^4.0",
"nunomaduro/collision": "^8.1.1",
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^3.0|^4.0",
"pestphp/pest-plugin-arch": "^3.0|^4.0",
"pestphp/pest-plugin-laravel": "^3.0|^4.0",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"spatie/laravel-ray": "^1.35"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelPasskeys\\LaravelPasskeysServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelPasskeys\\": "src/",
"Spatie\\LaravelPasskeys\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Use passkeys in your Laravel app",
"homepage": "https://github.com/spatie/laravel-passkeys",
"keywords": [
"laravel",
"laravel-passkeys",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-passkeys/issues",
"source": "https://github.com/spatie/laravel-passkeys/tree/1.7.0"
},
"funding": [
{
"url": "https://github.com/Spatie",
"type": "github"
}
],
"time": "2026-04-08T07:32:15+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "7.3.0",
@@ -6747,6 +6825,187 @@
],
"time": "2026-01-12T07:42:22+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.2.3",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/cbor-php.git",
"reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
"reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
"shasum": ""
},
"require": {
"brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"ext-mbstring": "*",
"php": ">=8.0"
},
"require-dev": {
"ext-json": "*",
"roave/security-advisories": "dev-latest",
"symfony/error-handler": "^6.4|^7.1|^8.0",
"symfony/var-dumper": "^6.4|^7.1|^8.0"
},
"suggest": {
"ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
"ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
},
"type": "library",
"autoload": {
"psr-4": {
"CBOR\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
}
],
"description": "CBOR Encoder/Decoder for PHP",
"keywords": [
"Concise Binary Object Representation",
"RFC7049",
"cbor"
],
"support": {
"issues": "https://github.com/Spomky-Labs/cbor-php/issues",
"source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-04-01T12:15:20+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pki-framework.git",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
"shasum": ""
},
"require": {
"brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"ext-mbstring": "*",
"php": ">=8.1",
"psr/clock": "^1.0"
},
"require-dev": {
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ext-gmp": "*",
"ext-openssl": "*",
"infection/infection": "^0.28|^0.29|^0.31|^0.32",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/extension-installer": "^1.3|^2.0",
"phpstan/phpstan": "^1.8|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.1|^2.0",
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
"phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
"rector/rector": "^1.0|^2.0",
"roave/security-advisories": "dev-latest",
"symfony/string": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symplify/easy-coding-standard": "^12.0|^13.0"
},
"suggest": {
"ext-bcmath": "For better performance (or GMP)",
"ext-gmp": "For better performance (or BCMath)",
"ext-openssl": "For OpenSSL based cyphering"
},
"type": "library",
"autoload": {
"psr-4": {
"SpomkyLabs\\Pki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joni Eskelinen",
"email": "jonieske@gmail.com",
"role": "Original developer"
},
{
"name": "Florent Morselli",
"email": "florent.morselli@spomky-labs.com",
"role": "Spomky-Labs PKI Framework developer"
}
],
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
"homepage": "https://github.com/spomky-labs/pki-framework",
"keywords": [
"DER",
"Private Key",
"ac",
"algorithm identifier",
"asn.1",
"asn1",
"attribute certificate",
"certificate",
"certification request",
"cryptography",
"csr",
"decrypt",
"ec",
"encrypt",
"pem",
"pkcs",
"public key",
"rsa",
"sign",
"signature",
"verify",
"x.509",
"x.690",
"x509",
"x690"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-03-23T22:56:56+00:00"
},
{
"name": "stechstudio/laravel-zipstream",
"version": "5.10",
@@ -10400,6 +10659,164 @@
],
"time": "2024-07-03T16:05:14+00:00"
},
{
"name": "web-auth/cose-lib",
"version": "4.5.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
"shasum": ""
},
"require": {
"brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"ext-json": "*",
"ext-openssl": "*",
"php": ">=8.1",
"spomky-labs/pki-framework": "^1.0"
},
"require-dev": {
"spomky-labs/cbor-php": "^3.2.2"
},
"suggest": {
"ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
"ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
"spomky-labs/cbor-php": "For COSE Signature support"
},
"type": "library",
"autoload": {
"psr-4": {
"Cose\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/cose/contributors"
}
],
"description": "CBOR Object Signing and Encryption (COSE) For PHP",
"homepage": "https://github.com/web-auth",
"keywords": [
"COSE",
"RFC8152"
],
"support": {
"issues": "https://github.com/web-auth/cose-lib/issues",
"source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-04-01T12:47:39+00:00"
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.x-dev",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950",
"reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-openssl": "*",
"paragonie/constant_time_encoding": "^2.6|^3.0",
"php": ">=8.2",
"phpdocumentor/reflection-docblock": "^5.3|^6.0",
"psr/clock": "^1.0",
"psr/event-dispatcher": "^1.0",
"psr/log": "^1.0|^2.0|^3.0",
"spomky-labs/cbor-php": "^3.0",
"spomky-labs/pki-framework": "^1.0",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^3.2",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/uid": "^6.4|^7.0|^8.0",
"web-auth/cose-lib": "^4.2.3"
},
"suggest": {
"psr/log-implementation": "Recommended to receive logs from the library",
"symfony/event-dispatcher": "Recommended to use dispatched events",
"web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources"
},
"default-branch": true,
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/web-auth/webauthn-framework",
"name": "web-auth/webauthn-framework"
}
},
"autoload": {
"psr-4": {
"Webauthn\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/webauthn-library/contributors"
}
],
"description": "FIDO2/Webauthn Support For PHP",
"homepage": "https://github.com/web-auth",
"keywords": [
"FIDO2",
"fido",
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-03-22T17:54:03+00:00"
},
{
"name": "webmozart/assert",
"version": "2.3.0",
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Actions\Passkeys\ConfigureCeremonyStepManagerFactoryAction;
use App\Actions\Passkeys\FindPasskeyToAuthenticateAction;
use App\Actions\Passkeys\GeneratePasskeyAuthenticationOptionsAction;
use App\Actions\Passkeys\GeneratePasskeyRegisterOptionsAction;
use App\Models\User;
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
use Spatie\LaravelPasskeys\Models\Passkey;
return [
/*
* After a successful authentication attempt using a passkey
* we'll redirect to this URL.
*/
'redirect_to_after_login' => '/',
/*
* These class are responsible for performing core tasks regarding passkeys.
* You can customize them by creating a class that extends the default, and
* by specifying your custom class name here.
*/
'actions' => [
'generate_passkey_register_options' => GeneratePasskeyRegisterOptionsAction::class,
'store_passkey' => StorePasskeyAction::class,
'generate_passkey_authentication_options' => GeneratePasskeyAuthenticationOptionsAction::class,
'find_passkey' => FindPasskeyToAuthenticateAction::class,
'configure_ceremony_step_manager_factory' => ConfigureCeremonyStepManagerFactoryAction::class,
],
/*
* These properties will be used to generate the passkey.
*/
'relying_party' => [
'name' => config('app.name'),
'id' => env(
'PASSKEY_RELYING_PARTY_ID',
parse_url(config('app.url', 'http://localhost'), PHP_URL_HOST) ?: 'localhost'
),
'icon' => null,
],
/*
* The models used by the package.
*
* You can override this by specifying your own models.
*/
'models' => [
'passkey' => Passkey::class,
'authenticatable' => env('AUTH_MODEL', User::class),
],
];
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\LaravelPasskeys\Support\Config;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('passkeys')) {
return;
}
$authenticatableClass = Config::getAuthenticatableModel();
$authenticatableTableName = (new $authenticatableClass)->getTable();
Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName): void {
$table->id();
$table->unsignedInteger('authenticatable_id');
$table->foreign('authenticatable_id', 'passkeys_authenticatable_fk')
->references('id')
->on($authenticatableTableName)
->cascadeOnDelete();
$table->text('name');
$table->text('credential_id');
$table->json('data');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('passkeys');
}
};
+1
View File
@@ -25,6 +25,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"@melloware/coloris": "^0.25.0",
"@simonwep/pickr": "^1.9.1",
"@simplewebauthn/browser": "^13.3.0",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"feather-icons": "^4.29.2",
@@ -0,0 +1,14 @@
import Alpine from '@alpinejs/csp';
Alpine.data('adminUserPasskeys', () => ({
showDangerZone: false,
wipeConfirm: '',
toggleDangerZone() {
this.showDangerZone = !this.showDangerZone;
},
canWipe() {
return this.wipeConfirm === 'WIPE';
},
}));
@@ -0,0 +1,104 @@
import Alpine from '@alpinejs/csp';
Alpine.data('passkeyLogin', () => ({
supported: false,
busy: false,
error: '',
showCreateHint: false,
init() {
this.supported = typeof window.browserSupportsWebAuthn === 'function'
&& window.browserSupportsWebAuthn();
// If backend already reported an invalid passkey login attempt,
// immediately show the "sign in first, then create passkey" guidance.
this.showCreateHint = this.$el.dataset.serverPasskeyError === '1';
},
async authenticate() {
this.error = '';
this.showCreateHint = false;
this.busy = true;
try {
this.copyCaptchaResponse();
const rawOptionsUrl = this.$el.dataset.optionsUrl;
const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined')
? '/passkeys/authentication-options'
: rawOptionsUrl;
const optionsResponse = await fetch(optionsUrl, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!optionsResponse.ok) {
throw new Error('Unable to load passkey authentication options.');
}
const optionsJson = await optionsResponse.json();
const startAuthenticationResponse = await window.startAuthentication({
optionsJSON: optionsJson,
});
this.$refs.response.value = JSON.stringify(startAuthenticationResponse);
document.getElementById('passkey-login-form')?.submit();
} catch (error) {
this.error = error instanceof Error
? error.message
: 'Passkey authentication failed.';
// Common browser errors when no passkey exists for this account/device.
if (error instanceof Error && ['NotAllowedError', 'InvalidStateError', 'SecurityError'].includes(error.name)) {
this.showCreateHint = true;
}
this.busy = false;
}
},
copyCaptchaResponse() {
const turnstileValue = this.getFieldValue('cf-turnstile-response');
const recaptchaValue = this.getFieldValue('g-recaptcha-response');
if (this.$refs.turnstileResponse) {
this.$refs.turnstileResponse.value = turnstileValue;
}
if (this.$refs.recaptchaResponse) {
this.$refs.recaptchaResponse.value = recaptchaValue;
}
},
getFieldValue(fieldName) {
const candidates = document.querySelectorAll(`[name="${fieldName}"]`);
for (const field of candidates) {
if (field === this.$refs.turnstileResponse || field === this.$refs.recaptchaResponse) {
continue;
}
if (typeof field.value === 'string' && field.value.trim() !== '') {
return field.value.trim();
}
}
return '';
},
focusPasswordLogin() {
const username = document.getElementById('username');
if (username) {
username.focus();
return;
}
const password = document.getElementById('password');
if (password) {
password.focus();
}
},
}));
@@ -0,0 +1,115 @@
import Alpine from '@alpinejs/csp';
Alpine.data('passkeyManage', () => ({
supported: false,
busy: false,
name: '',
error: '',
success: '',
passkeys: [],
init() {
this.supported = typeof window.browserSupportsWebAuthn === 'function'
&& window.browserSupportsWebAuthn();
const rawPasskeys = this.$el.dataset.passkeys;
if (rawPasskeys) {
try {
this.passkeys = JSON.parse(rawPasskeys);
} catch (_error) {
this.passkeys = [];
}
}
},
async createPasskey() {
if (!this.supported) {
this.error = 'Your browser does not support passkeys.';
return;
}
this.busy = true;
this.error = '';
this.success = '';
try {
const optionsUrl = this.$el.dataset.optionsUrl || '/passkeys/register-options';
const storeUrl = this.$el.dataset.storeUrl || '/passkeys';
const optionsResponse = await window.axios.post(optionsUrl, {
name: this.name,
});
const options = optionsResponse.data?.options;
const registration = await window.startRegistration({ optionsJSON: options });
const storeResponse = await window.axios.post(storeUrl, {
name: this.name,
passkey: JSON.stringify(registration),
});
if (!storeResponse.data?.ok) {
throw new Error(storeResponse.data?.message || 'Could not store passkey.');
}
this.passkeys.unshift(storeResponse.data.passkey);
this.success = 'Passkey created successfully.';
this.name = '';
} catch (error) {
this.error = this.extractErrorMessage(error);
} finally {
this.busy = false;
}
},
async deletePasskey(passkeyId) {
if (!window.confirm('Delete this passkey? This device will no longer work for passkey login.')) {
return;
}
this.error = '';
this.success = '';
try {
const destroyUrl = `${this.$el.dataset.destroyBaseUrl}/${passkeyId}`;
const response = await window.axios.delete(destroyUrl);
if (!response.data?.ok) {
throw new Error('Could not delete passkey.');
}
this.passkeys = this.passkeys.filter((passkey) => passkey.id !== passkeyId);
this.success = 'Passkey deleted successfully.';
} catch (error) {
this.error = this.extractErrorMessage(error);
}
},
formatDate(value) {
if (!value) {
return 'Unknown';
}
return new Date(value).toLocaleString();
},
formatLastUsed(value) {
if (!value) {
return 'Not used yet';
}
return this.formatDate(value);
},
extractErrorMessage(error) {
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Something went wrong. Please try again.';
},
}));
+3
View File
@@ -39,6 +39,9 @@ const lazyComponentMap = {
'releaseMultiOps': () => import('./components/cart-page.js'), // same file
'authPage': () => import('./components/auth-page.js'),
'otpInput': () => import('./components/auth-page.js'), // same file
'passkeyLogin': () => import('./components/passkey-login.js'),
'passkeyManage': () => import('./components/passkey-manage.js'),
'adminUserPasskeys': () => import('./components/admin-user-passkeys.js'),
// --- Admin components (includes Chart.js — heavy) ---
'adminDashboard': () => import('./components/admin/dashboard.js'),
+9
View File
@@ -1,4 +1,13 @@
import axios from 'axios';
import {
browserSupportsWebAuthn,
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
window.startAuthentication = startAuthentication;
window.startRegistration = startRegistration;
@@ -606,6 +606,101 @@
</div>
@endif
@if(!is_array($user))
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900" x-data="adminUserPasskeys">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<i class="fas fa-fingerprint mr-2 text-blue-600 dark:text-blue-400"></i>Passkeys (WebAuthn)
</h3>
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
{{ $user->passkeys->count() }} registered
</span>
</div>
@if($user->passkeys->isEmpty())
<div class="mt-4 rounded-lg border border-dashed border-gray-300 p-3 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-300">
This user has no registered passkeys. They can create one from their profile security settings.
</div>
@else
<div class="mt-4 overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Created</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Last used</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
@foreach($user->passkeys as $passkey)
<tr>
<td class="px-4 py-3 text-gray-900 dark:text-gray-100">{{ $passkey->name }}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ optional($passkey->created_at)->diffForHumans() ?? 'Unknown' }}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ optional($passkey->last_used_at)->diffForHumans() ?? 'Not used yet' }}</td>
<td class="px-4 py-3 text-right">
<form method="POST" action="{{ route('admin.user-passkey.destroy', $passkey) }}" class="inline">
@csrf
@method('DELETE')
<button
type="submit"
data-confirm="Delete this passkey? The user will lose this device as a login method."
class="confirm-link rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
>
Delete
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<button
type="button"
@click="toggleDangerZone()"
class="text-sm font-semibold text-red-700 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200"
>
<i class="fas fa-exclamation-triangle mr-2"></i>Emergency actions
</button>
<div x-show="showDangerZone" x-cloak class="mt-3 space-y-3">
<p class="text-xs text-red-700 dark:text-red-300">
Remove all passkeys if this user lost or replaced their passkey device.
</p>
<form method="POST" action="{{ route('admin.user-passkeys.wipe') }}" class="space-y-3">
@csrf
<input type="hidden" name="user_id" value="{{ $user->id }}">
<div>
<label for="passkey_wipe_confirmation" class="block text-xs font-medium text-red-700 dark:text-red-300">Type WIPE to confirm</label>
<input
id="passkey_wipe_confirmation"
name="confirmation"
x-model="wipeConfirm"
type="text"
required
class="mt-1 w-full rounded-md border border-red-300 px-3 py-2 text-sm text-gray-900 focus:border-red-500 focus:ring-2 focus:ring-red-500 dark:border-red-700 dark:bg-gray-800 dark:text-gray-100"
>
</div>
<button
type="submit"
:disabled="!canWipe()"
class="rounded-lg bg-red-600 px-4 py-2 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-red-700 dark:hover:bg-red-800"
>
Remove ALL passkeys
</button>
</form>
</div>
</div>
</div>
@endif
<!-- Action Buttons -->
<div class="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="submit" class="px-6 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
+2
View File
@@ -135,6 +135,8 @@
</button>
</div>
</form>
@include('partials.passkey-authenticate')
</div>
<!-- Card Footer -->
@@ -0,0 +1,73 @@
<div
x-data="passkeyLogin"
x-cloak
x-show="supported"
data-options-url="{{ route('passkeys.authentication_options') }}"
data-server-passkey-error="{{ session('authenticatePasskey::reason') === 'invalid_passkey' ? '1' : '0' }}"
data-captcha-enabled="{{ \App\Support\CaptchaHelper::isEnabled() ? '1' : '0' }}"
data-captcha-field="{{ \App\Support\CaptchaHelper::isEnabled() ? \App\Support\CaptchaHelper::getResponseFieldName() : '' }}"
class="mt-6"
>
<div class="relative flex items-center">
<div class="flex-grow border-t border-gray-300 dark:border-gray-600"></div>
<span class="mx-4 flex-shrink text-xs uppercase text-gray-500 dark:text-gray-400">or</span>
<div class="flex-grow border-t border-gray-300 dark:border-gray-600"></div>
</div>
@if($message = session('authenticatePasskey::message'))
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300" role="alert">
{{ $message }}
</div>
@endif
<form id="passkey-login-form" method="POST" action="{{ route('passkeys.login') }}" class="mt-4">
@csrf
<input type="hidden" name="remember" x-ref="remember" value="0">
<input type="hidden" name="start_authentication_response" x-ref="response" value="">
<input type="hidden" name="cf-turnstile-response" x-ref="turnstileResponse" value="">
<input type="hidden" name="g-recaptcha-response" x-ref="recaptchaResponse" value="">
</form>
<button
type="button"
@click="authenticate()"
:disabled="busy"
class="mt-4 flex w-full items-center justify-center rounded-lg border border-blue-600 px-4 py-3 text-sm font-medium text-blue-700 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/30"
>
<i class="fas fa-fingerprint mr-2"></i>
<span x-text="busy ? 'Waiting for passkey...' : 'Sign in with passkey'"></span>
</button>
<p x-show="error" x-text="error" class="mt-2 text-sm text-red-600 dark:text-red-400"></p>
<div
x-show="showCreateHint"
x-cloak
class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200"
>
<p>
No passkey was found for this login on this device/browser.
</p>
<p class="mt-1">
Sign in with your password first. After login, go to your profile security settings and create a passkey.
</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
@click="focusPasswordLogin()"
class="rounded-md border border-blue-300 px-3 py-1.5 text-xs font-medium text-blue-800 hover:bg-blue-100 dark:border-blue-600 dark:text-blue-200 dark:hover:bg-blue-900/40"
>
Use password login
</button>
@if(Route::has('register'))
<a
href="{{ route('register') }}"
class="rounded-md border border-blue-300 px-3 py-1.5 text-xs font-medium text-blue-800 hover:bg-blue-100 dark:border-blue-600 dark:text-blue-200 dark:hover:bg-blue-900/40"
>
Create account
</a>
@endif
</div>
</div>
</div>
@@ -0,0 +1,100 @@
@php
$passkeyPayload = $user->passkeys->map(function ($passkey) {
return [
'id' => $passkey->id,
'name' => $passkey->name,
'created_at' => optional($passkey->created_at)?->toIso8601String(),
'last_used_at' => optional($passkey->last_used_at)?->toIso8601String(),
];
});
@endphp
<div
x-data="passkeyManage"
x-cloak
data-options-url="{{ route('passkeys.register_options') }}"
data-store-url="{{ route('passkeys.store') }}"
data-destroy-base-url="{{ url('passkeys') }}"
data-passkeys='@json($passkeyPayload)'
>
<p class="text-sm text-gray-600 dark:text-gray-300">
Register a passkey from your security key, browser, or password manager.
</p>
<template x-if="!supported">
<div class="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-200">
Your browser does not support passkeys.
</div>
</template>
<template x-if="supported">
<div class="mt-4 space-y-4">
<form @submit.prevent="createPasskey()" class="space-y-3">
<label for="passkey_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Passkey name
</label>
<div class="flex flex-col gap-3 sm:flex-row">
<input
id="passkey_name"
x-model="name"
type="text"
maxlength="255"
required
placeholder="Work laptop, iPhone, etc."
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<button
type="submit"
:disabled="busy"
class="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-primary-700 dark:hover:bg-primary-800"
>
<i class="fas fa-plus mr-2"></i>
<span x-text="busy ? 'Creating...' : 'Create passkey'"></span>
</button>
</div>
</form>
<p x-show="error" x-text="error" class="text-sm text-red-600 dark:text-red-400"></p>
<p x-show="success" x-text="success" class="text-sm text-green-600 dark:text-green-400"></p>
<template x-if="passkeys.length === 0">
<div class="rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-300">
No passkeys registered yet.
</div>
</template>
<template x-if="passkeys.length > 0">
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Created</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Last used</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<template x-for="passkey in passkeys" :key="passkey.id">
<tr>
<td class="px-4 py-3 text-gray-900 dark:text-gray-100" x-text="passkey.name"></td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300" x-text="formatDate(passkey.created_at)"></td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300" x-text="formatLastUsed(passkey.last_used_at)"></td>
<td class="px-4 py-3 text-right">
<button
type="button"
@click="deletePasskey(passkey.id)"
class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
>
Delete
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
</div>
+9
View File
@@ -467,6 +467,15 @@
</p>
</div>
</div>
<div class="p-6 border-t border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
<i class="fas fa-fingerprint mr-2 text-primary-600"></i>Passkeys
</h3>
<div class="mt-4">
@include('partials.passkeys-manage')
</div>
</div>
</div>
</div>
@endsection
+16 -1
View File
@@ -51,6 +51,8 @@ use App\Http\Controllers\ApiHelpController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\PasskeyLoginController;
use App\Http\Controllers\Auth\PasskeyManagementController;
use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\BooksController;
@@ -83,6 +85,7 @@ use App\Http\Controllers\SearchSuggestController;
use App\Http\Controllers\SeriesController;
use App\Http\Controllers\StatusPageController;
use App\Http\Controllers\TermsController;
use Spatie\LaravelPasskeys\Http\Controllers\GeneratePasskeyAuthenticationOptionsController;
// Serve cover images from storage - Must be public (no auth required)
Route::get('/covers/{type}/{filename}', [CoverController::class, 'show'])
@@ -108,6 +111,9 @@ Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class,
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('login', [LoginController::class, 'login'])->name('login.post');
Route::match(['GET', 'POST'], 'logout', [LoginController::class, 'logout'])->name('logout');
Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class)
->name('passkeys.authentication_options');
Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login');
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify');
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
@@ -123,7 +129,14 @@ Route::post('contact-us', [ContactUsController::class, 'contact']);
Route::get('status', [StatusPageController::class, 'showStatusPage'])->name('status');
Route::middleware('isVerified')->group(function () {
Route::middleware(['auth', 'isVerified'])->group(function () {
Route::post('passkeys/register-options', [PasskeyManagementController::class, 'options'])
->name('passkeys.register_options');
Route::post('passkeys', [PasskeyManagementController::class, 'store'])
->name('passkeys.store');
Route::delete('passkeys/{passkey}', [PasskeyManagementController::class, 'destroy'])
->name('passkeys.destroy');
Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword');
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
@@ -224,6 +237,8 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
Route::get('category-delete', [AdminCategoryController::class, 'destroy'])->name('admin.category-delete');
Route::get('user-list', [AdminUserController::class, 'index'])->name('admin.user-list');
Route::match(['GET', 'POST'], 'user-edit', [AdminUserController::class, 'edit'])->name('admin.user-edit');
Route::delete('user-passkey/{passkey}', [AdminUserController::class, 'destroyPasskey'])->name('admin.user-passkey.destroy');
Route::post('user-passkeys/wipe', [AdminUserController::class, 'wipePasskeys'])->name('admin.user-passkeys.wipe');
Route::post('user-delete', [AdminUserController::class, 'destroy'])->name('admin.user-delete');
Route::post('verify', [AdminUserController::class, 'verify'])->name('admin.verify');
Route::post('resendverification', [AdminUserController::class, 'resendVerification'])->name('admin.resend-verification');
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Http\Middleware\Google2FAMiddleware;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class PasskeyAdminTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
'session.driver' => 'array',
]);
DB::purge();
DB::reconnect();
$this->createSchema();
$this->seedSettings();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->withoutMiddleware(Google2FAMiddleware::class);
}
public function test_non_admin_cannot_delete_another_users_passkey(): void
{
$regularUser = $this->createUserWithRole('User');
$targetUser = $this->createUserWithRole('User');
$passkeyId = $this->createPasskey($targetUser->id);
$response = $this->actingAs($regularUser)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
$response->assertForbidden();
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
}
public function test_admin_can_delete_single_passkey_for_another_user(): void
{
$admin = $this->createUserWithRole('Admin');
$targetUser = $this->createUserWithRole('User');
$passkeyId = $this->createPasskey($targetUser->id, 'Work Laptop');
$response = $this->actingAs($admin)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
$response->assertRedirect('admin/user-edit?id='.$targetUser->id);
$this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
}
public function test_admin_wipe_requires_wipe_confirmation_text(): void
{
$admin = $this->createUserWithRole('Admin');
$targetUser = $this->createUserWithRole('User');
$passkeyId = $this->createPasskey($targetUser->id);
$response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
'user_id' => $targetUser->id,
'confirmation' => 'NOPE',
]);
$response->assertSessionHasErrors('confirmation');
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
}
public function test_admin_can_wipe_all_passkeys_for_target_user_only(): void
{
$admin = $this->createUserWithRole('Admin');
$targetUser = $this->createUserWithRole('User');
$otherUser = $this->createUserWithRole('User');
$targetPasskeyOne = $this->createPasskey($targetUser->id, 'Target A');
$targetPasskeyTwo = $this->createPasskey($targetUser->id, 'Target B');
$otherPasskey = $this->createPasskey($otherUser->id, 'Other');
$response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
'user_id' => $targetUser->id,
'confirmation' => 'WIPE',
]);
$response->assertRedirect('admin/user-edit?id='.$targetUser->id);
$this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyOne]);
$this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyTwo]);
$this->assertDatabaseHas('passkeys', ['id' => $otherPasskey]);
}
private function createSchema(): void
{
Schema::create('settings', function (Blueprint $table): void {
$table->string('name')->primary();
$table->text('value')->nullable();
});
Schema::create('roles', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->integer('rate_limit')->default(60);
$table->boolean('isdefault')->default(false);
$table->unsignedInteger('defaultinvites')->default(0);
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->increments('id');
$table->string('username');
$table->string('email')->unique();
$table->string('password');
$table->unsignedInteger('roles_id')->default(1);
$table->integer('rate_limit')->default(60);
$table->string('api_token')->nullable();
$table->boolean('verified')->default(true);
$table->boolean('can_post')->default(true);
$table->timestamp('email_verified_at')->nullable();
$table->timestamp('lastlogin')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('model_has_roles', function (Blueprint $table): void {
$table->unsignedInteger('role_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['role_id', 'model_id', 'model_type']);
});
Schema::create('model_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['permission_id', 'model_id', 'model_type']);
});
Schema::create('role_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->unsignedInteger('role_id');
$table->primary(['permission_id', 'role_id']);
});
Schema::create('user_activities', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('user_id')->nullable();
$table->string('username');
$table->string('activity_type', 50);
$table->text('description');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('root_categories', function (Blueprint $table): void {
$table->increments('id');
$table->string('title')->default('');
$table->integer('status')->default(1);
$table->timestamps();
});
Schema::create('categories', function (Blueprint $table): void {
$table->increments('id');
$table->string('title')->default('');
$table->unsignedInteger('root_categories_id')->nullable();
$table->integer('status')->default(1);
$table->timestamps();
});
Schema::create('user_excluded_categories', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('users_id');
$table->unsignedInteger('categories_id');
});
Schema::create('passkeys', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('authenticatable_id');
$table->text('name');
$table->text('credential_id');
$table->json('data');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
private function seedSettings(): void
{
DB::table('settings')->insert([
['name' => 'title', 'value' => 'NNTmux Test'],
['name' => 'home_link', 'value' => '/'],
['name' => 'categorizeforeign', 'value' => '0'],
['name' => 'catwebdl', 'value' => '0'],
]);
DB::table('root_categories')->insert([
'id' => 1,
'title' => 'General',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createUserWithRole(string $roleName): User
{
$role = Role::query()->firstOrCreate(
['name' => $roleName, 'guard_name' => 'web'],
['rate_limit' => 60, 'isdefault' => $roleName === 'User', 'defaultinvites' => 1]
);
$user = User::query()->create([
'username' => strtolower($roleName).'_'.Str::random(8),
'email' => Str::random(12).'@example.test',
'password' => bcrypt('password'),
'roles_id' => $role->id,
'rate_limit' => 60,
'api_token' => Str::random(32),
'verified' => true,
'email_verified_at' => now(),
'lastlogin' => now(),
]);
$user->assignRole($role);
return $user->fresh();
}
private function createPasskey(int $userId, string $name = 'Key'): int
{
return (int) DB::table('passkeys')->insertGetId([
'authenticatable_id' => $userId,
'name' => $name,
'credential_id' => 'credential-'.Str::random(8),
'data' => json_encode(['origin' => 'tests'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Events\UserLoggedIn;
use App\Http\Middleware\Google2FAMiddleware;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class PasskeyAuthenticationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
'session.driver' => 'array',
]);
DB::purge();
DB::reconnect();
$this->createSchema();
$this->seedSettings();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->withoutMiddleware(Google2FAMiddleware::class);
}
public function test_passkey_authentication_logs_user_in_and_sets_2fa_session_flag(): void
{
Event::fake([UserLoggedIn::class]);
$user = $this->createUser('passkey-user@example.test');
$passkey = new Passkey;
$passkey->setRawAttributes([
'id' => 1,
'authenticatable_id' => $user->id,
'name' => 'Laptop',
'credential_id' => 'credential-1',
'data' => '{}',
], true);
$passkey->setRelation('authenticatable', $user);
FakeFindPasskeyAction::$passkey = $passkey;
config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
$response = $this
->withSession(['passkey-authentication-options' => '{}'])
->post(route('passkeys.login'), [
'start_authentication_response' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
'remember' => false,
]);
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
$this->assertTrue((bool) session(config('google2fa.session_var')));
Event::assertDispatched(UserLoggedIn::class);
}
public function test_unverified_users_cannot_authenticate_with_passkeys(): void
{
$user = $this->createUser('unverified@example.test', false);
$passkey = new Passkey;
$passkey->setRawAttributes([
'id' => 2,
'authenticatable_id' => $user->id,
'name' => 'Phone',
'credential_id' => 'credential-2',
'data' => '{}',
], true);
$passkey->setRelation('authenticatable', $user);
FakeFindPasskeyAction::$passkey = $passkey;
config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
$response = $this
->from(route('login'))
->withSession(['passkey-authentication-options' => '{}'])
->post(route('passkeys.login'), [
'start_authentication_response' => json_encode(['id' => 'credential-2'], JSON_THROW_ON_ERROR),
]);
$response->assertRedirect(route('login'));
$this->assertGuest();
$this->assertSame('You have not verified your email address!', session('authenticatePasskey::message'));
}
private function createSchema(): void
{
Schema::create('settings', function (Blueprint $table): void {
$table->string('name')->primary();
$table->text('value')->nullable();
});
Schema::create('roles', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->integer('rate_limit')->default(60);
$table->boolean('isdefault')->default(false);
$table->unsignedInteger('defaultinvites')->default(0);
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->increments('id');
$table->string('username');
$table->string('email')->unique();
$table->string('password');
$table->unsignedInteger('roles_id')->default(1);
$table->integer('rate_limit')->default(60);
$table->string('api_token')->nullable();
$table->boolean('verified')->default(true);
$table->boolean('can_post')->default(true);
$table->timestamp('email_verified_at')->nullable();
$table->timestamp('lastlogin')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('model_has_roles', function (Blueprint $table): void {
$table->unsignedInteger('role_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['role_id', 'model_id', 'model_type']);
});
Schema::create('model_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['permission_id', 'model_id', 'model_type']);
});
Schema::create('role_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->unsignedInteger('role_id');
$table->primary(['permission_id', 'role_id']);
});
Schema::create('user_activities', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('user_id')->nullable();
$table->string('username');
$table->string('activity_type', 50);
$table->text('description');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('passkeys', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('authenticatable_id');
$table->text('name');
$table->text('credential_id');
$table->json('data');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
private function seedSettings(): void
{
DB::table('settings')->insert([
['name' => 'title', 'value' => 'NNTmux Test'],
['name' => 'home_link', 'value' => '/'],
['name' => 'categorizeforeign', 'value' => '0'],
['name' => 'catwebdl', 'value' => '0'],
]);
}
private function createUser(string $email, bool $verified = true): User
{
$role = Role::query()->firstOrCreate(
['name' => 'User', 'guard_name' => 'web'],
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
);
$user = User::query()->create([
'username' => 'user_'.md5($email),
'email' => $email,
'password' => bcrypt('password'),
'roles_id' => $role->id,
'rate_limit' => 60,
'api_token' => md5($email),
'verified' => $verified,
'email_verified_at' => $verified ? now() : null,
'lastlogin' => now(),
]);
$user->assignRole($role);
return $user->fresh();
}
}
class FakeFindPasskeyAction extends FindPasskeyToAuthenticateAction
{
public static ?Passkey $passkey = null;
public function execute(string $publicKeyCredentialJson, string $passkeyOptionsJson): ?Passkey
{
return self::$passkey;
}
}
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Http\Middleware\Google2FAMiddleware;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction;
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class PasskeyManagementTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
'session.driver' => 'array',
]);
DB::purge();
DB::reconnect();
$this->createSchema();
$this->seedSettings();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->withoutMiddleware(Google2FAMiddleware::class);
config()->set('passkeys.actions.generate_passkey_register_options', FakeGeneratePasskeyRegisterOptionsAction::class);
config()->set('passkeys.actions.store_passkey', FakeStorePasskeyAction::class);
}
public function test_verified_user_can_generate_options_store_and_delete_a_passkey(): void
{
$user = $this->createUser('passkey-manage@example.test');
$optionsResponse = $this
->actingAs($user)
->postJson(route('passkeys.register_options'), ['name' => 'MacBook']);
$optionsResponse->assertOk()->assertJsonPath('ok', true);
$storeResponse = $this
->actingAs($user)
->withSession([
'passkey-registration-options' => '{}',
])
->postJson(route('passkeys.store'), [
'name' => 'MacBook',
'passkey' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
]);
$storeResponse->assertOk()->assertJsonPath('ok', true);
$passkeyId = (int) $storeResponse->json('passkey.id');
$this->assertDatabaseHas('passkeys', ['id' => $passkeyId, 'authenticatable_id' => $user->id]);
$deleteResponse = $this
->actingAs($user)
->deleteJson(route('passkeys.destroy', ['passkey' => $passkeyId]));
$deleteResponse->assertOk()->assertJson(['ok' => true]);
$this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
}
public function test_guest_cannot_hit_passkey_management_routes(): void
{
$this->post(route('passkeys.register_options'), ['name' => 'Guest key'])->assertRedirect(route('login'));
$this->post(route('passkeys.store'), ['name' => 'Guest key', 'passkey' => '{}'])->assertRedirect(route('login'));
}
private function createSchema(): void
{
Schema::create('settings', function (Blueprint $table): void {
$table->string('name')->primary();
$table->text('value')->nullable();
});
Schema::create('roles', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->integer('rate_limit')->default(60);
$table->boolean('isdefault')->default(false);
$table->unsignedInteger('defaultinvites')->default(0);
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->string('guard_name');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->increments('id');
$table->string('username');
$table->string('email')->unique();
$table->string('password');
$table->unsignedInteger('roles_id')->default(1);
$table->integer('rate_limit')->default(60);
$table->string('api_token')->nullable();
$table->boolean('verified')->default(true);
$table->boolean('can_post')->default(true);
$table->timestamp('email_verified_at')->nullable();
$table->timestamp('lastlogin')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('model_has_roles', function (Blueprint $table): void {
$table->unsignedInteger('role_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['role_id', 'model_id', 'model_type']);
});
Schema::create('model_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->string('model_type');
$table->unsignedInteger('model_id');
$table->primary(['permission_id', 'model_id', 'model_type']);
});
Schema::create('role_has_permissions', function (Blueprint $table): void {
$table->unsignedInteger('permission_id');
$table->unsignedInteger('role_id');
$table->primary(['permission_id', 'role_id']);
});
Schema::create('user_activities', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('user_id')->nullable();
$table->string('username');
$table->string('activity_type', 50);
$table->text('description');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('passkeys', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('authenticatable_id');
$table->text('name');
$table->text('credential_id');
$table->json('data');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
private function seedSettings(): void
{
DB::table('settings')->insert([
['name' => 'title', 'value' => 'NNTmux Test'],
['name' => 'home_link', 'value' => '/'],
['name' => 'categorizeforeign', 'value' => '0'],
['name' => 'catwebdl', 'value' => '0'],
]);
}
private function createUser(string $email): User
{
$role = Role::query()->firstOrCreate(
['name' => 'User', 'guard_name' => 'web'],
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
);
$user = User::query()->create([
'username' => 'user_'.md5($email),
'email' => $email,
'password' => bcrypt('password'),
'roles_id' => $role->id,
'rate_limit' => 60,
'api_token' => md5($email),
'verified' => true,
'email_verified_at' => now(),
'lastlogin' => now(),
]);
$user->assignRole($role);
return $user->fresh();
}
}
class FakeGeneratePasskeyRegisterOptionsAction extends GeneratePasskeyRegisterOptionsAction
{
public function execute(HasPasskeys $authenticatable, bool $asJson = true): string
{
return json_encode([
'challenge' => 'test-challenge',
'rp' => ['name' => 'NNTmux'],
'user' => ['id' => (string) $authenticatable->id],
], JSON_THROW_ON_ERROR);
}
}
class FakeStorePasskeyAction extends StorePasskeyAction
{
public function execute(
HasPasskeys $authenticatable,
string $passkeyJson,
string $passkeyOptionsJson,
string $hostName,
array $additionalProperties = []
): Passkey {
DB::table('passkeys')->insertGetId([
'authenticatable_id' => $authenticatable->id,
'name' => $additionalProperties['name'] ?? 'Unnamed',
'credential_id' => 'credential-'.md5($passkeyJson),
'data' => json_encode(['host' => $hostName], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
return Passkey::query()->where('authenticatable_id', $authenticatable->id)->latest('id')->firstOrFail();
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Support\Passkeys\RelyingPartyIdResolver;
use Illuminate\Http\Request;
use Tests\TestCase;
class RelyingPartyIdResolverTest extends TestCase
{
public function test_prefers_forwarded_host_for_proxied_requests(): void
{
config()->set('passkeys.relying_party.id', 'example.com');
$request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET', [], [], [], [
'HTTP_X_FORWARDED_HOST' => 'auth.example.com',
]);
$this->assertSame('auth.example.com', RelyingPartyIdResolver::resolve($request));
}
public function test_prefers_configured_domain_over_localhost_request_host(): void
{
config()->set('passkeys.relying_party.id', 'example.com');
$request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
$this->assertSame('example.com', RelyingPartyIdResolver::resolve($request));
}
public function test_rejects_ip_hosts_and_falls_back_to_localhost(): void
{
config()->set('passkeys.relying_party.id', '127.0.0.1');
$request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET');
$this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
}
public function test_allows_localhost_for_development(): void
{
config()->set('passkeys.relying_party.id', 'localhost');
$request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
$this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
}
}