diff --git a/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php b/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php new file mode 100644 index 000000000..53dbba99f --- /dev/null +++ b/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php b/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php new file mode 100644 index 000000000..a07c7c554 --- /dev/null +++ b/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php b/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php new file mode 100644 index 000000000..9b0689784 --- /dev/null +++ b/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php @@ -0,0 +1,32 @@ +toJson($options); + + Session::flash('passkey-authentication-options', $options); + + return $options; + } +} diff --git a/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php b/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php new file mode 100644 index 000000000..3a387b45f --- /dev/null +++ b/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php @@ -0,0 +1,59 @@ + '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(), + ); + } +} diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index 7d53eedb5..342690557 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -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); + } } diff --git a/app/Http/Controllers/Auth/PasskeyLoginController.php b/app/Http/Controllers/Auth/PasskeyLoginController.php new file mode 100644 index 000000000..605663e6c --- /dev/null +++ b/app/Http/Controllers/Auth/PasskeyLoginController.php @@ -0,0 +1,108 @@ +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'); + } +} diff --git a/app/Http/Controllers/Auth/PasskeyManagementController.php b/app/Http/Controllers/Auth/PasskeyManagementController.php new file mode 100644 index 000000000..0e48f8574 --- /dev/null +++ b/app/Http/Controllers/Auth/PasskeyManagementController.php @@ -0,0 +1,107 @@ +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.' + ); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index a91437419..bcd203c80 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -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'); diff --git a/app/Models/User.php b/app/Models/User.php index 1cf0e2b75..5fe49c1d3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 $series * @property-read Collection $promotionStats * @property-read Collection $roleHistory + * @property-read Collection $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'; diff --git a/app/Support/Passkeys/RelyingPartyIdResolver.php b/app/Support/Passkeys/RelyingPartyIdResolver.php new file mode 100644 index 000000000..b40d3c6e9 --- /dev/null +++ b/app/Support/Passkeys/RelyingPartyIdResolver.php @@ -0,0 +1,57 @@ +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, '.'); + } +} diff --git a/composer.json b/composer.json index 91692efe5..0f7b18e03 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 745f793b1..2bab10f4f 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/passkeys.php b/config/passkeys.php new file mode 100644 index 000000000..35084080d --- /dev/null +++ b/config/passkeys.php @@ -0,0 +1,54 @@ + '/', + + /* + * 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), + ], +]; diff --git a/database/migrations/2026_04_24_000000_create_passkeys_table.php b/database/migrations/2026_04_24_000000_create_passkeys_table.php new file mode 100644 index 000000000..8448e93c4 --- /dev/null +++ b/database/migrations/2026_04_24_000000_create_passkeys_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/package.json b/package.json index 2e4010c5d..3861026b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/alpine/components/admin-user-passkeys.js b/resources/js/alpine/components/admin-user-passkeys.js new file mode 100644 index 000000000..45073e13e --- /dev/null +++ b/resources/js/alpine/components/admin-user-passkeys.js @@ -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'; + }, +})); diff --git a/resources/js/alpine/components/passkey-login.js b/resources/js/alpine/components/passkey-login.js new file mode 100644 index 000000000..cbf9f9bff --- /dev/null +++ b/resources/js/alpine/components/passkey-login.js @@ -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(); + } + }, +})); diff --git a/resources/js/alpine/components/passkey-manage.js b/resources/js/alpine/components/passkey-manage.js new file mode 100644 index 000000000..d44075157 --- /dev/null +++ b/resources/js/alpine/components/passkey-manage.js @@ -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.'; + }, +})); diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js index 261601337..c5c3220fc 100644 --- a/resources/js/alpine/lazy-loader.js +++ b/resources/js/alpine/lazy-loader.js @@ -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'), diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 5f1390b01..3856182f6 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -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; diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index e919fef66..4425333b4 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -606,6 +606,101 @@ @endif + @if(!is_array($user)) +
+
+

+ Passkeys (WebAuthn) +

+ + {{ $user->passkeys->count() }} registered + +
+ + @if($user->passkeys->isEmpty()) +
+ This user has no registered passkeys. They can create one from their profile security settings. +
+ @else +
+ + + + + + + + + + + @foreach($user->passkeys as $passkey) + + + + + + + @endforeach + +
NameCreatedLast usedActions
{{ $passkey->name }}{{ optional($passkey->created_at)->diffForHumans() ?? 'Unknown' }}{{ optional($passkey->last_used_at)->diffForHumans() ?? 'Not used yet' }} +
+ @csrf + @method('DELETE') + +
+
+
+ @endif + +
+ + +
+

+ Remove all passkeys if this user lost or replaced their passkey device. +

+ +
+ @csrf + + +
+ + +
+ + +
+
+
+
+ @endif +
+ + @include('partials.passkey-authenticate') diff --git a/resources/views/partials/passkey-authenticate.blade.php b/resources/views/partials/passkey-authenticate.blade.php new file mode 100644 index 000000000..7316ad245 --- /dev/null +++ b/resources/views/partials/passkey-authenticate.blade.php @@ -0,0 +1,73 @@ +
+
+
+ or +
+
+ + @if($message = session('authenticatePasskey::message')) + + @endif + +
+ @csrf + + + + +
+ + + +

+ +
+

+ No passkey was found for this login on this device/browser. +

+

+ Sign in with your password first. After login, go to your profile security settings and create a passkey. +

+ +
+ + @if(Route::has('register')) + + Create account + + @endif +
+
+
diff --git a/resources/views/partials/passkeys-manage.blade.php b/resources/views/partials/passkeys-manage.blade.php new file mode 100644 index 000000000..ff7c4a0a9 --- /dev/null +++ b/resources/views/partials/passkeys-manage.blade.php @@ -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 + +
+

+ Register a passkey from your security key, browser, or password manager. +

+ + + + +
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php index 9b139a1ce..d6094c80d 100644 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/profile/edit.blade.php @@ -467,6 +467,15 @@

+ +
+

+ Passkeys +

+
+ @include('partials.passkeys-manage') +
+
@endsection diff --git a/routes/web.php b/routes/web.php index e68fe08da..5a1bdc7d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/Auth/PasskeyAdminTest.php b/tests/Feature/Auth/PasskeyAdminTest.php new file mode 100644 index 000000000..5cdd52b35 --- /dev/null +++ b/tests/Feature/Auth/PasskeyAdminTest.php @@ -0,0 +1,253 @@ + '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(), + ]); + } +} diff --git a/tests/Feature/Auth/PasskeyAuthenticationTest.php b/tests/Feature/Auth/PasskeyAuthenticationTest.php new file mode 100644 index 000000000..132062641 --- /dev/null +++ b/tests/Feature/Auth/PasskeyAuthenticationTest.php @@ -0,0 +1,226 @@ + '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; + } +} diff --git a/tests/Feature/Auth/PasskeyManagementTest.php b/tests/Feature/Auth/PasskeyManagementTest.php new file mode 100644 index 000000000..1a977fcda --- /dev/null +++ b/tests/Feature/Auth/PasskeyManagementTest.php @@ -0,0 +1,230 @@ + '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(); + } +} diff --git a/tests/Feature/Auth/RelyingPartyIdResolverTest.php b/tests/Feature/Auth/RelyingPartyIdResolverTest.php new file mode 100644 index 000000000..b71d3f6f6 --- /dev/null +++ b/tests/Feature/Auth/RelyingPartyIdResolverTest.php @@ -0,0 +1,50 @@ +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)); + } +}