+
+export { default as Field } from "./Field.vue"
+export { default as FieldContent } from "./FieldContent.vue"
+export { default as FieldDescription } from "./FieldDescription.vue"
+export { default as FieldError } from "./FieldError.vue"
+export { default as FieldGroup } from "./FieldGroup.vue"
+export { default as FieldLabel } from "./FieldLabel.vue"
+export { default as FieldLegend } from "./FieldLegend.vue"
+export { default as FieldSeparator } from "./FieldSeparator.vue"
+export { default as FieldSet } from "./FieldSet.vue"
+export { default as FieldTitle } from "./FieldTitle.vue"
diff --git a/resources/js/entity-list/components/EntityList.vue b/resources/js/entity-list/components/EntityList.vue
index 79e231b5f..b89b5577e 100644
--- a/resources/js/entity-list/components/EntityList.vue
+++ b/resources/js/entity-list/components/EntityList.vue
@@ -248,7 +248,9 @@
if(await showDeleteConfirm(entityList.config.deleteConfirmationText, {
highlightElement: () => el.value?.querySelector(`[data-instance-row="${instanceId}"]`) as HTMLElement,
})) {
- await api.delete(route('code16.sharp.api.list.delete', { entityKey, instanceId }));
+ await api.delete(route('code16.sharp.api.list.delete', { entityKey, instanceId }), {
+ params: { ...props.entityList.query },
+ });
commands.handleCommandResponse({ action: 'reload' });
}
}
@@ -775,7 +777,7 @@
-
+
{{ item[field.key] }}
diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php
index aff0899c3..acf794310 100644
--- a/resources/lang/en/auth.php
+++ b/resources/lang/en/auth.php
@@ -28,6 +28,27 @@
],
],
],
+ 'passkeys' => [
+ 'entity_label' => 'Passkey',
+ 'list' => [
+ 'commands' => [
+ 'rename' => [
+ 'command_label' => 'Rename passkey',
+ 'name_field_label' => 'Name',
+ ],
+ 'add' => [
+ 'command_label' => 'New passkey...',
+ ],
+ ],
+ 'fields' => [
+ 'name' => 'Name',
+ 'usage' => 'Usage',
+ 'created_at' => 'Created at',
+ 'last_used_at' => 'Last used at',
+ ],
+ 'used_in_this_browser_badge' => 'Used in this browser',
+ ],
+ ],
'password_change' => [
'command' => [
'label' => 'Change password...',
diff --git a/resources/lang/en/pages/auth/login.php b/resources/lang/en/pages/auth/login.php
index 7b06c2d0d..7759727f0 100644
--- a/resources/lang/en/pages/auth/login.php
+++ b/resources/lang/en/pages/auth/login.php
@@ -8,5 +8,7 @@
'code_field' => 'Code',
'remember' => 'Remember me',
'button' => 'Login',
+ 'passkey_button' => 'Use passkey',
+ 'or_label' => 'or',
'forgot_password_link' => 'Forgot password?',
];
diff --git a/resources/lang/en/pages/auth/passkeys.php b/resources/lang/en/pages/auth/passkeys.php
new file mode 100644
index 000000000..6637baf62
--- /dev/null
+++ b/resources/lang/en/pages/auth/passkeys.php
@@ -0,0 +1,17 @@
+ 'Create a passkey',
+ 'name_field' => 'Name',
+ 'description' => 'Your device supports passkeys, a password replacement that validates your identity using touch, facial recognition, a device password, or a PIN.
Passkeys can be used for sign-in as a simple and secure alternative to your password and two-factor credentials.
',
+ 'name_help_text' => 'The passkey name will help you identify it later.',
+ 'prompt_version' => [
+ 'button' => 'Create passkey',
+ 'skip_prompt_button' => 'Remind me later',
+ 'never_ask_again_button' => "Don't ask me again in this browser",
+ ],
+ 'account_version' => [
+ 'button' => 'Create passkey',
+ 'cancel_button' => 'Cancel',
+ ],
+];
diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php
index 7c1e7b02e..74b76b268 100644
--- a/resources/lang/fr/auth.php
+++ b/resources/lang/fr/auth.php
@@ -28,6 +28,27 @@
],
],
],
+ 'passkeys' => [
+ 'entity_label' => 'Clé d’accès',
+ 'list' => [
+ 'commands' => [
+ 'rename' => [
+ 'command_label' => 'Renommer la clé d’accès',
+ 'name_field_label' => 'Nom',
+ ],
+ 'add' => [
+ 'command_label' => 'Nouvelle clé d’accès...',
+ ],
+ ],
+ 'fields' => [
+ 'name' => 'Nom',
+ 'usage' => 'Utilisation',
+ 'created_at' => 'Créé le',
+ 'last_used_at' => 'Dernière utilisation',
+ ],
+ 'used_in_this_browser_badge' => 'Utilisée dans ce navigateur',
+ ],
+ ],
'password_change' => [
'command' => [
'label' => 'Modifier le mot de passe...',
diff --git a/resources/lang/fr/pages/auth/login.php b/resources/lang/fr/pages/auth/login.php
index 9b4e7a5eb..d54965539 100644
--- a/resources/lang/fr/pages/auth/login.php
+++ b/resources/lang/fr/pages/auth/login.php
@@ -8,5 +8,6 @@
'code_field' => 'Code',
'remember' => 'Rester connecté',
'button' => 'Connexion',
+ 'passkey_button' => 'Utiliser une clé d’accès',
'forgot_password_link' => 'Mot de passe oublié ?',
];
diff --git a/resources/lang/fr/pages/auth/passkeys.php b/resources/lang/fr/pages/auth/passkeys.php
new file mode 100644
index 000000000..2841c0fef
--- /dev/null
+++ b/resources/lang/fr/pages/auth/passkeys.php
@@ -0,0 +1,17 @@
+ 'Créer une clé d’accès',
+ 'name_field' => 'Nom',
+ 'description' => 'Votre appareil prend en charge les clés d’accès, un remplaçant du mot de passe qui valide votre identité à l’aide du toucher, de la reconnaissance faciale, d’un mot de passe d’appareil ou d’un code PIN.
Les clés d’accès peuvent être utilisées pour la connexion en tant qu’alternative simple et sûre à votre mot de passe et à vos identifiants à deux facteurs.
',
+ 'name_help_text' => 'Le nom de la clé vous aidera à l’identifier plus tard.',
+ 'prompt_version' => [
+ 'button' => 'Créer une clé',
+ 'skip_prompt_button' => 'Plus tard',
+ 'never_ask_again_button' => 'Ne plus me demander sur ce navigateur',
+ ],
+ 'account_version' => [
+ 'button' => 'Créer une clé',
+ 'cancel_button' => 'Annuler',
+ ],
+];
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index 83c820550..dd0ab9c8d 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -24,7 +24,7 @@
{{-- --}}
@php
- config()->set('ziggy', ['only' => 'code16.sharp.*', 'skip-route-function' => true]);
+ config()->set('ziggy', ['only' => ['code16.sharp.*', 'passkeys.*'], 'skip-route-function' => true]);
\Tighten\Ziggy\BladeRouteGenerator::$generated = false; // Don't generate "merge script" (https://github.com/code16/sharp/issues/649)
@endphp
@routes(nonce: \Illuminate\Support\Facades\Vite::cspNonce())
diff --git a/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php
new file mode 100644
index 000000000..738a6f3ce
--- /dev/null
+++ b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php
@@ -0,0 +1,44 @@
+addField(
+ SharpFormTextField::make('name')
+ ->setLabel(trans('sharp::auth.passkeys.list.commands.rename.name_field_label'))
+ );
+ }
+
+ protected function initialData(mixed $instanceId): array
+ {
+ return [
+ 'name' => Passkey::findOrFail($instanceId)->name,
+ ];
+ }
+
+ public function execute(mixed $instanceId, array $data = []): array
+ {
+ $this->validate($data, [
+ 'name' => 'required',
+ ]);
+
+ Passkey::findOrFail($instanceId)->update([
+ 'name' => $data['name'],
+ ]);
+
+ return $this->refresh($instanceId);
+ }
+}
diff --git a/src/Auth/Passkeys/Entity/PasskeyEntity.php b/src/Auth/Passkeys/Entity/PasskeyEntity.php
new file mode 100644
index 000000000..49a67fc3d
--- /dev/null
+++ b/src/Auth/Passkeys/Entity/PasskeyEntity.php
@@ -0,0 +1,16 @@
+addField(
+ EntityListField::make('name')
+ ->setLabel(trans('sharp::auth.passkeys.list.fields.name'))
+ )
+ ->addField(
+ EntityListBadgeField::make('usage')
+ ->setLabel(trans('sharp::auth.passkeys.list.fields.usage'))
+ )
+ ->addField(
+ EntityListField::make('last_used_at')
+ ->setLabel(trans('sharp::auth.passkeys.list.fields.last_used_at'))
+ )
+ ->addField(
+ EntityListField::make('created_at')
+ ->setLabel(trans('sharp::auth.passkeys.list.fields.created_at'))
+ );
+ }
+
+ public function buildListConfig(): void
+ {
+ $this->configurePrimaryEntityCommand('add');
+ }
+
+ public function getEntityCommands(): ?array
+ {
+ return [
+ 'add' => new class() extends EntityCommand
+ {
+ public function label(): string
+ {
+ return trans('sharp::auth.passkeys.list.commands.add.command_label');
+ }
+
+ public function execute(array $data = []): array
+ {
+ redirect()->setIntendedUrl(sharp()->context()->breadcrumb()->getCurrentSegmentUrl());
+
+ return $this->link(route('code16.sharp.passkeys.create'));
+ }
+ },
+ ];
+ }
+
+ public function getInstanceCommands(): ?array
+ {
+ return [
+ UpdatePasskeyNameCommand::class,
+ ];
+ }
+
+ public function delete(mixed $id): void
+ {
+ $this->currentUser()->passKeys()->findOrFail($id)->delete();
+ }
+
+ public function getListData(): array|Arrayable
+ {
+ return $this
+ ->setCustomTransformer('usage', function ($value, Model $passkey) {
+ return $passkey->getKey() == request()->cookie('sharp_last_used_passkey')
+ ? trans('sharp::auth.passkeys.list.used_in_this_browser_badge')
+ : null;
+ })
+ ->setCustomTransformer('last_used_at', function ($value, Model $passkey) {
+ return $passkey->last_used_at?->diffForHumans();
+ })
+ ->setCustomTransformer('created_at', function ($value, Model $passkey) {
+ return $passkey->created_at?->isoFormat('LLL');
+ })
+ ->transform(
+ $this->currentUser()->passkeys()->orderByDesc('created_at')->get()
+ );
+ }
+
+ protected function currentUser(): Authenticatable&HasPasskeys
+ {
+ /** @var Authenticatable&HasPasskeys $user */
+ $user = auth()->user();
+
+ return $user;
+ }
+}
diff --git a/src/Auth/Passkeys/PasskeyEventSubscriber.php b/src/Auth/Passkeys/PasskeyEventSubscriber.php
new file mode 100644
index 000000000..9ee0c4943
--- /dev/null
+++ b/src/Auth/Passkeys/PasskeyEventSubscriber.php
@@ -0,0 +1,17 @@
+listen(PasskeyUsedToAuthenticateEvent::class, function (PasskeyUsedToAuthenticateEvent $event) {
+ Cookie::queue('sharp_last_used_passkey', $event->passkey->id, 576000);
+ });
+ }
+}
diff --git a/src/Config/SharpConfigBuilder.php b/src/Config/SharpConfigBuilder.php
index eec017334..2a1141364 100644
--- a/src/Config/SharpConfigBuilder.php
+++ b/src/Config/SharpConfigBuilder.php
@@ -5,10 +5,17 @@
use Closure;
use Code16\Sharp\Auth\Impersonate\SharpDefaultEloquentImpersonationHandler;
use Code16\Sharp\Auth\Impersonate\SharpImpersonationHandler;
+use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity;
use Code16\Sharp\Auth\TwoFactor\Sharp2faHandler;
use Code16\Sharp\Exceptions\SharpInvalidConfigException;
use Code16\Sharp\Exceptions\SharpInvalidEntityKeyException;
use Code16\Sharp\Filters\GlobalRequiredFilter;
+use Code16\Sharp\Http\Middleware\AddLinkHeadersForPreloadedRequests;
+use Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors;
+use Code16\Sharp\Http\Middleware\HandleGlobalFilters;
+use Code16\Sharp\Http\Middleware\HandleInertiaRequests;
+use Code16\Sharp\Http\Middleware\HandleSharpErrors;
+use Code16\Sharp\Http\Middleware\InvalidateCache;
use Code16\Sharp\Search\SharpSearchEngine;
use Code16\Sharp\Utils\Entities\BaseSharpEntity;
use Code16\Sharp\Utils\Entities\SharpDashboardEntity;
@@ -18,8 +25,16 @@
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\PasswordBroker;
use Illuminate\Contracts\View\View;
+use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
+use Illuminate\Cookie\Middleware\EncryptCookies;
+use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Foundation\Vite;
+use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
+use Illuminate\Routing\Middleware\SubstituteBindings;
+use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Traits\Conditionable;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+use Intervention\Image\Drivers\Gd\Driver;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
@@ -46,23 +61,23 @@ class SharpConfigBuilder
'global_filters' => [],
'middleware' => [
'common' => [
- \Illuminate\Cookie\Middleware\EncryptCookies::class,
- \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
- \Illuminate\Session\Middleware\StartSession::class,
- \Illuminate\View\Middleware\ShareErrorsFromSession::class,
- \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
- \Code16\Sharp\Http\Middleware\HandleGlobalFilters::class,
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ ShareErrorsFromSession::class,
+ VerifyCsrfToken::class,
+ HandleGlobalFilters::class,
+ SubstituteBindings::class,
],
'web' => [
- \Code16\Sharp\Http\Middleware\InvalidateCache::class,
- \Code16\Sharp\Http\Middleware\HandleSharpErrors::class,
- \Code16\Sharp\Http\Middleware\HandleInertiaRequests::class,
- \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
- \Code16\Sharp\Http\Middleware\AddLinkHeadersForPreloadedRequests::class,
+ InvalidateCache::class,
+ HandleSharpErrors::class,
+ HandleInertiaRequests::class,
+ AddLinkHeadersForPreloadedAssets::class,
+ AddLinkHeadersForPreloadedRequests::class,
],
'api' => [
- \Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors::class,
+ HandleSharpApiErrors::class,
],
],
'auth' => [
@@ -97,7 +112,7 @@ class SharpConfigBuilder
'transform_keep_original_image' => true,
'max_file_size' => 5,
'model_class' => null,
- 'image_driver' => \Intervention\Image\Drivers\Gd\Driver::class,
+ 'image_driver' => Driver::class,
'file_handling_queue' => 'default',
'file_handling_queue_connection' => 'sync',
],
@@ -198,6 +213,13 @@ public function declareEntity(string $entityClass): self
->toString();
}
+ // prepend _internal to entities located in sharp's source (e.g. PasskeyEntity)
+ // to avoid conflicts with project entities
+ if (str($entityClass)->startsWith('Code16\Sharp')
+ && ! str($entityClass)->startsWith('Code16\Sharp\Tests')) {
+ $entityKey = "_internal_$entityKey";
+ }
+
$this->config['entities'][$entityKey] = $entityClass;
$this->config['entity_resolver'] = null;
@@ -336,7 +358,7 @@ public function configureUploadsThumbnailCreation(
string $thumbnailsDisk = 'public',
string $thumbnailsDir = 'thumbnails',
?string $uploadModelClass = null,
- string $imageDriverClass = \Intervention\Image\Drivers\Gd\Driver::class,
+ string $imageDriverClass = Driver::class,
): self {
$this->config['uploads']['thumbnails_disk'] = $thumbnailsDisk;
$this->config['uploads']['thumbnails_dir'] = $thumbnailsDir;
@@ -498,6 +520,18 @@ public function enable2faByTotp(): self
return $this;
}
+ public function enablePasskeys(bool $promptAfterLogin = true): self
+ {
+ $this->config['auth']['passkeys'] = [
+ 'enabled' => true,
+ 'prompt_after_login' => $promptAfterLogin,
+ ];
+
+ $this->declareEntity(PasskeyEntity::class);
+
+ return $this;
+ }
+
public function enable2faCustom(string|Sharp2faHandler $customHandler): self
{
$this->config['auth']['2fa'] = [
diff --git a/src/Exceptions/SharpException.php b/src/Exceptions/SharpException.php
index 829eb50f5..385607bc4 100644
--- a/src/Exceptions/SharpException.php
+++ b/src/Exceptions/SharpException.php
@@ -29,6 +29,10 @@ public function render(Request $request): Response|RedirectResponse|JsonResponse
return false;
}
+ if (! app()->isBooted()) {
+ throw $this;
+ }
+
return Inertia::render(
'Error',
[
diff --git a/src/Http/Controllers/Api/ApiEntityListController.php b/src/Http/Controllers/Api/ApiEntityListController.php
index 0311da9e7..5df67a5d6 100644
--- a/src/Http/Controllers/Api/ApiEntityListController.php
+++ b/src/Http/Controllers/Api/ApiEntityListController.php
@@ -32,19 +32,21 @@ public function delete(string $globalFilter, string $entityKey, string $instance
{
$this->authorizationManager->check('delete', $entityKey, $instanceId);
- $impl = $this->getListInstance($entityKey);
- if (! self::isDeleteMethodImplementedInConcreteClass($impl)) {
- // Try to delete from Show Page
+ $list = $this->getListInstance($entityKey);
+ $list->initQueryParams(request()->query());
+
+ if (self::isDeleteMethodImplementedInConcreteClass($list)) {
+ $list->delete($instanceId);
+ } else {
try {
- $impl = $this->getShowInstance($entityKey);
+ $show = $this->getShowInstance($entityKey);
+ $show->delete($instanceId);
} catch (SharpInvalidEntityKeyException $ex) {
// No Show Page implementation was defined for this entity
throw new SharpMethodNotImplementedException('The delete() method is not implemented, neither in the Entity List nor in the Show Page');
}
}
- $impl->delete($instanceId);
-
return response()->json([
'ok' => true,
]);
diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php
index 484f088b6..54c2cc883 100644
--- a/src/Http/Controllers/Auth/LoginController.php
+++ b/src/Http/Controllers/Auth/LoginController.php
@@ -9,6 +9,7 @@
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
@@ -27,6 +28,14 @@ public function create(): RedirectResponse|Response
return redirect()->to($loginPageUrl);
}
+ if (sharp()->config()->get('auth.passkeys.enabled')) {
+ session()->put('passkeys.redirect', route('code16.sharp.home'));
+
+ if (! Route::has('passkeys.login')) {
+ throw new \Exception('Passkeys routes are not defined. Add `Route::passkeys()` in your routes/web.php file.');
+ }
+ }
+
$message = sharp()->config()->get('auth.login_form_message');
return Inertia::render('Auth/Login', [
@@ -36,6 +45,7 @@ public function create(): RedirectResponse|Response
? $message->render()
: view('sharp::partials.login-form-message', ['message' => $message])->render()
: null,
+ 'passkeyError' => session('authenticatePasskey::message'),
]);
}
@@ -50,6 +60,16 @@ public function store(LoginRequest $request): RedirectResponse
$request->session()->regenerate();
+ if (sharp()->config()->get('auth.passkeys.enabled')
+ && sharp()->config()->get('auth.passkeys.prompt_after_login')
+ && ! $request->cookie('sharp_skip_passkey_prompt')
+ && $request->boolean('supports_passkeys')
+ && method_exists($request->user(), 'passkeys')
+ && $request->user()->passkeys()->count() === 0
+ ) {
+ return redirect()->route('code16.sharp.passkeys.create', ['prompt' => true]);
+ }
+
return redirect()->intended(route('code16.sharp.home'));
}
diff --git a/src/Http/Controllers/Auth/Passkeys/PasskeyController.php b/src/Http/Controllers/Auth/Passkeys/PasskeyController.php
new file mode 100644
index 000000000..3a633eea0
--- /dev/null
+++ b/src/Http/Controllers/Auth/Passkeys/PasskeyController.php
@@ -0,0 +1,83 @@
+ $request->boolean('prompt'),
+ 'cancelUrl' => redirect()->getIntendedUrl(),
+ ]);
+ }
+
+ public function validate(): JsonResponse
+ {
+ request()->validate([
+ 'name' => 'required|string|max:255',
+ ]);
+
+ return response()->json([
+ 'passkeyOptions' => json_decode($this->generatePasskeyOptions()),
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $passkey = request()->input('passkey');
+ $storePasskeyAction = Config::getAction('store_passkey', StorePasskeyAction::class);
+
+ try {
+ $storePasskeyAction->execute(
+ $this->currentUser(),
+ $passkey, $this->previouslyGeneratedPasskeyOptions(),
+ request()->getHost(),
+ ['name' => request()->input('name')]
+ );
+ } catch (Throwable $e) {
+ throw ValidationException::withMessages([
+ 'name' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'),
+ ])->errorBag('passkeyForm');
+ }
+
+ return redirect()->intended(route('code16.sharp.home'));
+ }
+
+ protected function currentUser(): Authenticatable&HasPasskeys
+ {
+ /** @var Authenticatable&HasPasskeys $user */
+ $user = auth()->user();
+
+ return $user;
+ }
+
+ protected function generatePasskeyOptions(): string
+ {
+ $generatePassKeyOptionsAction = Config::getAction('generate_passkey_register_options', GeneratePasskeyRegisterOptionsAction::class);
+
+ $options = $generatePassKeyOptionsAction->execute($this->currentUser());
+
+ session()->put('passkey-registration-options', $options);
+
+ return $options;
+ }
+
+ protected function previouslyGeneratedPasskeyOptions(): ?string
+ {
+ return session()->pull('passkey-registration-options');
+ }
+}
diff --git a/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php b/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php
new file mode 100644
index 000000000..3a96974eb
--- /dev/null
+++ b/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php
@@ -0,0 +1,16 @@
+to(route('code16.sharp.home'))
+ ->withCookie(cookie()->forever('sharp_skip_passkey_prompt', true));
+ }
+}
diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php
index 62067be65..5f84b21ec 100644
--- a/src/Http/Middleware/HandleInertiaRequests.php
+++ b/src/Http/Middleware/HandleInertiaRequests.php
@@ -76,10 +76,12 @@ public function share(Request $request)
'sharp::pages/auth/login',
'sharp::pages/auth/impersonate',
'sharp::pages/auth/reset-password',
+ 'sharp::pages/auth/passkeys',
'sharp::show',
])
->map(fn ($group) => collect(__($group, [], app()->getFallbackLocale()))
->mapWithKeys(fn ($value, $key) => ["$group.$key" => __("$group.$key")])
+ ->dot()
)
->collapse()
->toArray();
@@ -89,6 +91,7 @@ public function share(Request $request)
'sharp.auth.forgotten_password.enabled' => sharp()->config()->get('auth.forgotten_password.enabled'),
'sharp.auth.forgotten_password.show_reset_link_in_login_form' => sharp()->config()->get('auth.forgotten_password.show_reset_link_in_login_form'),
'sharp.auth.suggest_remember_me' => sharp()->config()->get('auth.suggest_remember_me'),
+ 'sharp.auth.passkeys.enabled' => sharp()->config()->get('auth.passkeys.enabled'),
'sharp.custom_url_segment' => sharp()->config()->get('custom_url_segment'),
'sharp.display_sharp_version_in_title' => sharp()->config()->get('display_sharp_version_in_title'),
'sharp.breadcrumb.display' => sharp()->config()->get('breadcrumb.display'),
diff --git a/src/SharpInternalServiceProvider.php b/src/SharpInternalServiceProvider.php
index 3ce27408c..adba0b1ad 100644
--- a/src/SharpInternalServiceProvider.php
+++ b/src/SharpInternalServiceProvider.php
@@ -3,6 +3,7 @@
namespace Code16\Sharp;
use Code16\Sharp\Auth\Impersonate\SharpImpersonationHandler;
+use Code16\Sharp\Auth\Passkeys\PasskeyEventSubscriber;
use Code16\Sharp\Auth\SharpAuthorizationManager;
use Code16\Sharp\Auth\TwoFactor\Engines\GoogleTotpEngine;
use Code16\Sharp\Auth\TwoFactor\Engines\Sharp2faTotpEngine;
@@ -96,6 +97,8 @@ public function boot()
}
$this->configureOctane();
+
+ Event::subscribe(PasskeyEventSubscriber::class);
}
public function register()
@@ -239,6 +242,10 @@ public function loadRoutes(): void
if (sharp()->config()->get('auth.impersonate.enabled')) {
$this->loadRoutesFrom(__DIR__.'/routes/auth/impersonate.php');
}
+
+ if (sharp()->config()->get('auth.passkeys.enabled')) {
+ $this->loadRoutesFrom(__DIR__.'/routes/auth/passkeys.php');
+ }
}
private function configureOctane(): void
diff --git a/src/routes/auth/passkeys.php b/src/routes/auth/passkeys.php
new file mode 100644
index 000000000..ab0985144
--- /dev/null
+++ b/src/routes/auth/passkeys.php
@@ -0,0 +1,22 @@
+ '/'.sharp()->config()->get('custom_url_segment'),
+ 'middleware' => ['sharp_common', 'sharp_web', 'sharp_auth'],
+], function () {
+ Route::get('/passkeys/create', [PasskeyController::class, 'create'])
+ ->name('code16.sharp.passkeys.create');
+
+ Route::post('/passkeys/validate', [PasskeyController::class, 'validate'])
+ ->name('code16.sharp.passkeys.validate');
+
+ Route::post('/passkeys', [PasskeyController::class, 'store'])
+ ->name('code16.sharp.passkeys.store');
+
+ Route::post('/passkeys/skip-prompt', PasskeySkipPromptController::class)
+ ->name('code16.sharp.passkeys.skip-prompt');
+});
diff --git a/tests/Http/Auth/PasskeysTest.php b/tests/Http/Auth/PasskeysTest.php
new file mode 100644
index 000000000..b7151733d
--- /dev/null
+++ b/tests/Http/Auth/PasskeysTest.php
@@ -0,0 +1,399 @@
+use(LazilyRefreshDatabase::class);
+
+defineEnvironment(function () {
+ sharp()->config()->enablePasskeys();
+});
+
+beforeEach(function () {
+ Schema::create('users', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('name')->nullable();
+ $table->string('email');
+ $table->string('password')->nullable();
+ $table->string('remember_token')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('passkeys', function (Blueprint $table) {
+ $table->id();
+ $table->unsignedInteger('authenticatable_id');
+ $table->text('name');
+ $table->text('credential_id');
+ $table->json('data');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+
+ config()->set('auth.providers.users.model', PasskeyTestUser::class);
+ config()->set('passkeys.models.authenticatable', PasskeyTestUser::class);
+ config()->set('passkeys.models.passkey', TestPasskey::class);
+
+ sharp()->config()
+ ->declareEntity(PersonEntity::class);
+});
+
+function createPasskeyTestUser(array $attributes = []): PasskeyTestUser
+{
+ return PasskeyTestUser::create(array_merge([
+ 'email' => 'test@example.org',
+ 'name' => 'Test',
+ ], $attributes));
+}
+
+function createPasskey(PasskeyTestUser $user, array $attributes = []): TestPasskey
+{
+ $attrs = array_merge([
+ 'name' => 'My Passkey',
+ 'credential_id' => 'test-credential-'.uniqid(),
+ 'last_used_at' => null,
+ ], $attributes);
+
+ $id = DB::table('passkeys')->insertGetId([
+ 'authenticatable_id' => $user->id,
+ 'name' => $attrs['name'],
+ 'credential_id' => $attrs['credential_id'],
+ 'data' => json_encode(['test' => true]),
+ 'last_used_at' => $attrs['last_used_at'],
+ 'created_at' => $attrs['created_at'] ?? now(),
+ 'updated_at' => now(),
+ ]);
+
+ return TestPasskey::find($id);
+}
+
+function loginPasskeyUser(?PasskeyTestUser $user = null)
+{
+ test()->actingAs(
+ $user ?: createPasskeyTestUser(),
+ sharp()->config()->get('auth.guard') ?: 'web'
+ );
+}
+
+// --- PasskeyEntity tests ---
+
+it('has correct prohibited actions', function () {
+ $entity = new PasskeyEntity();
+ $reflection = new \ReflectionProperty($entity, 'prohibitedActions');
+
+ expect($reflection->getValue($entity))->toContain('create', 'update');
+});
+
+// --- PasskeyList data ---
+
+it('returns list data for authenticated user', function () {
+ $user = createPasskeyTestUser();
+ createPasskey($user, ['name' => 'My Passkey', 'last_used_at' => now()->subDay()]);
+ loginPasskeyUser($user);
+
+ $list = app(PasskeyList::class);
+ $data = $list->getListData();
+
+ expect($data)->toHaveCount(1);
+ expect($data[0]['name'])->toBe('My Passkey');
+});
+
+it('does not return passkeys of other users', function () {
+ $user = createPasskeyTestUser();
+ $otherUser = createPasskeyTestUser(['email' => 'other@example.org']);
+ createPasskey($otherUser, ['name' => 'Other Passkey']);
+ loginPasskeyUser($user);
+
+ $list = app(PasskeyList::class);
+ $data = $list->getListData();
+
+ expect($data)->toHaveCount(0);
+});
+
+it('transforms last_used_at to human readable format', function () {
+ $user = createPasskeyTestUser();
+ createPasskey($user, ['last_used_at' => now()->subHour()]);
+ loginPasskeyUser($user);
+
+ $list = app(PasskeyList::class);
+ $data = $list->getListData();
+
+ expect($data[0]['last_used_at'])->toBeString()->not->toBeEmpty();
+});
+
+// --- PasskeyList delete ---
+
+it('deletes a passkey for the authenticated user', function () {
+ $user = createPasskeyTestUser();
+ $passkey = createPasskey($user);
+ loginPasskeyUser($user);
+
+ $list = app(PasskeyList::class);
+ $list->delete($passkey->id);
+
+ expect($user->passkeys()->count())->toBe(0);
+});
+
+it('cannot delete another user passkey', function () {
+ $user = createPasskeyTestUser();
+ $otherUser = createPasskeyTestUser(['email' => 'other@example.org']);
+ $passkey = createPasskey($otherUser);
+ loginPasskeyUser($user);
+
+ $list = app(PasskeyList::class);
+
+ expect(fn () => $list->delete($passkey->id))
+ ->toThrow(ModelNotFoundException::class);
+});
+
+// --- UpdatePasskeyNameCommand ---
+
+it('renames a passkey', function () {
+ $user = createPasskeyTestUser();
+ $passkey = createPasskey($user);
+ loginPasskeyUser($user);
+
+ $command = app(UpdatePasskeyNameCommand::class);
+ $command->execute($passkey->id, ['name' => 'Renamed Passkey']);
+
+ expect($passkey->fresh()->name)->toBe('Renamed Passkey');
+});
+
+it('validates name is required when renaming', function () {
+ $user = createPasskeyTestUser();
+ $passkey = createPasskey($user);
+ loginPasskeyUser($user);
+
+ $command = app(UpdatePasskeyNameCommand::class);
+
+ expect(fn () => $command->execute($passkey->id, ['name' => '']))
+ ->toThrow(ValidationException::class);
+});
+
+// --- PasskeyEventSubscriber ---
+
+it('queues a cookie when passkey is used to authenticate', function () {
+ // here we expects the SharpInternalServiceProvider is booted
+ $user = createPasskeyTestUser();
+ $passkey = createPasskey($user);
+
+ $request = \Mockery::mock(AuthenticateUsingPasskeysRequest::class);
+ $event = new PasskeyUsedToAuthenticateEvent($passkey, $request);
+ app(Dispatcher::class)->dispatch($event);
+
+ $queued = Cookie::getQueuedCookies();
+ $names = array_map(fn ($c) => $c->getName(), $queued);
+ expect($names)->toContain('sharp_last_used_passkey');
+});
+
+// --- Usage badge ---
+
+it('shows usage badge when passkey matches cookie', function () {
+ $user = createPasskeyTestUser();
+ $passkey = createPasskey($user);
+ loginPasskeyUser($user);
+
+ request()->cookies->set('sharp_last_used_passkey', (string) $passkey->id);
+
+ $list = app(PasskeyList::class);
+ $data = $list->getListData();
+
+ expect($data[0]['usage'])->not->toBeNull();
+});
+
+it('does not show usage badge when passkey does not match cookie', function () {
+ $user = createPasskeyTestUser();
+ createPasskey($user);
+ loginPasskeyUser($user);
+
+ request()->cookies->set('sharp_last_used_passkey', '99999');
+
+ $list = app(PasskeyList::class);
+ $data = $list->getListData();
+
+ expect($data[0]['usage'])->toBeNull();
+});
+
+// --- PasskeyController tests ---
+
+it('renders the passkey create page', function () {
+ loginPasskeyUser();
+
+ $this->get(route('code16.sharp.passkeys.create'))
+ ->assertOk();
+});
+
+it('renders the passkey create page with prompt parameter', function () {
+ loginPasskeyUser();
+
+ $this->get(route('code16.sharp.passkeys.create', ['prompt' => 1]))
+ ->assertOk();
+});
+
+it('requires authentication to access passkey create', function () {
+ $this->get(route('code16.sharp.passkeys.create'))
+ ->assertRedirect();
+});
+
+it('validates name on passkey validate endpoint', function () {
+ loginPasskeyUser();
+
+ $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => ''])
+ ->assertJsonValidationErrors('name');
+
+ $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => str_repeat('a', 256)])
+ ->assertJsonValidationErrors('name');
+});
+
+it('returns passkey options on successful validate', function () {
+ $user = createPasskeyTestUser();
+ loginPasskeyUser($user);
+
+ // Configure a fake action that returns a JSON string
+ config()->set('passkeys.actions.generate_passkey_register_options', FakeGeneratePasskeyRegisterOptionsAction::class);
+
+ $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => 'My Key'])
+ ->assertOk()
+ ->assertJsonStructure(['passkeyOptions']);
+});
+
+it('store endpoint requires authentication', function () {
+ $this->post(route('code16.sharp.passkeys.store'))
+ ->assertRedirect();
+});
+
+it('store endpoint catches action errors and throws validation exception', function () {
+ $user = createPasskeyTestUser();
+ loginPasskeyUser($user);
+
+ // Put fake options in session as the controller expects them
+ session()->put('passkey-registration-options', '{"fake":"options"}');
+
+ $this->postJson(route('code16.sharp.passkeys.store'), [
+ 'passkey' => 'invalid-passkey-data',
+ 'name' => 'My Key',
+ ])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors('name');
+});
+
+class FakeStorePasskeyAction extends StorePasskeyAction
+{
+ public static $mock;
+ public function execute($authenticatable, $passkeyJson, $passkeyOptionsJson, $hostName, $additionalProperties = []): Passkey
+ {
+ return static::$mock->execute($authenticatable, $passkeyJson, $passkeyOptionsJson, $hostName, $additionalProperties);
+ }
+}
+
+it('store endpoint calls StorePasskeyAction with appropriate arguments', function () {
+ $user = createPasskeyTestUser();
+ loginPasskeyUser($user);
+
+ $passkeyData = '{"id":"some-id","rawId":"some-raw-id","type":"public-key","response":{"attestationObject":"some-attestation","clientDataJSON":"some-client-data"}}';
+ $passkeyOptions = '{"challenge":"some-challenge"}';
+ $passkeyName = 'My New Passkey';
+
+ session()->put('passkey-registration-options', $passkeyOptions);
+
+ $mockAction = \Mockery::mock(Spatie\LaravelPasskeys\Actions\StorePasskeyAction::class);
+ $mockAction->shouldReceive('execute')
+ ->once()
+ ->withArgs(function ($authenticatable, $passkeyJson, $optionsJson, $host, $additionalProperties) use ($user, $passkeyData, $passkeyOptions, $passkeyName) {
+ return $authenticatable->is($user)
+ && $passkeyJson === $passkeyData
+ && $optionsJson === $passkeyOptions
+ && $host === request()->getHost()
+ && $additionalProperties === ['name' => $passkeyName];
+ })
+ ->andReturn(new TestPasskey());
+
+ FakeStorePasskeyAction::$mock = $mockAction;
+ config()->set('passkeys.actions.store_passkey', FakeStorePasskeyAction::class);
+
+ $this->postJson(route('code16.sharp.passkeys.store'), [
+ 'passkey' => $passkeyData,
+ 'name' => $passkeyName,
+ ])
+ ->assertRedirect(route('code16.sharp.home'));
+
+ $mockAction->shouldHaveReceived('execute');
+});
+
+// --- PasskeySkipPromptController tests ---
+
+it('skip prompt redirects to home with cookie', function () {
+ loginPasskeyUser();
+
+ $this->post(route('code16.sharp.passkeys.skip-prompt'))
+ ->assertRedirect(route('code16.sharp.home'))
+ ->assertCookie('sharp_skip_passkey_prompt');
+});
+
+// --- Fixtures ---
+
+class TestPasskey extends Passkey
+{
+ protected $table = 'passkeys';
+
+ public function data(): Attribute
+ {
+ return new Attribute(
+ get: fn ($value) => $value,
+ set: fn ($value) => ['data' => is_string($value) ? $value : json_encode($value)],
+ );
+ }
+}
+
+class FakeGeneratePasskeyRegisterOptionsAction extends GeneratePasskeyRegisterOptionsAction
+{
+ public function execute(HasPasskeys $authenticatable, bool $asJson = true): string|PublicKeyCredentialCreationOptions
+ {
+ return '{"challenge":"fake-challenge","rp":{"name":"test"}}';
+ }
+}
+
+class PasskeyTestUser extends User implements HasPasskeys
+{
+ use InteractsWithPasskeys;
+
+ protected $table = 'users';
+
+ public function getPasskeyName(): string
+ {
+ return $this->email;
+ }
+
+ public function getPasskeyId(): string
+ {
+ return (string) $this->id;
+ }
+
+ public function getPasskeyDisplayName(): string
+ {
+ return $this->name ?? $this->email;
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index bafa29a4f..bcddd549b 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -3,14 +3,18 @@
namespace Code16\Sharp\Tests;
use BladeUI\Icons\BladeIconsServiceProvider;
+use BladeUI\Icons\Factory;
use Code16\ContentRenderer\ContentRendererServiceProvider;
use Code16\Sharp\SharpInternalServiceProvider;
use Illuminate\Testing\Fluent\AssertableJson;
+use Orchestra\Testbench\Pest\WithPest;
use Orchestra\Testbench\TestCase as Orchestra;
use PHPUnit\Framework\Assert as PHPUnit;
class TestCase extends Orchestra
{
+ use WithPest;
+
protected function setUp(): void
{
parent::setUp();
@@ -52,7 +56,7 @@ public function getEnvironmentSetUp($app)
{
config()->set('database.default', 'testing');
- $app->make(\BladeUI\Icons\Factory::class)->add('testicon', [
+ $app->make(Factory::class)->add('testicon', [
'path' => __DIR__.'/Fixtures/resources/svg',
'prefix' => 'testicon',
]);