diff --git a/composer.json b/composer.json index 46f9defa8..881c9b4a0 100644 --- a/composer.json +++ b/composer.json @@ -38,14 +38,24 @@ "laravel/pint": "^1.27", "mockery/mockery": "^1.5.0", "nunomaduro/collision": "^8.0", + "orchestra/pest-plugin-testbench": "^4.1", "orchestra/testbench": "^9.0|^10.0|^11.0", "pestphp/pest": "^3.0|^4.0", "pestphp/pest-plugin-laravel": "^3.0|^4.0", "phpunit/phpunit": "^11.0|^12.0", + "spatie/laravel-passkeys": "^1.0", "spatie/laravel-ray": "^1.26", "spatie/laravel-typescript-transformer": "^2.3", "spatie/typescript-transformer": "^2.2" }, + "suggest": { + "spatie/laravel-passkeys": "Allows you to use passkey authentication (^1.0 is required).", + "pragmarx/google2fa-laravel": "Allows you to use 2FA default TOTP command", + "bacon/bacon-qr-code": "Allows you to use QR codes in default TOTP command" + }, + "conflict": { + "spatie/laravel-passkeys": "<1.0 || >=2.0" + }, "autoload": { "files": [ "src/sharp_helper.php" diff --git a/demo/app/Models/User.php b/demo/app/Models/User.php index 3cead3d10..dd1c20d8d 100644 --- a/demo/app/Models/User.php +++ b/demo/app/Models/User.php @@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys; +use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys; -class User extends Authenticatable +class User extends Authenticatable implements HasPasskeys { use HasFactory; + use InteractsWithPasskeys; use Notifiable; protected $guarded = []; diff --git a/demo/app/Providers/DemoSharpServiceProvider.php b/demo/app/Providers/DemoSharpServiceProvider.php index 0ae63a48d..6d5c06af1 100644 --- a/demo/app/Providers/DemoSharpServiceProvider.php +++ b/demo/app/Providers/DemoSharpServiceProvider.php @@ -23,14 +23,15 @@ protected function configureSharp(SharpConfigBuilder $config): void ->setSharpMenu(SharpMenu::class) ->setThemeColor('#004c9b') ->setThemeLogo(logoUrl: '/img/sharp/logo.svg', logoHeight: '1rem', faviconUrl: '/img/sharp/favicon-32x32.png') - ->enableImpersonation() + // ->enableImpersonation() ->enableForgottenPassword() ->setAuthCustomGuard('web') + ->enable2faCustom(Demo2faNotificationHandler::class) + ->enableLoginRateLimiting(maxAttempts: 3) + ->when(config('demo.enable_passkeys'))->enablePasskeys() ->setLoginAttributes('email', 'password') ->setUserDisplayAttribute('name') ->setUserAvatarAttribute(fn () => auth()->user()->avatar?->thumbnail(200)) - ->enable2faCustom(Demo2faNotificationHandler::class) - ->enableLoginRateLimiting(maxAttempts: 3) ->suggestRememberMeOnLoginForm() ->appendMessageOnLoginForm(view('sharp._login-page-message')) ->enableGlobalSearch(AppSearchEngine::class, 'Search for posts or authors...') diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index 07f51acd6..e4b7fc697 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -5,6 +5,8 @@ use App\Sharp\Profile\Commands\Activate2faCommand; use App\Sharp\Profile\Commands\ChangePasswordCommand; use App\Sharp\Profile\Commands\Deactivate2faCommand; +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity; +use Code16\Sharp\Show\Fields\SharpShowEntityListField; use Code16\Sharp\Show\Fields\SharpShowPictureField; use Code16\Sharp\Show\Fields\SharpShowTextField; use Code16\Sharp\Show\Layout\ShowLayout; @@ -25,7 +27,11 @@ protected function buildShowFields(FieldsContainer $showFields): void ) ->addField( SharpShowPictureField::make('avatar'), - ); + ) + ->when(config('demo.enable_passkeys'), fn () => $showFields->addField( + SharpShowEntityListField::make(PasskeyEntity::class) + ->setLabel('Passkeys') + )); } protected function buildShowLayout(ShowLayout $showLayout): void @@ -35,7 +41,8 @@ protected function buildShowLayout(ShowLayout $showLayout): void $section ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('email')) ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('avatar')); - }); + }) + ->when(config('demo.enable_passkeys'))->addEntityListSection(PasskeyEntity::class); } public function buildShowConfig(): void diff --git a/demo/composer.json b/demo/composer.json index a708fa804..f60888a46 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -16,6 +16,7 @@ "masterminds/html5": "^2.9", "pragmarx/google2fa": "^8.0", "spatie/image-optimizer": "^1.7", + "spatie/laravel-passkeys": "^1.6", "spatie/laravel-translatable": "^6.13", "symfony/html-sanitizer": "^7.3", "technikermathe/blade-lucide-icons": "dev-l13-compatibility", diff --git a/demo/composer.lock b/demo/composer.lock index 92f7a268f..5994d0950 100644 --- a/demo/composer.lock +++ b/demo/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": "8a3a7304ef7bba40660d6e98d1a0bef0", + "content-hash": "2221840f079c1be1388507f2013756dc", "packages": [ { "name": "bacon/bacon-qr-code", @@ -459,6 +459,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -3264,6 +3312,181 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.7", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + }, + "time": "2026-03-18T20:47:46+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3339,6 +3562,53 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "pragmarx/google2fa", "version": "v8.0.3", @@ -4197,16 +4467,94 @@ "time": "2026-02-21T12:49:54+00:00" }, { - "name": "spatie/laravel-translatable", - "version": "6.13.0", + "name": "spatie/laravel-passkeys", + "version": "1.6.5", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-translatable.git", - "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb" + "url": "https://github.com/spatie/laravel-passkeys.git", + "reference": "108bc6b163034b2a19e22fdcf7a0a4bac5090585" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", + "url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/108bc6b163034b2a19e22fdcf7a0a4bac5090585", + "reference": "108bc6b163034b2a19e22fdcf7a0a4bac5090585", + "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.6.5" + }, + "funding": [ + { + "url": "https://github.com/Spatie", + "type": "github" + } + ], + "time": "2026-03-27T10:29:37+00:00" + }, + { + "name": "spatie/laravel-translatable", + "version": "6.13.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-translatable.git", + "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", "shasum": "" }, @@ -4279,6 +4627,187 @@ ], "time": "2026-02-21T14:20:19+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "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.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-11-13T13:00:34+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": "symfony/clock", "version": "v8.0.0", @@ -6131,35 +6660,31 @@ "time": "2026-01-26T15:08:38+00:00" }, { - "name": "symfony/routing", - "version": "v8.0.6", + "name": "symfony/property-access", + "version": "v8.0.4", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" + "url": "https://github.com/symfony/property-access.git", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "url": "https://api.github.com/repos/symfony/property-access/zipball/a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/property-info": "^7.4.4|^8.0.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6179,16 +6704,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.6" + "source": "https://github.com/symfony/property-access/tree/v8.0.4" }, "funding": [ { @@ -6208,46 +6738,45 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-01-05T09:27:50+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.1", + "name": "symfony/property-info", + "version": "v8.0.7", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "url": "https://github.com/symfony/property-info.git", + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/property-info/zipball/e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, "conflict": { - "ext-psr": "<1.1|>=2" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6256,26 +6785,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/property-info/tree/v8.0.7" }, "funding": [ { @@ -6295,46 +6824,38 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-04T15:54:04+00:00" }, { - "name": "symfony/string", + "name": "symfony/routing", "version": "v8.0.6", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "url": "https://github.com/symfony/routing.git", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\Routing\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6346,8 +6867,280 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/18bbaf7317e33e7e4bcd7ef281357ec4335fc900", + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:40+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", @@ -6562,6 +7355,88 @@ ], "time": "2025-07-15T13:41:35+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-04T13:55:34+00:00" + }, { "name": "symfony/uid", "version": "v8.0.4", @@ -7002,35 +7877,202 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "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.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-01-03T14:43:18+00:00" }, { - "name": "voku/portable-ascii", - "version": "2.0.3", + "name": "web-auth/webauthn-lib", + "version": "5.3.x-dev", "source": { "type": "git", - "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950", + "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950", "shasum": "" }, "require": { - "php": ">=7.0.0" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "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": { - "ext-intl": "Use Intl for transliterator_transliterate() support" + "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": { - "voku\\": "src/voku/" + "Webauthn\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7039,95 +8081,100 @@ ], "authors": [ { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" } ], - "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", - "homepage": "https://github.com/voku/portable-ascii", + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", "keywords": [ - "ascii", - "clean", - "php" + "FIDO2", + "fido", + "webauthn" ], "support": { - "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", + "url": "https://github.com/Spomky", "type": "github" }, { - "url": "https://opencollective.com/portable-ascii", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", + "url": "https://www.patreon.com/FlorentMorselli", "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", - "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" - } - ], - "packages-dev": [ + "time": "2026-03-22T17:54:03+00:00" + }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "webmozart/assert", + "version": "2.1.6", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/webmozarts/assert.git", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Webmozart\\Assert\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-02-07T07:09:04+00:00" - }, + "time": "2026-02-27T10:28:38+00:00" + } + ], + "packages-dev": [ { "name": "fakerphp/faker", "version": "v1.24.1", @@ -8137,164 +9184,6 @@ ], "time": "2025-08-16T11:10:48+00:00" }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/type-resolver", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" - }, - "time": "2025-11-21T15:09:14+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" - }, - "time": "2026-01-25T14:56:51+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "12.5.3", diff --git a/demo/config/demo.php b/demo/config/demo.php new file mode 100644 index 000000000..96d1bc084 --- /dev/null +++ b/demo/config/demo.php @@ -0,0 +1,5 @@ + env('DEMO_ENABLE_PASSKEYS', false), +]; diff --git a/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php b/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php new file mode 100644 index 000000000..665163935 --- /dev/null +++ b/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php @@ -0,0 +1,32 @@ +getTable(); + + Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName, $authenticatableClass) { + $table->id(); + + $table + ->foreignIdFor($authenticatableClass, 'authenticatable_id') + ->constrained(table: $authenticatableTableName, indexName: 'passkeys_authenticatable_fk') + ->cascadeOnDelete(); + + $table->text('name'); + $table->text('credential_id'); + $table->json('data'); + + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/demo/routes/web.php b/demo/routes/web.php index fe71fc62e..a8a018281 100644 --- a/demo/routes/web.php +++ b/demo/routes/web.php @@ -1,15 +1,18 @@ $post]); }); diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts index e3175e4f6..049ffa3bb 100644 --- a/docs/.vitepress/sidebar.ts +++ b/docs/.vitepress/sidebar.ts @@ -86,6 +86,8 @@ export function sidebar(): DefaultTheme.SidebarItem[] { collapsed: true, items: [ { text: 'Authentication', link: '/guide/authentication.md' }, + { text: 'Two-factor authentication', link: '/guide/authentication-2fa.md' }, + { text: 'Passkeys authentication', link: '/guide/authentication-passkeys.md' }, { text: 'Entity authorizations', link: '/guide/entity-authorizations.md' } ] }, diff --git a/docs/guide/authentication-2fa.md b/docs/guide/authentication-2fa.md new file mode 100644 index 000000000..14095ca59 --- /dev/null +++ b/docs/guide/authentication-2fa.md @@ -0,0 +1,158 @@ +# Two-factor authentication (2fa) + +Sharp provides a two-factor authentication (2fa) system, out of the box. You can configure it like this: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->enable2faByNotification() + // or ->enable2faByTotp() + // or ->enable2faCustom() + // [...] + } +} +``` + +With this configuration, Sharp will display a second screen to the user, after a successful password based login, asking for a 6-digit code. This code will be provided to the user depending on the configuration: +- `enable2faByNotification()`: a notification is sent to the user (email by default, but you can tweak this, see below) +- `enable2faByTotp()`: the user must use a 2fa authenticator app (like Google or Microsoft Authenticator) to generate a code +- `enable2faCustom()`: in this case you must provide your own 2fa handler, see below. + +### Handling the 2fa code via a notification + +::: warning +To be able to receive notifications, your User model must use the `Illuminate\Notifications\Notifiable` trait. +::: + +With this option, Sharp will send a notification to the user, containing the 6-digit code. By default, this notification is sent by email. You can override this behavior by providing your own handler class which must extend `Code16\Sharp\Auth\TwoFactor\Sharp2faNotificationHandler`: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->enable2faCustom(\App\Sharp\My2faNotificationHandler::class) + // [...] + } +} +``` + +```php +class My2faNotificationHandler extends Sharp2faNotificationHandler +{ + protected function getNotification(int $code): Notification + { + return new My2faDefaultNotification($code); + } +} +``` + +### Handling the 2fa code via a TOTP authenticator app + +::: warning +This implies a bit more work to implement, but this method is more secure than the notification handler. The out-of-the-box implementation implies that you leverage Eloquent. +::: + +With this option, Sharp will ask the user to register the app in a 2fa authenticator (like Google or Microsoft Authenticator). The user will have to provide a 6-digit code generated by the app to Sharp, in order to be authenticated. + +First, require two packages needed for this feature: + +```bash +composer require pragmarx/google2fa-laravel +composer require bacon/bacon-qr-code +``` + +Then, you'll need to configure the totp handler: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->enable2faByTotp() + // [...] + } +} +``` + +Add three columns in the users table to store the 2fa secret, 2fa recovery codes and 2fa confirmation timestamp. Here’s a migration example: + +```php +return new class extends Migration +{ + public function up(): void + { + Schema::table('users', function (Blueprint $table) { + $table->text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } +}; +``` + +After that, you must provide a way for your users to register the app in their 2fa authenticator. Sharp can help a lot with that, by extending two built-in Commands; one for activating and one for deactivating 2fa. The idea is to add these commands in a "profile" SingleShow, or in some related Entity List. + +```php +class Activate2faCommand extends SingleInstanceWizardCommand +{ + use Code16\Sharp\Auth\TwoFactor\Commands\Activate2faViaTotpWizardCommandTrait; +} +``` + +```php +class Deactivate2faCommand extends SingleInstanceCommand +{ + use Code16\Sharp\Auth\TwoFactor\Commands\Deactivate2FaViaTotpSingleInstanceCommandTrait; + // or Code16\Sharp\Auth\TwoFactor\Commands\Deactivate2FaViaTotpEntityCommandTrait +} +``` + +The first command is a wizard which will guide the user through the registration process; the second one is to deactivate the 2fa. Both require to enter a password. +You can tweak these commands and provide your own implementation if needed. + +Finally, if you need more control, you can provide your own handler class via `->enable2faCustom()`, which must extend `Code16\Sharp\Auth\TwoFactor\Sharp2faTotpHandler`. + +### Enabling 2fa for some users only + +Providing your own handler implementation, you can override the `isEnabledFor` method to enable 2fa for some users only: + +```php +class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2faTotpHandler +{ + public function isEnabledFor($user): bool + { + return $user->hasGroup('sharp'); + } +} +``` + +### Customize the 2fa form + +You can also change the default help text display above the 2fa form in the handler: + +```php +class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2faTotpHandler +{ + public function formHelpText(): string + { + return sprintf( + 'You code was sent via SMS to your phone number ending in %s', + substr(User::find($this->userId())->phone, -4) + ); + } +} +``` diff --git a/docs/guide/authentication-passkeys.md b/docs/guide/authentication-passkeys.md new file mode 100644 index 000000000..45558c93a --- /dev/null +++ b/docs/guide/authentication-passkeys.md @@ -0,0 +1,66 @@ +# Passkeys + +Sharp provides a built-in solution to manage and authenticate with passkeys. Passkeys are a replacement for passwords that provide faster, easier, and more secure sign-ins to websites and apps across a user’s devices. + +## Installation + +Passkeys in Sharp requires the `spatie/laravel-passkeys` package. +Follow the [installation instructions](https://spatie.be/docs/laravel-passkeys/installation-setup) of the package **(the JavaScript installation part is not needed for Sharp)**. + +## Configuration + +To enable passkeys in Sharp, use the `enablePasskeys()` method in your `SharpServiceProvider`: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->enablePasskeys() + // [...] + } +} +``` + +By default, Sharp will prompt the user to create a passkey after a successful password-based login if they don't have one yet. You can disable this feature like this : + +```php +$config->enablePasskeys(promptAfterLogin: false); +``` + +## Management in the User Profile + +Once enabled, Sharp automatically registers a `PasskeyEntity` that you can use in your application. A common use case is to allow users to manage their passkeys from their profile page using a `SharpShowEntityListField`. + +Here is an example of how to add the passkey management list to a `ProfileSingleShow`: + +```php +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity; +use Code16\Sharp\Show\Fields\SharpShowEntityListField; +// ... + +class ProfileSingleShow extends SharpSingleShow +{ + protected function buildShowFields(FieldsContainer $showFields): void + { + $showFields + // [...] + ->addField( + SharpShowEntityListField::make(PasskeyEntity::class) + ->setLabel('Passkeys') + ); + } + + protected function buildShowLayout(ShowLayout $showLayout): void + { + $showLayout + ->addSection('', function (ShowLayoutSection $section) { + // [...] + }) + ->addEntityListSection(PasskeyEntity::class); + } + + // ... +} +``` diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 4b253298a..857564269 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -82,165 +82,6 @@ class SharpServiceProvider extends SharpAppServiceProvider This implies that you defined a “sharp” guard in `config/auth.php`, as detailed [in the Laravel documentation](https://laravel.com/docs/authentication#adding-custom-guards). -## Two-factor authentication - -Sharp provides a two-factor authentication (2fa) system, out of the box. You can configure it like this: - -```php -class SharpServiceProvider extends SharpAppServiceProvider -{ - protected function configureSharp(SharpConfigBuilder $config): void - { - $config - ->enable2faByNotification() - // or ->enable2faByTotp() - // or ->enable2faCustom() - // [...] - } -} -``` - -With this configuration, Sharp will display a second screen to the user, after a successful password based login, asking for a 6-digit code. This code will be provided to the user depending on the configuration: -- `enable2faByNotification()`: a notification is sent to the user (email by default, but you can tweak this, see below) -- `enable2faByTotp()`: the user must use a 2fa authenticator app (like Google or Microsoft Authenticator) to generate a code -- `enable2faCustom()`: in this case you must provide your own 2fa handler, see below. - -### Handling the 2fa code via a notification - -::: warning -To be able to receive notifications, your User model must use the `Illuminate\Notifications\Notifiable` trait. -::: - -With this option, Sharp will send a notification to the user, containing the 6-digit code. By default, this notification is sent by email. You can override this behavior by providing your own handler class which must extend `Code16\Sharp\Auth\TwoFactor\Sharp2faNotificationHandler`: - -```php -class SharpServiceProvider extends SharpAppServiceProvider -{ - protected function configureSharp(SharpConfigBuilder $config): void - { - $config - ->enable2faCustom(\App\Sharp\My2faNotificationHandler::class) - // [...] - } -} -``` - -```php -class My2faNotificationHandler extends Sharp2faNotificationHandler -{ - protected function getNotification(int $code): Notification - { - return new My2faDefaultNotification($code); - } -} -``` - -### Handling the 2fa code via a TOTP authenticator app - -::: warning -This implies a bit more work to implement, but this method is more secure than the notification handler. The out-of-the-box implementation implies that you leverage Eloquent. -::: - -With this option, Sharp will ask the user to register the app in a 2fa authenticator (like Google or Microsoft Authenticator). The user will have to provide a 6-digit code generated by the app to Sharp, in order to be authenticated. - -First, require two packages needed for this feature: - -```bash -composer require pragmarx/google2fa-laravel -composer require bacon/bacon-qr-code -``` - -Then, you'll need to configure the totp handler: - -```php -class SharpServiceProvider extends SharpAppServiceProvider -{ - protected function configureSharp(SharpConfigBuilder $config): void - { - $config - ->enable2faByTotp() - // [...] - } -} -``` - -Add three columns in the users table to store the 2fa secret, 2fa recovery codes and 2fa confirmation timestamp. Here’s a migration example: - -```php -return new class extends Migration -{ - public function up(): void - { - Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret') - ->after('password') - ->nullable(); - - $table->text('two_factor_recovery_codes') - ->after('two_factor_secret') - ->nullable(); - - $table->timestamp('two_factor_confirmed_at') - ->after('two_factor_recovery_codes') - ->nullable(); - }); - } -}; -``` - -After that, you must provide a way for your users to register the app in their 2fa authenticator. Sharp can help a lot with that, by extending two built-in Commands; one for activating and one for deactivating 2fa. The idea is to add these commands in a "profile" SingleShow, or in some related Entity List. - -```php -class Activate2faCommand extends SingleInstanceWizardCommand -{ - use Code16\Sharp\Auth\TwoFactor\Commands\Activate2faViaTotpWizardCommandTrait; -} -``` - -```php -class Deactivate2faCommand extends SingleInstanceCommand -{ - use Code16\Sharp\Auth\TwoFactor\Commands\Deactivate2FaViaTotpSingleInstanceCommandTrait; - // or Code16\Sharp\Auth\TwoFactor\Commands\Deactivate2FaViaTotpEntityCommandTrait -} -``` - -The first command is a wizard which will guide the user through the registration process; the second one is to deactivate the 2fa. Both require to enter a password. -You can tweak these commands and provide your own implementation if needed. - -Finally, if you need more control, you can provide your own handler class via `->enable2faCustom()`, which must extend `Code16\Sharp\Auth\TwoFactor\Sharp2faTotpHandler`. - -### Enabling 2fa for some users only - -Providing your own handler implementation, you can override the `isEnabledFor` method to enable 2fa for some users only: - -```php -class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2faTotpHandler -{ - public function isEnabledFor($user): bool - { - return $user->hasGroup('sharp'); - } -} -``` - -### Customize the 2fa form - -You can also change the default help text display above the 2fa form in the handler: - -```php -class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2faTotpHandler -{ - public function formHelpText(): string - { - return sprintf( - 'You code was sent via SMS to your phone number ending in %s', - substr(User::find($this->userId())->phone, -4) - ); - } -} -``` - ## Forgotten password You can activate the classic Laravel workflow of forgotten password with a simple config: @@ -450,3 +291,13 @@ class SharpServiceProvider extends SharpAppServiceProvider } ``` +## Two-factor authentication (2fa) + +See [Two-factor authentication](authentication-2fa) + +## Passkeys authentication + +See [Passkeys authentication](authentication-passkeys) + + + diff --git a/package-lock.json b/package-lock.json index 5f4599d22..31446364f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@googlemaps/js-api-loader": "^1.16.8", "@inertiajs/vue3": "^2.2.20", + "@simplewebauthn/browser": "^13.1.0", "@tiptap/core": "^3.3.0", "@tiptap/extension-character-count": "^3.3.0", "@tiptap/extension-code-block": "^3.3.0", @@ -3003,6 +3004,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", @@ -23144,6 +23151,11 @@ "dev": true, "optional": true }, + "@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" + }, "@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", diff --git a/package.json b/package.json index 7b11afa46..4674660da 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "@googlemaps/js-api-loader": "^1.16.8", "@inertiajs/vue3": "^2.2.20", + "@simplewebauthn/browser": "^13.1.0", "@tiptap/core": "^3.3.0", "@tiptap/extension-character-count": "^3.3.0", "@tiptap/extension-code-block": "^3.3.0", diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index 8c1ffc9fb..3fa88e9b3 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -15,6 +15,14 @@ import { FormItem } from "@/components/ui/form"; import { Check } from "lucide-vue-next"; import TemplateRenderer from "@/components/TemplateRenderer.vue"; + import { onMounted } from "vue"; + import { + startAuthentication, + browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill + } from "@simplewebauthn/browser"; + import { api } from "@/api/api"; + import { Separator } from "@/components/ui/separator"; const props = defineProps<{ loginIsEmail: boolean, @@ -23,6 +31,7 @@ login: string | null, password: string | null, }, + passkeyError: string | null, }>(); @@ -30,6 +39,41 @@ login: props.prefill?.login, password: props.prefill?.password, remember: false, + supports_passkeys: browserSupportsWebAuthn(), + }); + + const passkeyForm = useForm({ + remember: false, + start_authentication_response: '', + }); + + async function loginWithPasskey(autofill = false) { + try { + const response = await api.get(route('passkeys.authentication_options'), { + ignoreContentType: true, + }); + + const authenticationOptions = response.data; + const authenticationResponse = await startAuthentication({ + optionsJSON: authenticationOptions, + useBrowserAutofill: autofill, + }); + + console.log(authenticationResponse); + + passkeyForm.remember = form.remember; + passkeyForm.start_authentication_response = JSON.stringify(authenticationResponse); + + passkeyForm.post(route('passkeys.login')); + } catch (error) { + console.error(error); + } + } + + onMounted(async () => { + if(config('sharp.auth.passkeys.enabled') && await browserSupportsWebAuthnAutofill()) { + await loginWithPasskey(true); + } }); @@ -39,10 +83,10 @@ {{ __('sharp::pages/auth/login.title') }} - - + diff --git a/resources/js/Pages/Auth/Passkeys/Create.vue b/resources/js/Pages/Auth/Passkeys/Create.vue new file mode 100644 index 000000000..6526bc83f --- /dev/null +++ b/resources/js/Pages/Auth/Passkeys/Create.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/api/interceptors.ts b/resources/js/api/interceptors.ts index 69cd127d6..8bf80afef 100644 --- a/resources/js/api/interceptors.ts +++ b/resources/js/api/interceptors.ts @@ -5,6 +5,7 @@ import { Axios, AxiosError, isCancel } from "axios"; declare module 'axios' { interface AxiosRequestConfig { preloaded?: boolean; + ignoreContentType?: boolean; } } @@ -31,6 +32,7 @@ export function installInterceptors(api: Axios) { response => { if(!response.headers['content-type']?.includes('application/json') && !response.headers['content-disposition']?.includes('attachment') + && !response.config.ignoreContentType ) { throw new Error( `${response.config.method.toUpperCase()} ${response.config.url} :` + diff --git a/resources/js/components/ui/field/Field.vue b/resources/js/components/ui/field/Field.vue new file mode 100644 index 000000000..05f741b01 --- /dev/null +++ b/resources/js/components/ui/field/Field.vue @@ -0,0 +1,25 @@ + + + diff --git a/resources/js/components/ui/field/FieldContent.vue b/resources/js/components/ui/field/FieldContent.vue new file mode 100644 index 000000000..941f2be7f --- /dev/null +++ b/resources/js/components/ui/field/FieldContent.vue @@ -0,0 +1,20 @@ + + + diff --git a/resources/js/components/ui/field/FieldDescription.vue b/resources/js/components/ui/field/FieldDescription.vue new file mode 100644 index 000000000..2eb8a85bb --- /dev/null +++ b/resources/js/components/ui/field/FieldDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/resources/js/components/ui/field/FieldError.vue b/resources/js/components/ui/field/FieldError.vue new file mode 100644 index 000000000..d8d454472 --- /dev/null +++ b/resources/js/components/ui/field/FieldError.vue @@ -0,0 +1,53 @@ + + + diff --git a/resources/js/components/ui/field/FieldGroup.vue b/resources/js/components/ui/field/FieldGroup.vue new file mode 100644 index 000000000..81a3d87cb --- /dev/null +++ b/resources/js/components/ui/field/FieldGroup.vue @@ -0,0 +1,20 @@ + + + diff --git a/resources/js/components/ui/field/FieldLabel.vue b/resources/js/components/ui/field/FieldLabel.vue new file mode 100644 index 000000000..4443a8ce8 --- /dev/null +++ b/resources/js/components/ui/field/FieldLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/components/ui/field/FieldLegend.vue b/resources/js/components/ui/field/FieldLegend.vue new file mode 100644 index 000000000..8b6201de7 --- /dev/null +++ b/resources/js/components/ui/field/FieldLegend.vue @@ -0,0 +1,24 @@ + + + diff --git a/resources/js/components/ui/field/FieldSeparator.vue b/resources/js/components/ui/field/FieldSeparator.vue new file mode 100644 index 000000000..94413b231 --- /dev/null +++ b/resources/js/components/ui/field/FieldSeparator.vue @@ -0,0 +1,29 @@ + + + diff --git a/resources/js/components/ui/field/FieldSet.vue b/resources/js/components/ui/field/FieldSet.vue new file mode 100644 index 000000000..a5ef54be0 --- /dev/null +++ b/resources/js/components/ui/field/FieldSet.vue @@ -0,0 +1,21 @@ + + + diff --git a/resources/js/components/ui/field/FieldTitle.vue b/resources/js/components/ui/field/FieldTitle.vue new file mode 100644 index 000000000..3345c14b5 --- /dev/null +++ b/resources/js/components/ui/field/FieldTitle.vue @@ -0,0 +1,20 @@ + + + diff --git a/resources/js/components/ui/field/index.ts b/resources/js/components/ui/field/index.ts new file mode 100644 index 000000000..c831cb089 --- /dev/null +++ b/resources/js/components/ui/field/index.ts @@ -0,0 +1,39 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +) + +export type FieldVariants = VariantProps + +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 @@
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', ]);