diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index d08dc62..51f4425 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-workflow +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Tests @@ -9,120 +9,120 @@ on: jobs: byte_level: - name: "0️⃣ Byte-level" - runs-on: "ubuntu-latest" + name: 0️⃣ Byte-level + runs-on: ubuntu-latest steps: - - name: "Checkout code" - uses: "actions/checkout@v3" + - name: Checkout code + uses: actions/checkout@v4 - - name: "Check file permissions" + - name: Check file permissions run: | - test "$(find . -type f -not -path './.git/*' -executable)" == "" - - name: "Find non-printable ASCII characters" + test $(find . -type f -not -path './.git/*' -executable) == + - name: Find non-printable ASCII characters run: | - ! LC_ALL=C.UTF-8 find ./src -type f -name "*.php" -print0 | xargs -0 -- grep -PHn "[^ -~]" + ! LC_ALL=C.UTF-8 find ./src -type f -name *.php -print0 | xargs -0 -- grep -PHn [^ -~] syntax_errors: - name: "1️⃣ Syntax errors" - runs-on: "ubuntu-latest" + name: 1️⃣ Syntax errors + runs-on: ubuntu-latest steps: - - name: "Set up PHP" - uses: "shivammathur/setup-php@v2" + - name: Set up PHP + uses: shivammathur/setup-php@v2 with: - php-version: "8.3" - tools: "parallel-lint" + php-version: 8.3 + tools: parallel-lint - - name: "Checkout code" - uses: "actions/checkout@v3" + - name: Checkout code + uses: actions/checkout@v4 - - name: "Validate Composer configuration" - run: "composer validate --strict" + - name: Validate Composer configuration + run: composer validate --strict - - name: "Check source code for syntax errors" - run: "composer exec -- parallel-lint src/" + - name: Check source code for syntax errors + run: composer exec -- parallel-lint src/ unit_tests: - name: "2️⃣ Unit and Feature tests" + name: 2️⃣ Unit and Feature tests needs: - - "byte_level" - - "syntax_errors" - runs-on: "ubuntu-latest" + - byte_level + - syntax_errors + runs-on: ubuntu-latest strategy: matrix: php-version: - - "8.0" - - "8.1" - - "8.2" + - 8.1 + - 8.2 + - 8.3 laravel-constraint: - - "9.*" - - "10.*" + - 10.* + - 11.* dependencies: - - "lowest" - - "highest" + - lowest + - highest exclude: - - php-version: "8.0" - laravel-constraint: "10.*" + - laravel-constraint: 11.* + php-version: 8.1 steps: - - name: "Set up PHP" - uses: "shivammathur/setup-php@v2" + - name: Set up PHP + uses: shivammathur/setup-php@v2 with: - php-version: "${{ matrix.php-version }}" - extensions: "mbstring, intl" - coverage: "xdebug" + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: xdebug - - name: "Checkout code" - uses: "actions/checkout@v3" + - name: Checkout code + uses: actions/checkout@v4 - - name: "Install dependencies" - uses: "ramsey/composer-install@v2" + - name: Install dependencies + uses: ramsey/composer-install@v3 with: - dependency-versions: "${{ matrix.dependencies }}" - composer-options: "--with=laravel/framework:${{ matrix.laravel-constraint }}" + dependency-versions: ${{ matrix.dependencies }} + composer-options: --with=laravel/framework:${{ matrix.laravel-constraint }} - - name: "Execute unit tests" - run: "composer run-script test" + - name: Execute unit tests + run: composer run-script test - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v3" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 static_analysis: - name: "3️⃣ Static Analysis" + name: 3️⃣ Static Analysis needs: - - "byte_level" - - "syntax_errors" - runs-on: "ubuntu-latest" + - byte_level + - syntax_errors + runs-on: ubuntu-latest steps: - - name: "Set up PHP" - uses: "shivammathur/setup-php@v2" + - name: Set up PHP + uses: shivammathur/setup-php@v2 with: - tools: "phpstan" - php-version: "latest" - coverage: "none" + tools: phpstan + php-version: latest + coverage: none - - name: "Checkout code" - uses: "actions/checkout@v3" + - name: Checkout code + uses: actions/checkout@v4 - - name: "Install dependencies" - uses: "ramsey/composer-install@v2" + - name: Install dependencies + uses: ramsey/composer-install@v3 - - name: "Execute static analysis" - run: "composer exec -- phpstan analyze -l 5 src/" + - name: Execute static analysis + run: composer exec -- phpstan analyze -l 5 src/ exported_files: - name: "4️⃣ Exported files" + name: 4️⃣ Exported files needs: - - "byte_level" - - "syntax_errors" - runs-on: "ubuntu-latest" + - byte_level + - syntax_errors + runs-on: ubuntu-latest steps: - - name: "Checkout code" - uses: "actions/checkout@v3" + - name: Checkout code + uses: actions/checkout@v4 - - name: "Check exported files" + - name: Check exported files run: | - EXPECTED="LICENSE.md,README.md,composer.json" - CURRENT="$(git archive HEAD | tar --list --exclude="src" --exclude="src/*" --exclude=".stubs" --exclude=".stubs/*" --exclude="routes" --exclude="routes/*" --exclude="stubs" --exclude="stubs/*" --exclude="lang" --exclude="lang/*" --exclude="config" --exclude="config/*" --exclude="database" --exclude="database/*" --exclude="resources" --exclude="resources/*" | paste -s -d ",")" - echo "CURRENT =${CURRENT}" - echo "EXPECTED=${EXPECTED}" - test "${CURRENT}" == "${EXPECTED}" + EXPECTED=LICENSE.md,MIGRATIONS.md,README.md,composer.json + CURRENT=$(git archive HEAD | tar --list --exclude=src --exclude=src/* --exclude=.stubs --exclude=.stubs/* --exclude=routes --exclude=routes/* --exclude=stubs --exclude=stubs/* --exclude=lang --exclude=lang/* --exclude=config --exclude=config/* --exclude=database --exclude=database/* --exclude=resources --exclude=resources/* | paste -s -d ,) + echo CURRENT =${CURRENT} + echo EXPECTED=${EXPECTED} + test ${CURRENT} == ${EXPECTED} diff --git a/LICENSE.md b/LICENSE.md index 2efb876..95af944 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ MIT License Copyright (c) Italo Israel Baeza Cabrera -Copyright (c) 2021 Lukas Buchs (Attestation Object & Formats, Authenticator Data parts) +Copyright (c) 2022 Lukas Buchs (Attestation Object & Formats, Assertion, Authenticator Data parts) Copyright (c) 2018 Thomas Bleeker (CBOR & ByteBuffer part) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..995139b --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,111 @@ +# Migration + +This package comes with a very hands-off approach for migrations. If you check the new migrations published in `database/migrations`, you will find something very similar to this: + +```php +use Vendor\Package\Models\Car; + +return Car::migration(); +``` + +Worry not, the migration will still work. It has been _simplified_ for easy customization. + +## Adding columns + +To add columns to the migration, add a callback to the `migration()` method that receives the table blueprint. + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\Package\Models\Car; + +return Car::migration(function (Blueprint $table) { + $table->boolean('is_cool')->default(true); + $table->string('color'); +}); +``` + +> [!INFO] +> +> If your package doesn't support additional tables, the callback never executes. Refer to the package documentation. + +## After Up & Before Down + +If you need to execute logic after creating the table, or before dropping it, use the `afterUp()` and `beforeDown()` methods, respectively. + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\Package\Models\Car; + +return Car::migration() + ->afterUp(function (Blueprint $table) { + $table->foreignId('sociable_id')->references('id')->on('users'); + }) + ->beforeDown(function (Blueprint $table) { + $table->dropForeign('sociable_id'); + }); +``` + +### Morphs + +You may find yourself needing to alter the type of the morph relation created in the migration. For example, the migration will create an integer-type morph that you won't be able to attach to an ULID-based User model. + +To change the morph type, use the `morph...` property access preferably, or the `morph()` method with `numeric`, `uuid` or `ulid` if you need to also set an index name (in case your database engine doesn't play nice with large index names). + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\Package\Models\Car; + +return Car::migration()->morphUuid; + +return Car::migration()->morph('uuid', 'shorter_morph_index_name'); +``` + +## Custom table name + +By default, tables are set using the model name in plural. If you want to change the table name from the standard, set it using the `$useTable` static property of the target Model. You should do this on the `register()` method of your `AppServiceProvider`. + +```php +namespace App\Providers; + +use Illuminate\Support\ServiceProvider; +use Laragear\Package\Models\Model; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + Model::$useTable = 'my_custom_table'; + } +} +``` + +### Configuring the model + +All customizable models can be configured with additional fillable, guarded, hidden, visible and appended attributes. These are _merged_ with the original configuration of the model itself, so changes are not destructive. + +Customize the model using the available static properties: + +- `$useCasts`: The casts attributes to merge. +- `$useFillable`: The fillable attributes to merge. +- `$useGuarded`: The guarded attributes to merge. +- `$useHidden`: The hidden attributes to merge. +- `$useVisible`: The visible attributes to merge. +- `$useAppends`: The appends attributes to merge. + +```php +use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection; +use Vendor\Package\Models\Car; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + Car::$useCasts = [ + 'is_cool' => 'boolean', + 'colors' => AsEncryptedCollection::class, + ]; + + Car::$useHidden = ['colors']; + } +} +``` diff --git a/README.md b/README.md index 97e1f34..f97599b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ public function login(AssertedRequest $request) return response()->json(['message' => "Welcome back, $user->name!"]); } ``` - +> [!TIP] +> > You want to add two-factor authentication to your app? Check out [Laragear TwoFactor](https://github.com/Laragear/TwoFactor). ## Become a sponsor @@ -31,8 +32,14 @@ Your support allows me to keep this package free, up-to-date and maintainable. A ## Requirements -* PHP 8.1 or later, with `ext-openssl`. -* Laravel 9.x or later. +* Laravel 10.x or later. +* PHP 8.1 or later. +* The `ext-openssl` extension. +* The `ext-sodium` extension (optional, for EdDSA 25519 public keys). + +> [!TIP] +> +> If you can't enable the `ext-sodium` extension for whatever reason, you may try installing [`paragonie/sodium_compat`](https://github.com/paragonie/sodium_compat). ## Installation @@ -56,22 +63,15 @@ The private key doesn't leave the authenticator, there are no shared passwords s We need to make sure your users can register their devices and authenticate with them. -1. [Add the `eloquent-webauthn` driver](#1-add-the-eloquent-webauthn-driver) -2. [Create the `webauthn_credentials` table](#2-create-the-webauthn_credentials-table) +1. [Publish the files](#2-publish-files-and-migrate) +2. [Add the WebAuthn driver](#1-add-the-webauthn-driver) 3. [Implement the contract and trait](#3-implement-the-contract-and-trait) +4. [Register the controllers](#4-register-the-routes-and-controllers) _(optional)_ +5. [Use the Javascript helper](#5-use-the-javascript-helper) _(optional)_ -After that, you can quickly start WebAuthn with the included controllers and install the JavaScript helper to make your life easier. - -4. [Register the controllers](#4-register-the-routes-and-controllers) -5. [Use the Javascript helper](#5-use-the-javascript-helper) - -> **Info** -> -> While you can use Passkeys without users by invoking the _ceremonies_ manually, Laragear WebAuthn is intended to be used with already existing Users. - -### 1. Add the `eloquent-webauthn` driver +### 1. Add the WebAuthn driver -Laragear WebAuthn works by extending the Eloquent User Provider with an additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have. +Laragear WebAuthn works by extending the Eloquent User Provider with a simple additional check to find a user for the given WebAuthn Credentials (Assertion). This makes this WebAuthn package compatible with any guard you may have. Simply go into your `auth.php` configuration file, change the driver from `eloquent` to `eloquent-webauthn`, and add the `password_fallback` to `true`. @@ -91,16 +91,23 @@ return [ The `password_fallback` indicates the User Provider should fall back to validate the password when the request is not a WebAuthn Assertion. It's enabled to seamlessly use both classic (password) and WebAuthn authentication procedures. -### 2. Create the `webauthn_credentials` table +### 2. Publish files and migrate -Create the `webauthn_credentials` table by publishing the migration file and migrating the table: +With the single `webauthn:install` command, you can install the configuration, routes, and migration files. + +```shell +php artisan webauthn:install +``` + +This will also publish a migration file needed to create a table to hold the WebAuthn Credentials (Passkeys). Once ready, migrate your application to create the table. ```shell -php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="migrations" php artisan migrate ``` -> You may edit the migration to your liking, like adding new columns, but **not** to remove them or change their name. +> [!TIP] +> +> You can [modify the migration](MIGRATIONS.md) if you need to, like [changing the table name](MIGRATIONS.md#custom-table-name). ### 3. Implement the contract and trait @@ -127,44 +134,61 @@ From here you're ready to work with WebAuthn Authentication. The following steps ### 4. Register the routes and controllers -WebAuthn uses exclusive routes to register and authenticate users. Creating these routes and controller may be cumbersome, specially if it's your first time in the WebAuthn realm. - -Instead, go for a quick start and publish the controllers included in Laragear WebAuthn. These controllers will be located at `app\Http\Controllers\WebAuthn`. - -```shell -php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider" --tag="controllers" -``` +WebAuthn uses exclusive routes to register and authenticate users. Creating these routes and controller may be cumbersome, specially if it's your first time in the WebAuthn realm, so these are installed automatically at `Http\Controllers\WebAuthn` when using `webauthn:install`. -Next, to pick these controllers easily, go into your `web.php` routes file and register a default set of routes with the `WebAuthn::routes()` method. +Go into your `web.php` routes file and register a default set of routes with the `\Laragear\WebAuthn\Http\Routes::register()` method. ```php // web.php use Illuminate\Support\Facades\Route; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Http\Routes as WebAuthnRoutes; Route::view('welcome'); // WebAuthn Routes -WebAuthn::routes(); +WebAuthnRoutes::register(); ``` +The method allows to use different attestation and assertion paths, and even each of the controllers. + +```php +use Laragear\WebAuthn\Http\Routes as WebAuthnRoutes; + +WebAuthnRoutes::register( + attest: 'auth/register', + assert: 'auth/login' +); +``` + +> [!INFO] +> +> You can also delete the controllers and implement [attestation](#attestation) and [assertion](#assertion) manually. + ### 5. Use the Javascript helper -If you're using simple HTML packages, you may use the `laragear-webpass` Javascript file directly setting it in your HTML document header. +This package original Javascript helper has been moved into its own package, called `@laragear/webpass`. You may use directly in your HTML application by just using JSDelivr CDN: ```html - + - ``` -Alternatively, you may want to include it in your project packages: +Alternatively, you may want to include it in your project dependencies if you're using a frontend framework like Vue, React, Angular or Svelte, to name a few. ```shell npm i laragear-webpass @@ -180,13 +204,13 @@ if (Webpass.isUnsupported()) { } // Create new credentials for a logged in user -const { credential, success, error } = await Webpass.attest("/auth/register/options", "/auth/register") +const { credential, success, error } = await Webpass.attest("/webauthn/register/options", "/webauthn/register") // Check the credentials for a guest user -const { user, success, error } = await Webpass.assert("/auth/login/options", "/auth/login") +const { user, success, error } = await Webpass.assert("/webauthn/login/options", "/webauthn/login") ``` -The Webpass helper offers more flexibility than just adjusting the WebAuthn path. For more information, check [the documentation of `laragear-webpass`](https://github.com/Laragear/webpass). +The Webpass helper offers more flexibility than just adjusting the WebAuthn ceremony paths. For more information, check [the documentation of `@laragear/webpass`](https://github.com/Laragear/webpass). ## Attestation @@ -237,7 +261,9 @@ public function register(AttestedRequest $request) } ``` -> Both `AttestationRequest` and `AttestedRequest` validates the authenticated user. If the user is not authenticated, an HTTP 403 status code will be returned. +> [!IMPORTANT] +> +> Both `AttestationRequest` and `AttestedRequest` require the authenticated user. If the user is not authenticated, an HTTP 403 status code will be returned. ### Attestation User verification @@ -257,7 +283,7 @@ public function createChallenge(AttestationRequest $request) ### Userless/One-touch/Typeless Login -Userless/One-touch/Typeless login This enables one click/tap login, without the need to specify the user credentials (like the email) beforehand. +This enables one click/tap login, without the need to specify the user credentials (like the email) beforehand. For this to work, the device has to save the "username id" inside itself. Some authenticators _may_ save it regardless, others may be not compatible. To make this mandatory when creating the WebAuthn Credential, use the `userless()` method of the `AttestationRequest` form request. @@ -270,14 +296,15 @@ public function registerDevice(AttestationRequest $request) return $request->userless()->toCreate(); } ``` - +> [!IMPORTANT] +> > The Authenticator WILL require [user verification](#attestation-user-verification) on login when using `userless()`. Its highly probable the user will also be asked for [user verification on login](#assertion-user-verification), as it will depend on the authenticator itself. ### Multiple credentials per device By default, during Attestation, the device will be informed about the existing enabled credentials already registered in the application. This way the device can avoid creating another one for the same purpose. -You can enable multiple credentials per device using `allowDuplicates()`, which in turn will always return an empty list of credentials to exclude. This way the authenticator will _think_ there are no already stored credentials for your app. +You can enable multiple credentials per device using `allowDuplicates()`, which in turn will always return an empty list of credentials to exclude. This way the authenticator will _think_ there are no already stored credentials for your app, and create a new one. ```php // app\Http\Controllers\WebAuthn\AttestationController.php @@ -472,8 +499,70 @@ public function authenticate(Request $request, AssertionValidator $assertion) } ``` +> [!WARNING] +> > The pipes list and the pipes themselves are **not** covered by API changes, and are marked as `internal`. These may change between versions without notice. +## Migrations + +This package comes with a migration file that extends a special class that takes most of the heavy lifting for you. You only need to create additional columns if you need to. + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\WebAuthn\Database\WebAuthnCredentialsMigration; + +return new class extends WebAuthnCredentialsMigration { + /** + * Modify the migration for the WebAuthn Credentials. + */ + public function modifyMigration(Blueprint $table): void + { + // You may add here your own columns... + // + // $table->string('device_name')->nullable(); + // $table->string('device_type')->nullable(); + // $table->timestamp('last_login_at')->nullable(); + } +}; +``` + +If you need to modify the table, or adjust the data, after is created or before is dropped, you may use the `afterUp()` and `beforeDown()` methods of the migration file, respectively. + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\WebAuthn\Database\WebAuthnCredentialsMigration; + +return new class extends WebAuthnCredentialsMigration { + // ... + + public function afterUp(Blueprint $table): void + { + $table->foreignId('device_serial')->references('serial')->on('devices'); + } + + public function beforeDown(Blueprint $table): void + { + $table->dropForeign('device_serial') + } +}; +``` + +### UUID or ULID morphs + +There may be some scenarios where your _authenticatable_ User is using a different type of primary ID in the database, like UUID or ULID. If this is the case, you may change the morph type accordingly with the `$morphType` property. + +```php +use Illuminate\Database\Schema\Blueprint; +use Laragear\WebAuthn\Database\WebAuthnCredentialsMigration; + +return new class extends WebAuthnCredentialsMigration { + + protected ?string $morphType = 'ulid'; + + // ... +}; +``` + ## Advanced Configuration Laragear WebAuthn was made to work out-of-the-box, but you can override the configuration by simply publishing the config file. @@ -516,7 +605,7 @@ The _Relying Party_ is just a way to uniquely identify your application in the u * `name`: The name of the application. Defaults to the application name. * `id`: An unique ID the application, [recommended to be the site domain](https://www.w3.org/TR/webauthn-2/#rp-id). If `null`, the device _may_ fill it internally, usually as the full domain. -> Warning +> [!WARNING] > > WebAuthn authentication only work on the top domain it was registered. @@ -659,7 +748,7 @@ Use `localhost` exclusively (not `127.0.0.1` or `::1`) or use a proxy to tunnel Because `direct`, `indirect` and `enterprise` attestations are mostly used on high-security high-risk scenarios, where an entity has total control on the devices used to authenticate. Imagine banks, medical, or military. -If you deem this feature critical for you, [**consider supporting this package**](#keep-this-package-free). +If you deem this feature critical for you, [**consider supporting this package**](#become-a-sponsor). * **Can I allow logins with only USB keys?** @@ -677,6 +766,18 @@ If you have [debugging enabled](https://laravel.com/docs/9.x/configuration#debug The rest of errors are thrown as-is. You may want to log them manually using [Laravel's Error Handler](https://laravel.com/docs/10.x/errors) depending on the case. +* **Can I publish only some files?** + +Yes. Instead of using `webauthn:install`, use `vendor:publish` and follow the prompts. + +* **Why `ext-sodium` is required as optional?** + +Some authenticators can create EdDSA 25519 public keys, which are part of [W3C WebAuthn 3.0 draft](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-pubkeycredparams). These keys are shorter and don't require too much computational power to verify, which opens the usage for low-power or "passive" authenticators (like smart-cards). + +If sodium or the [`paragonie/sodium-compat`](https://github.com/paragonie/sodium_compat) package are not installed, the server won't report EdDSA 25519 compatibility to the authenticator, and any EdDSA 25519 public key previously stored will fail validation. + +Consider also that there are no signs of EdDSA 25519 incorporation into PHP `ext-openssl` extension. + ## Laravel Octane Compatibility * There are no singletons using a stale application instance. @@ -688,15 +789,16 @@ There should be no problems using this package with Laravel Octane. ## Security -These are some details about this WebAuthn implementation: +These are some details about this WebAuthn implementation you should be aware of. * Registration (attestation) and Login (assertion) challenges use the current request session. -* Only one ceremony can be done at a time. +* Only one ceremony can be done at a time, because ceremonies use the same challenge key. * Challenges are pulled (retrieved and deleted from source) from the session on resolution, independently of their result. -* All challenges and ceremonies expire at 60 seconds. -* WebAuthn User Handle is UUID v4, reusable if another credential exists. +* All challenges and ceremonies expire after 60 seconds. +* WebAuthn User Handle is UUID v4. +* User Handle is reused when a new credential for the same user is created. * Credentials can be blacklisted (enabled/disabled). -* Public Keys are encrypted by with application key in the database automatically. +* Public Keys are encrypted by with application key in the database automatically, using the application key. If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. diff --git a/composer.json b/composer.json index 6471c94..05827fd 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Authenticate users with Passkeys: fingerprints, patterns and biometric data.", "type": "library", "license": "MIT", - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, "keywords": [ "laravel", @@ -32,19 +32,24 @@ "issues": "https://github.com/Laragear/WebAuthn/issues" }, "require": { - "php": "8.*", - "ext-openssl": "*", + "php": "^8.1", "ext-json": "*", - "illuminate/auth": "9.*|10.*", - "illuminate/http": "9.*|10.*", - "illuminate/session": "9.*|10.*", - "illuminate/support": "9.*|10.*", - "illuminate/config": "9.*|10.*", - "illuminate/database": "9.*|10.*", - "illuminate/encryption": "9.*|10.*" + "ext-openssl": "*", + "illuminate/auth": "10.*|11.*", + "illuminate/config": "10.*|11.*", + "illuminate/database": "10.*|11.*", + "illuminate/encryption": "10.*|11.*", + "illuminate/http": "10.*|11.*", + "illuminate/session": "10.*|11.*", + "illuminate/support": "10.*|11.*", + "laragear/meta-model": "1.*" }, "require-dev": { - "orchestra/testbench": "^7.22|8.*" + "ext-sodium": "*", + "orchestra/testbench": "8.*|9.*" + }, + "suggest": { + "paragonie/sodium_compat": "To enable EdDSA 25519 keys from authenticators, if `ext-sodium` is unavailable." }, "autoload": { "psr-4": { diff --git a/config/webauthn.php b/config/webauthn.php index e279bd3..4fc15f2 100644 --- a/config/webauthn.php +++ b/config/webauthn.php @@ -27,6 +27,8 @@ | of randomness. Since we need to later check them, we'll also store the | bytes for a small amount of time inside this current request session. | + | @see https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges + | */ 'challenge' => [ diff --git a/database/migrations/0000_00_00_000000_create_webauthn_credentials.php b/database/migrations/0000_00_00_000000_create_webauthn_credentials.php new file mode 100644 index 0000000..e163f1e --- /dev/null +++ b/database/migrations/0000_00_00_000000_create_webauthn_credentials.php @@ -0,0 +1,10 @@ +string('alias')->nullable(); +}); diff --git a/database/migrations/2022_07_01_000000_create_webauthn_credentials.php b/database/migrations/2022_07_01_000000_create_webauthn_credentials.php deleted file mode 100644 index 5b86956..0000000 --- a/database/migrations/2022_07_01_000000_create_webauthn_credentials.php +++ /dev/null @@ -1,79 +0,0 @@ -timestamp('last_login_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down(): void - { - Schema::dropIfExists('webauthn_credentials'); - } - - /** - * Generate the default blueprint for the WebAuthn credentials table. - * - * @param \Illuminate\Database\Schema\Blueprint $table - * @return void - */ - protected static function defaultBlueprint(Blueprint $table): void - { - $table->string('id', 510)->primary(); - - $table->morphs('authenticatable', 'webauthn_user_index'); - - // This is the user UUID that is generated automatically when a credential for the - // given user is created. If a second credential is created, this UUID is queried - // and then copied on top of the new one, this way the real User ID doesn't change. - $table->uuid('user_id'); - - // The app may allow the user to name or rename a credential to a friendly name, - // like "John's iPhone" or "Office Computer". - $table->string('alias')->nullable(); - - // Allows to detect cloned credentials when the assertion does not have this same counter. - $table->unsignedBigInteger('counter')->nullable(); - // Who created the credential. Should be the same reported by the Authenticator. - $table->string('rp_id'); - // Where the credential was created. Should be the same reported by the Authenticator. - $table->string('origin'); - $table->json('transports')->nullable(); - $table->uuid('aaguid')->nullable(); // GUID are essentially UUID - - // This is the public key the credential uses to verify the challenges. - $table->text('public_key'); - // The attestation of the public key. - $table->string('attestation_format')->default('none'); - // This would hold the certificate chain for other different attestation formats. - $table->json('certificates')->nullable(); - - // A way to disable the credential without deleting it. - $table->timestamp('disabled_at')->nullable(); - $table->timestamps(); - } -}; diff --git a/phpunit.xml b/phpunit.xml index a10c51f..f949efb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,10 +1,6 @@ - + - - src/ - stubs/controllers - @@ -21,4 +17,10 @@ + + + src/ + stubs/controllers + + diff --git a/resources/js/webauthn.js b/resources/js/webauthn.js index b2636b9..17b1b76 100644 --- a/resources/js/webauthn.js +++ b/resources/js/webauthn.js @@ -68,6 +68,8 @@ class WebAuthn { * @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars) */ constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) { + console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass') + Object.assign(this.#routes, routes); Object.assign(this.#headers, headers); diff --git a/routes/webauthn.php b/routes/webauthn.php deleted file mode 100644 index 571a8e1..0000000 --- a/routes/webauthn.php +++ /dev/null @@ -1,20 +0,0 @@ -group(static function (): void { - Route::controller(WebAuthnRegisterController::class) - ->group(static function (): void { - Route::post('webauthn/register/options', 'options')->name('webauthn.register.options'); - Route::post('webauthn/register', 'register')->name('webauthn.register'); - }); - - Route::controller(WebAuthnLoginController::class) - ->group(static function (): void { - Route::post('webauthn/login/options', 'options')->name('webauthn.login.options'); - Route::post('webauthn/login', 'login')->name('webauthn.login'); - }); - }); diff --git a/src/Assertion/Creator/AssertionCreation.php b/src/Assertion/Creator/AssertionCreation.php index bc1a4e1..20ac3e4 100644 --- a/src/Assertion/Creator/AssertionCreation.php +++ b/src/Assertion/Creator/AssertionCreation.php @@ -5,32 +5,22 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; +use Laragear\WebAuthn\Enums\UserVerification; use Laragear\WebAuthn\JsonTransport; class AssertionCreation { - /** - * The Json Transport helper to build the message. - * - * @var \Laragear\WebAuthn\JsonTransport - */ - public JsonTransport $json; - /** * Create a new Assertion Creation instance. - * - * @param \Illuminate\Http\Request $request - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user - * @param \Illuminate\Database\Eloquent\Collection|null $acceptedCredentials - * @param string|null $userVerification */ public function __construct( public Request $request, public ?WebAuthnAuthenticatable $user = null, public ?Collection $acceptedCredentials = null, - public ?string $userVerification = null, + public ?UserVerification $userVerification = null, + public JsonTransport $json = new JsonTransport(), ) { - $this->json = new JsonTransport(); + // } } diff --git a/src/Assertion/Creator/Pipes/AddConfiguration.php b/src/Assertion/Creator/Pipes/AddConfiguration.php index 77d819c..adf7f71 100644 --- a/src/Assertion/Creator/Pipes/AddConfiguration.php +++ b/src/Assertion/Creator/Pipes/AddConfiguration.php @@ -10,8 +10,6 @@ class AddConfiguration { /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -20,10 +18,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming Assertion. - * - * @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion - * @param \Closure $next - * @return mixed */ public function handle(AssertionCreation $assertion, Closure $next): mixed { diff --git a/src/Assertion/Creator/Pipes/CreateAssertionChallenge.php b/src/Assertion/Creator/Pipes/CreateAssertionChallenge.php index c9d78b1..7f0a778 100644 --- a/src/Assertion/Creator/Pipes/CreateAssertionChallenge.php +++ b/src/Assertion/Creator/Pipes/CreateAssertionChallenge.php @@ -13,8 +13,6 @@ class CreateAssertionChallenge /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -24,9 +22,7 @@ public function __construct(protected Repository $config) /** * Handle the incoming Assertion. * - * @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion - * @param \Closure $next - * @return mixed + * @throws \Random\RandomException */ public function handle(AssertionCreation $assertion, Closure $next): mixed { diff --git a/src/Assertion/Creator/Pipes/MayRequireUserVerification.php b/src/Assertion/Creator/Pipes/MayRequireUserVerification.php index d8b537f..eb3f178 100644 --- a/src/Assertion/Creator/Pipes/MayRequireUserVerification.php +++ b/src/Assertion/Creator/Pipes/MayRequireUserVerification.php @@ -9,15 +9,11 @@ class MayRequireUserVerification { /** * Handle the incoming Assertion. - * - * @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion - * @param \Closure $next - * @return mixed */ public function handle(AssertionCreation $assertion, Closure $next): mixed { if ($assertion->userVerification) { - $assertion->json->set('userVerification', $assertion->userVerification); + $assertion->json->set('userVerification', $assertion->userVerification->value); } return $next($assertion); diff --git a/src/Assertion/Creator/Pipes/MayRetrieveCredentialsIdForUser.php b/src/Assertion/Creator/Pipes/MayRetrieveCredentialsIdForUser.php index 42977da..1a4a0d6 100644 --- a/src/Assertion/Creator/Pipes/MayRetrieveCredentialsIdForUser.php +++ b/src/Assertion/Creator/Pipes/MayRetrieveCredentialsIdForUser.php @@ -13,14 +13,10 @@ class MayRetrieveCredentialsIdForUser { /** * Handle the incoming Assertion. - * - * @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion - * @param \Closure $next - * @return mixed */ public function handle(AssertionCreation $assertion, Closure $next): mixed { - // If there is a user found, we will pluck the IDS and add them as a binary buffer. + // If there is a user found, we will pluck the IDs and add them as a binary buffer. if ($assertion->user) { $assertion->acceptedCredentials = $assertion->user->webAuthnCredentials()->get(['id', 'transports']); diff --git a/src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php b/src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php index fa5fc85..4825e9b 100644 --- a/src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php +++ b/src/Assertion/Validator/Pipes/CheckCredentialIsForUser.php @@ -28,9 +28,6 @@ class CheckCredentialIsForUser /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed @@ -50,9 +47,6 @@ public function handle(AssertionValidation $validation, Closure $next): mixed /** * Validate the user owns the Credential if it already exists in the validation procedure. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return void */ protected function validateUser(AssertionValidation $validation): void { @@ -64,9 +58,6 @@ protected function validateUser(AssertionValidation $validation): void /** * Validate the user ID of the response. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return void */ protected function validateId(AssertionValidation $validation): void { diff --git a/src/Assertion/Validator/Pipes/CheckCredentialIsWebAuthnGet.php b/src/Assertion/Validator/Pipes/CheckCredentialIsWebAuthnGet.php index 73b48e1..e59c232 100644 --- a/src/Assertion/Validator/Pipes/CheckCredentialIsWebAuthnGet.php +++ b/src/Assertion/Validator/Pipes/CheckCredentialIsWebAuthnGet.php @@ -14,9 +14,6 @@ class CheckCredentialIsWebAuthnGet /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed diff --git a/src/Assertion/Validator/Pipes/CheckPublicKeyCounterCorrect.php b/src/Assertion/Validator/Pipes/CheckPublicKeyCounterCorrect.php index ad6a448..99a7bae 100644 --- a/src/Assertion/Validator/Pipes/CheckPublicKeyCounterCorrect.php +++ b/src/Assertion/Validator/Pipes/CheckPublicKeyCounterCorrect.php @@ -28,9 +28,6 @@ class CheckPublicKeyCounterCorrect /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed @@ -48,9 +45,6 @@ public function handle(AssertionValidation $validation, Closure $next): mixed /** * Check if the incoming credential or the stored credential have a counter. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return bool */ protected function hasCounter(AssertionValidation $validation): bool { @@ -60,9 +54,6 @@ protected function hasCounter(AssertionValidation $validation): bool /** * Check if the credential counter is equal or higher than what the authenticator reports. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return bool */ protected function counterBelowStoredCredential(AssertionValidation $validation): bool { diff --git a/src/Assertion/Validator/Pipes/CheckPublicKeySignature.php b/src/Assertion/Validator/Pipes/CheckPublicKeySignature.php index 7b91584..9ed3b25 100644 --- a/src/Assertion/Validator/Pipes/CheckPublicKeySignature.php +++ b/src/Assertion/Validator/Pipes/CheckPublicKeySignature.php @@ -3,13 +3,20 @@ namespace Laragear\WebAuthn\Assertion\Validator\Pipes; use Closure; +use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Laragear\WebAuthn\Assertion\Validator\AssertionValidation; use Laragear\WebAuthn\Exceptions\AssertionException; -use OpenSSLAsymmetricKey; +use Laragear\WebAuthn\Models\WebAuthnCredential; use function base64_decode; +use function function_exists; use function hash; +use function in_array; +use function openssl_error_string; use function openssl_pkey_get_public; use function openssl_verify; +use function strlen; use const OPENSSL_ALGO_SHA256; /** @@ -20,49 +27,99 @@ class CheckPublicKeySignature /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed { - $publicKey = openssl_pkey_get_public($validation->credential->public_key); + $signature = $this->retrieveSignature($validation->request); + $verifiable = $this->retrieveBinaryVerifiable($validation->request); - if (!$publicKey) { - throw AssertionException::make('Stored Public Key is invalid.'); + if ($this->challengeRequiresSodium($signature, $validation->credential)) { + $this->validateWithSodium($signature, $verifiable, $validation->credential); + } else { + $this->validateWithOpenSsl($signature, $verifiable, $validation->credential); } - $signature = base64_decode($validation->request->json('response.signature', '')); + return $next($validation); + } - if (!$signature) { - throw AssertionException::make('Signature is empty.'); - } + /** + * Retrieves the signature of the data created by the authenticator. + */ + protected function retrieveSignature(Request $request): string + { + $signature = base64_decode($request->json('response.signature', '')); - $this->validateSignature($validation, $publicKey, $signature); + return $signature + ?: throw AssertionException::make('Signature is empty.'); + } - return $next($validation); + /** + * Returns the binary representation of the authenticator and client data from the authenticator. + */ + protected function retrieveBinaryVerifiable(Request $request): string + { + $verifiable = base64_decode($request->json('response.authenticatorData')). + hash('sha256', base64_decode($request->json('response.clientDataJSON')), true); + + return $verifiable + ?: throw AssertionException::make('Authenticator Data or Client Data JSON are empty or malformed.'); } /** - * Validate the signature from the assertion. + * Check if the challenge is to be verified by an EdDSA 25519 public key. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param string $signature - * @param \OpenSSLAsymmetricKey $publicKey - * @return void - * @throws \Laragear\WebAuthn\Exceptions\AssertionException + * @see https://www.rfc-editor.org/rfc/rfc8410#section-10.1 */ - public function validateSignature( - AssertionValidation $validation, - OpenSSLAsymmetricKey $publicKey, - string $signature - ): void { - $verifiable = base64_decode($validation->request->json('response.authenticatorData')) - .hash('sha256', base64_decode($validation->request->json('response.clientDataJSON')), true); + protected function challengeRequiresSodium(string $signature, WebAuthnCredential $credential): bool + { + return function_exists('sodium_crypto_sign_verify_detached') + // Double ensure the signature has the given key length. + && 64 === strlen($signature) + // Double-ensure the key has the length of a EdDSA 25519 public key. + && in_array($this->extractKey($credential)->length(), [44, 60], true); + } - if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + /** + * Extracts the public key data from a PEM and returns it without header and footer. + */ + protected function extractKey(WebAuthnCredential $credential): Stringable + { + return Str::of($credential->public_key) + ->between('-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----') + ->trim(); + } + + /** + * Try to validate the WebAuthn data with Sodium (if installed). + */ + protected function validateWithSodium(string $signature, string $verifiable, WebAuthnCredential $credential): void + { + try { + $valid = \sodium_crypto_sign_verify_detached( + // Remove any header from the public key, as the key is on the tail. + $signature, $verifiable, base64_decode($this->extractKey($credential)->substr(-44)) + ); + } catch (\SodiumException $e) { + throw AssertionException::make($e->getMessage()); + } + + if (!$valid) { throw AssertionException::make('Signature is invalid.'); } } + + /** + * Try to validate the WebAuthn data with OpenSSL. + */ + protected function validateWithOpenSsl(string $signature, string $verifiable, WebAuthnCredential $credential): void + { + if (!$publicKey = openssl_pkey_get_public($credential->public_key)) { + throw AssertionException::make('Public key is invalid: ' . openssl_error_string()); + } + + if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw AssertionException::make('Signature is invalid: ' . openssl_error_string()); + } + } } diff --git a/src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php b/src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php index eadc1b5..345f94f 100644 --- a/src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php +++ b/src/Assertion/Validator/Pipes/CheckRelyingPartyHashSame.php @@ -14,9 +14,6 @@ class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame { /** * Return the Attestation data to check the RP ID Hash. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return \Laragear\WebAuthn\Attestation\AuthenticatorData */ protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData { @@ -25,9 +22,6 @@ protected function authenticatorData(AssertionValidation|AttestationValidation $ /** * Return the Relying Party ID from the config or credential. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @return string */ protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string { diff --git a/src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php b/src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php index 5299f15..15d7599 100644 --- a/src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php +++ b/src/Assertion/Validator/Pipes/CheckTypeIsPublicKey.php @@ -14,9 +14,6 @@ class CheckTypeIsPublicKey /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed diff --git a/src/Assertion/Validator/Pipes/CompileAuthenticatorData.php b/src/Assertion/Validator/Pipes/CompileAuthenticatorData.php index 62150c2..efac545 100644 --- a/src/Assertion/Validator/Pipes/CompileAuthenticatorData.php +++ b/src/Assertion/Validator/Pipes/CompileAuthenticatorData.php @@ -17,9 +17,6 @@ class CompileAuthenticatorData /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed diff --git a/src/Assertion/Validator/Pipes/IncrementCredentialCounter.php b/src/Assertion/Validator/Pipes/IncrementCredentialCounter.php index 70bd560..c5f1ea6 100644 --- a/src/Assertion/Validator/Pipes/IncrementCredentialCounter.php +++ b/src/Assertion/Validator/Pipes/IncrementCredentialCounter.php @@ -25,10 +25,6 @@ class IncrementCredentialCounter { /** * Handle the incoming Assertion Validation. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed */ public function handle(AssertionValidation $validation, Closure $next): mixed { diff --git a/src/Assertion/Validator/Pipes/RetrievesCredentialId.php b/src/Assertion/Validator/Pipes/RetrievesCredentialId.php index c3aed8f..7838663 100644 --- a/src/Assertion/Validator/Pipes/RetrievesCredentialId.php +++ b/src/Assertion/Validator/Pipes/RetrievesCredentialId.php @@ -16,9 +16,6 @@ class RetrievesCredentialId /** * Handle the incoming Assertion Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ public function handle(AssertionValidation $validation, Closure $next): mixed @@ -47,10 +44,6 @@ public function handle(AssertionValidation $validation, Closure $next): mixed /** * Check if the previous Assertion request specified a credentials list to accept. - * - * @param string $id - * @param array $properties - * @return bool */ protected function credentialNotInChallenge(string $id, array $properties): bool { diff --git a/src/Attestation/AttestationObject.php b/src/Attestation/AttestationObject.php index 3741153..32cdb88 100644 --- a/src/Attestation/AttestationObject.php +++ b/src/Attestation/AttestationObject.php @@ -11,16 +11,12 @@ class AttestationObject { /** * Create a new Attestation Object. - * - * @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData - * @param \Laragear\WebAuthn\Attestation\Formats\Format $format - * @param string $formatName */ public function __construct( public AuthenticatorData $authenticatorData, public Format $format, - public string $formatName) - { + public string $formatName + ) { // } } diff --git a/src/Attestation/AuthenticatorData.php b/src/Attestation/AuthenticatorData.php index 02219ad..e187358 100644 --- a/src/Attestation/AuthenticatorData.php +++ b/src/Attestation/AuthenticatorData.php @@ -51,33 +51,34 @@ class AuthenticatorData { // COSE encoded keys - protected static int $COSE_KTY = 1; - protected static int $COSE_ALG = 3; + protected const COSE_KTY = 1; + protected const COSE_ALG = 3; // COSE EC2 ES256 P-256 curve - protected static int $COSE_CRV = -1; - protected static int $COSE_X = -2; - protected static int $COSE_Y = -3; + protected const COSE_CRV = -1; + protected const COSE_X = -2; + protected const COSE_Y = -3; // COSE RSA PS256 - protected static int $COSE_N = -1; - protected static int $COSE_E = -2; + protected const COSE_N = -1; + protected const COSE_E = -2; - protected static int $EC2_TYPE = 2; - protected static int $EC2_ES256 = -7; - protected static int $EC2_P256 = 1; + // EC2 + protected const EC2_TYPE = 2; + protected const EC2_P256 = 1; + public const EC2_ES256 = -7; - protected static int $RSA_TYPE = 3; - protected static int $RSA_RS256 = -257; + // RSA + protected const RSA_TYPE = 3; + public const RSA_RS256 = -257; + + // OKP + protected const OKP_TYPE = 1; + protected const OKP_ED25519 = 6; + public const OKP_EDDSA = -8; /** * Creates a new Authenticator Data instance from a binary string. - * - * @param string $relyingPartyIdHash - * @param object $flags - * @param int $counter - * @param object $attestedCredentialData - * @param array $extensionData */ final public function __construct( public string $relyingPartyIdHash, @@ -93,10 +94,6 @@ final public function __construct( /** * Checks if the Relying Party ID hash is the same as the one issued. - * - * @param string $relyingPartyId - * @param bool $hash - * @return bool */ public function hasSameRPIdHash(string $relyingPartyId, bool $hash = true): bool { @@ -109,10 +106,6 @@ public function hasSameRPIdHash(string $relyingPartyId, bool $hash = true): bool /** * Checks if the Relying Party ID hash is not the same as the one issued. - * - * @param string $relyingPartyId - * @param bool $hash - * @return bool */ public function hasNotSameRPIdHash(string $relyingPartyId, bool $hash = true): bool { @@ -121,8 +114,6 @@ public function hasNotSameRPIdHash(string $relyingPartyId, bool $hash = true): b /** * Check if the user was present during the authentication. - * - * @return bool */ public function wasUserPresent(): bool { @@ -131,8 +122,6 @@ public function wasUserPresent(): bool /** * Check if the user was absent during the authentication. - * - * @return bool */ public function wasUserAbsent(): bool { @@ -141,8 +130,6 @@ public function wasUserAbsent(): bool /** * Check if the user was actively verified by the authenticator. - * - * @return bool */ public function wasUserVerified(): bool { @@ -151,8 +138,6 @@ public function wasUserVerified(): bool /** * Check if the user was not actively verified by the authenticator. - * - * @return bool */ public function wasUserNotVerified(): bool { @@ -161,29 +146,25 @@ public function wasUserNotVerified(): bool /** * Returns the public key in PEM format. - * - * @return string - * @throws \Laragear\WebAuthn\Exceptions\DataException */ public function getPublicKeyPem(): string { $der = match ($this->attestedCredentialData->credentialPublicKey->kty) { - self::$EC2_TYPE => $this->getEc2Der(), - self::$RSA_TYPE => $this->getRsaDer(), + static::EC2_TYPE => $this->getEc2Der(), + static::RSA_TYPE => $this->getRsaDer(), + static::OKP_TYPE => $this->getOkpDer(), default => throw new DataException('Invalid credential public key type [kty].'), }; - $pem = '-----BEGIN PUBLIC KEY-----'."\n"; - $pem .= chunk_split(base64_encode($der), 64, "\n"); - $pem .= '-----END PUBLIC KEY-----'."\n"; - - return $pem; + return + '-----BEGIN PUBLIC KEY-----'. + "\n".chunk_split(base64_encode($der), 64, "\n"). + '-----END PUBLIC KEY-----'. + "\n"; } /** * Returns the public key in U2F format. - * - * @return string */ public function getPublicKeyU2F(): string { @@ -193,9 +174,7 @@ public function getPublicKeyU2F(): string } /** - * Returns DER encoded EC2 key - * - * @return string + * Returns DER encoded EC2 key. */ protected function getEc2Der(): string { @@ -210,8 +189,6 @@ protected function getEc2Der(): string /** * Returns DER encoded RSA key. - * - * @return string */ protected function getRsaDer(): string { @@ -229,11 +206,21 @@ protected function getRsaDer(): string ); } + /** + * Returns KPE encoded EdDSA key. + */ + protected function getOkpDer(): string + { + return $this->derSequence( + $this->derSequence( + $this->derOid("\x2B\x65\x70") // OID 1.3.101.112 curveEd25519 (EdDSA 25519 signature algorithm) + ). + $this->derBitString($this->attestedCredentialData->credentialPublicKey->x) + ); + } + /** * Returns the length of a DER encoded string. - * - * @param int $der - * @return string */ protected function derLength(int $der): string { @@ -253,9 +240,6 @@ protected function derLength(int $der): string /** * Encode a string as DER. - * - * @param string $contents - * @return string */ protected function derSequence(string $contents): string { @@ -264,9 +248,6 @@ protected function derSequence(string $contents): string /** * Encode something an ID of zero as DER. - * - * @param string $encoded - * @return string */ protected function derOid(string $encoded): string { @@ -275,9 +256,6 @@ protected function derOid(string $encoded): string /** * Encode the bit string as DER. - * - * @param string $bytes - * @return string */ protected function derBitString(string $bytes): string { @@ -286,8 +264,6 @@ protected function derBitString(string $bytes): string /** * Encode a null value as DER. - * - * @return string */ protected function derNullValue(): string { @@ -296,9 +272,6 @@ protected function derNullValue(): string /** * Encode a unsigned integer as DER. - * - * @param string $bytes - * @return string */ protected function derUnsignedInteger(string $bytes): string { @@ -325,9 +298,6 @@ protected function derUnsignedInteger(string $bytes): string /** * Create a new Authenticator data from a binary string. * - * @param string $binary - * @return static - * @throws \Laragear\WebAuthn\Exceptions\DataException * @codeCoverageIgnore */ public static function fromBinary(string $binary): static @@ -360,7 +330,6 @@ public static function fromBinary(string $binary): static /** * Reads the flags from flag byte array. * - * @param string $binFlag * @return object{userPresent: bool, userVerified: bool, attestedDataIncluded: bool, extensionDataIncluded: bool} */ protected static function readFlags(string $binFlag): object @@ -400,10 +369,7 @@ protected static function readFlags(string $binFlag): object /** * Reads the attestation data. * - * @param string $binary - * @param int $endOffset * @return object{aaguid: string, credentialId: \Laragear\WebAuthn\ByteBuffer, credentialPublicKey: object}&\stdClass - * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function readAttestData(string $binary, int &$endOffset): object { @@ -414,7 +380,7 @@ protected static function readAttestData(string $binary, int &$endOffset): objec // Byte length L of Credential ID, 16-bit unsigned big-endian integer. $length = unpack('nlength', substr($binary, 53, 2))['length']; - // Set end offset + // Set end offset. $endOffset = 55 + $length; return (object) [ @@ -426,12 +392,6 @@ protected static function readAttestData(string $binary, int &$endOffset): objec /** * Read COSE key-encoded elliptic curve public key in EC2 format. - * - * @param string $binary - * @param int $offset - * @param int $endOffset - * @return object - * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function readCredentialPublicKey(string $binary, int $offset, int &$endOffset): object { @@ -439,45 +399,38 @@ protected static function readCredentialPublicKey(string $binary, int $offset, i // COSE key-encoded elliptic curve public key in EC2 format $publicKey = (object) [ - 'kty' => $enc[static::$COSE_KTY], - 'alg' => $enc[static::$COSE_ALG] + 'kty' => $enc[static::COSE_KTY], + 'alg' => $enc[static::COSE_ALG] ]; - switch ($publicKey->alg) { - case static::$EC2_ES256: - static::readCredentialPublicKeyES256($publicKey, $enc); - break; - case static::$RSA_RS256: - static::readCredentialPublicKeyRS256($publicKey, $enc); - break; - } + match ($publicKey->alg) { + static::EC2_ES256 => static::readCredentialPublicKeyES256($publicKey, $enc), + static::RSA_RS256 => static::readCredentialPublicKeyRS256($publicKey, $enc), + static::OKP_EDDSA => static::readCredentialPublicKeyEDDSA($publicKey, $enc), + default => throw new DataException("The $publicKey->alg algorithm is not supported.") + }; return $publicKey; } /** * Extracts ES256 information from COSE encoding. - * - * @param object $publicKey - * @param array $cose - * @return object - * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function readCredentialPublicKeyES256(object $publicKey, array $cose): object { - $publicKey->crv = $cose[self::$COSE_CRV]; - $publicKey->x = $cose[self::$COSE_X] instanceof ByteBuffer ? $cose[self::$COSE_X]->getBinaryString() : null; - $publicKey->y = $cose[self::$COSE_Y] instanceof ByteBuffer ? $cose[self::$COSE_Y]->getBinaryString() : null; + $publicKey->crv = $cose[static::COSE_CRV]; + $publicKey->x = $cose[static::COSE_X] instanceof ByteBuffer ? $cose[static::COSE_X]->getBinaryString() : null; + $publicKey->y = $cose[static::COSE_Y] instanceof ByteBuffer ? $cose[static::COSE_Y]->getBinaryString() : null; - if ($publicKey->kty !== self::$EC2_TYPE) { + if ($publicKey->kty !== static::EC2_TYPE) { throw new DataException('Public key not in EC2 format'); } - if ($publicKey->alg !== self::$EC2_ES256) { + if ($publicKey->alg !== static::EC2_ES256) { throw new DataException('Signature algorithm not ES256'); } - if ($publicKey->crv !== self::$EC2_P256) { + if ($publicKey->crv !== static::EC2_P256) { throw new DataException('Curve not P-256'); } @@ -494,22 +447,17 @@ protected static function readCredentialPublicKeyES256(object $publicKey, array /** * Extract RS256 information from COSE. - * - * @param object $publicKey - * @param array $enc - * @return void - * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function readCredentialPublicKeyRS256(object $publicKey, array $enc): void { - $publicKey->n = $enc[self::$COSE_N] instanceof ByteBuffer ? $enc[self::$COSE_N]->getBinaryString() : null; - $publicKey->e = $enc[self::$COSE_E] instanceof ByteBuffer ? $enc[self::$COSE_E]->getBinaryString() : null; + $publicKey->n = $enc[static::COSE_N] instanceof ByteBuffer ? $enc[static::COSE_N]->getBinaryString() : null; + $publicKey->e = $enc[static::COSE_E] instanceof ByteBuffer ? $enc[static::COSE_E]->getBinaryString() : null; - if ($publicKey->kty !== self::$RSA_TYPE) { + if ($publicKey->kty !== static::RSA_TYPE) { throw new DataException('Public key not in RSA format'); } - if ($publicKey->alg !== self::$RSA_RS256) { + if ($publicKey->alg !== static::RSA_RS256) { throw new DataException('Signature algorithm not ES256'); } @@ -522,12 +470,33 @@ protected static function readCredentialPublicKeyRS256(object $publicKey, array } } + /** + * Extract EdDSA information from COSE. + */ + protected static function readCredentialPublicKeyEDDSA(object $publicKey, array $enc): void + { + $publicKey->crv = $enc[static::COSE_CRV]; + $publicKey->x = $enc[static::COSE_X] instanceof ByteBuffer ? $enc[static::COSE_X]->getBinaryString() : null; + + if ($publicKey->kty !== static::OKP_TYPE) { + throw new DataException('Public key not in OKP format'); + } + + if ($publicKey->alg !== static::OKP_EDDSA) { + throw new DataException('Signature algorithm not EdDSA'); + } + + if ($publicKey->crv !== static::OKP_ED25519) { + throw new DataException('Curve not Ed25519'); + } + + if (strlen($publicKey->x) !== 32) { + throw new DataException('Invalid X coordinate for Ed25519 curve.'); + } + } + /** * Reads CBOR encoded extension data. - * - * @param string $binary - * @return array - * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function readExtensionData(string $binary): array { diff --git a/src/Attestation/Creator/AttestationCreation.php b/src/Attestation/Creator/AttestationCreation.php index e63bfa0..fa146db 100644 --- a/src/Attestation/Creator/AttestationCreation.php +++ b/src/Attestation/Creator/AttestationCreation.php @@ -4,37 +4,23 @@ use Illuminate\Http\Request; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; +use Laragear\WebAuthn\Enums\ResidentKey; +use Laragear\WebAuthn\Enums\UserVerification; use Laragear\WebAuthn\JsonTransport; class AttestationCreation { - - public const ATTACHMENT_CROSS_PLATFORM = 'cross-platform'; - public const ATTACHMENT_PLATFORM = 'platform'; - - /** - * The underlying JSON representation of the Assertion Challenge. - * - * @var \Laragear\WebAuthn\JsonTransport - */ - public JsonTransport $json; - /** * Create a new Attestation Instructions instance. - * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user - * @param \Illuminate\Http\Request $request - * @param string|null $residentKey - * @param string|null $userVerification - * @param bool $uniqueCredentials */ public function __construct( public WebAuthnAuthenticatable $user, public Request $request, - public ?string $residentKey = null, - public ?string $userVerification = null, + public ?ResidentKey $residentKey = null, + public ?UserVerification $userVerification = null, public bool $uniqueCredentials = true, + public JsonTransport $json = new JsonTransport() ) { - $this->json = new JsonTransport(); + // } } diff --git a/src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php b/src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php index ada7d81..9e4b451 100644 --- a/src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php +++ b/src/Attestation/Creator/Pipes/AddAcceptedAlgorithms.php @@ -3,7 +3,9 @@ namespace Laragear\WebAuthn\Attestation\Creator\Pipes; use Closure; +use Laragear\WebAuthn\Attestation\AuthenticatorData; use Laragear\WebAuthn\Attestation\Creator\AttestationCreation; +use function function_exists; /** * @internal @@ -12,17 +14,22 @@ class AddAcceptedAlgorithms { /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { - $attestable->json->set('pubKeyCredParams', [ - ['type' => 'public-key', 'alg' => -7], - ['type' => 'public-key', 'alg' => -257], - ]); + // Here we set the supported algorithms to sign and check challenges. The + // authenticator will pick one of these, create the public key, and tell + // which type the key is. This way we can later validate the challenge. + $algorithms = [ + AuthenticatorData::EC2_ES256, + AuthenticatorData::RSA_RS256 + ]; + + if ($this->isSupportingEd25519()) { + $algorithms[] = AuthenticatorData::OKP_EDDSA; + } + + $attestable->json->set('pubKeyCredParams', $this->fillAlgorithms($algorithms)); // Currently we don't support direct attestation. In other words, it won't ask // for attestation data from the authenticator to cross-check later against @@ -31,4 +38,30 @@ public function handle(AttestationCreation $attestable, Closure $next): mixed return $next($attestable); } + + /** + * Check if the server supports Ed25519 curves for public keys. + * + * @return bool + */ + protected function isSupportingEd25519(): bool + { + // Check if the sodium function is available. + return function_exists('sodium_crypto_sign_verify_detached'); + } + + /** + * Transform the supported algorithms to an array the authenticator can understand. + * + * @param int[] ...$algorithms + * @return array + */ + protected function fillAlgorithms(array $algorithms): array + { + foreach ($algorithms as $key => $algorithm) { + $algorithms[$key] = ['type' => 'public-key', 'alg' => $algorithm]; + } + + return $algorithms; + } } diff --git a/src/Attestation/Creator/Pipes/AddRelyingParty.php b/src/Attestation/Creator/Pipes/AddRelyingParty.php index edec1a0..68259d1 100644 --- a/src/Attestation/Creator/Pipes/AddRelyingParty.php +++ b/src/Attestation/Creator/Pipes/AddRelyingParty.php @@ -13,8 +13,6 @@ class AddRelyingParty { /** * Create a new pipe instance. - * - * @param \Illuminate\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -23,10 +21,6 @@ public function __construct(protected Repository $config) /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { diff --git a/src/Attestation/Creator/Pipes/AddUserDescriptor.php b/src/Attestation/Creator/Pipes/AddUserDescriptor.php index d1ba4d3..893b158 100644 --- a/src/Attestation/Creator/Pipes/AddUserDescriptor.php +++ b/src/Attestation/Creator/Pipes/AddUserDescriptor.php @@ -3,7 +3,6 @@ namespace Laragear\WebAuthn\Attestation\Creator\Pipes; use Closure; -use Illuminate\Support\Str; use Laragear\WebAuthn\Attestation\Creator\AttestationCreation; /** @@ -13,21 +12,16 @@ class AddUserDescriptor { /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { - $config = $attestable->user->webAuthnData(); + // Try to find the User Handle (user_id) to reuse it on the new credential. + $existingId = $attestable->user->webAuthnCredentials()->getQuery()->value('user_id'); - // Create a new User UUID if it doesn't existe already in the credentials. - // @phpstan-ignore-next-line - $config['id'] = $attestable->user->webAuthnCredentials()->value('user_id') - ?: Str::uuid()->getHex()->toString(); - - $attestable->json->set('user', $config); + $attestable->json->set('user', [ + 'id' => $existingId ?: $attestable->user->webAuthnId()->getHex()->toString(), + ...$attestable->user->webAuthnData(), + ]); return $next($attestable); } diff --git a/src/Attestation/Creator/Pipes/CreateAttestationChallenge.php b/src/Attestation/Creator/Pipes/CreateAttestationChallenge.php index d6c4b09..a88ca52 100644 --- a/src/Attestation/Creator/Pipes/CreateAttestationChallenge.php +++ b/src/Attestation/Creator/Pipes/CreateAttestationChallenge.php @@ -17,9 +17,6 @@ class CreateAttestationChallenge /** * Create a new pipe instance. - * - * @param \Illuminate\Config\Repository $config - * @param \Illuminate\Contracts\Cache\Factory $cache */ public function __construct(protected Repository $config, protected Factory $cache) { @@ -29,9 +26,7 @@ public function __construct(protected Repository $config, protected Factory $cac /** * Handle the Attestation creation * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed + * @throws \Random\RandomException */ public function handle(AttestationCreation $attestable, Closure $next): mixed { diff --git a/src/Attestation/Creator/Pipes/MayPreventDuplicateCredentials.php b/src/Attestation/Creator/Pipes/MayPreventDuplicateCredentials.php index 4f90068..52a3a08 100644 --- a/src/Attestation/Creator/Pipes/MayPreventDuplicateCredentials.php +++ b/src/Attestation/Creator/Pipes/MayPreventDuplicateCredentials.php @@ -14,10 +14,6 @@ class MayPreventDuplicateCredentials { /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { @@ -30,9 +26,6 @@ public function handle(AttestationCreation $attestable, Closure $next): mixed /** * Returns a collection of credentials ready to be inserted into the Attestable JSON. - * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user - * @return array */ protected function credentials(WebAuthnAuthenticatable $user): array { diff --git a/src/Attestation/Creator/Pipes/MayRequireUserVerification.php b/src/Attestation/Creator/Pipes/MayRequireUserVerification.php index 41a67d7..bcdcb8f 100644 --- a/src/Attestation/Creator/Pipes/MayRequireUserVerification.php +++ b/src/Attestation/Creator/Pipes/MayRequireUserVerification.php @@ -12,15 +12,11 @@ class MayRequireUserVerification { /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { if ($attestable->userVerification) { - $attestable->json->set('authenticatorSelection.userVerification', $attestable->userVerification); + $attestable->json->set('authenticatorSelection.userVerification', $attestable->userVerification->value); } return $next($attestable); diff --git a/src/Attestation/Creator/Pipes/SetResidentKeyConfiguration.php b/src/Attestation/Creator/Pipes/SetResidentKeyConfiguration.php index e6e6759..3a9bf19 100644 --- a/src/Attestation/Creator/Pipes/SetResidentKeyConfiguration.php +++ b/src/Attestation/Creator/Pipes/SetResidentKeyConfiguration.php @@ -4,7 +4,8 @@ use Closure; use Laragear\WebAuthn\Attestation\Creator\AttestationCreation; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Enums\ResidentKey; +use Laragear\WebAuthn\Enums\UserVerification; /** * @internal @@ -13,22 +14,18 @@ class SetResidentKeyConfiguration { /** * Handle the Attestation creation - * - * @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable - * @param \Closure $next - * @return mixed */ public function handle(AttestationCreation $attestable, Closure $next): mixed { if ($attestable->residentKey) { - $attestable->json->set('authenticatorSelection.residentKey', $attestable->residentKey); + $attestable->json->set('authenticatorSelection.residentKey', $attestable->residentKey->value); - $verifiesUser = $attestable->residentKey === WebAuthn::RESIDENT_KEY_REQUIRED; + $verifiesUser = $attestable->residentKey === ResidentKey::REQUIRED; $attestable->json->set('authenticatorSelection.requireResidentKey', $verifiesUser); if ($verifiesUser) { - $attestable->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED; + $attestable->userVerification = UserVerification::REQUIRED; } } diff --git a/src/Attestation/Formats/Format.php b/src/Attestation/Formats/Format.php index 2c64fd7..a4f8fb7 100644 --- a/src/Attestation/Formats/Format.php +++ b/src/Attestation/Formats/Format.php @@ -44,7 +44,6 @@ abstract class Format * Create a new Attestation Format. * * @param array{fmt: string, attStmt: array, authData: \Laragear\WebAuthn\ByteBuffer} $attestationObject - * @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData */ public function __construct(public array $attestationObject, public AuthenticatorData $authenticatorData) { diff --git a/src/Attestation/Formats/None.php b/src/Attestation/Formats/None.php index 3b7f9f3..9b8bbc9 100644 --- a/src/Attestation/Formats/None.php +++ b/src/Attestation/Formats/None.php @@ -8,5 +8,5 @@ */ class None extends Format { - + // } diff --git a/src/Attestation/SessionChallenge.php b/src/Attestation/SessionChallenge.php index ac99f84..6d4f9c8 100644 --- a/src/Attestation/SessionChallenge.php +++ b/src/Attestation/SessionChallenge.php @@ -4,19 +4,17 @@ use Illuminate\Http\Request; use Laragear\WebAuthn\Challenge; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Enums\UserVerification; trait SessionChallenge { /** * Stores an Attestation challenge into the Cache. * - * @param \Illuminate\Http\Request $request - * @param string|null $verify - * @param array $options - * @return \Laragear\WebAuthn\Challenge + * @param array{credentials?: string[]}|array{user_uuid: string, user_handle: string} $options + * @throws \Random\RandomException */ - protected function storeChallenge(Request $request, ?string $verify, array $options = []): Challenge + protected function storeChallenge(Request $request, ?UserVerification $verify, array $options = []): Challenge { $challenge = $this->createChallenge($verify, $options); @@ -28,16 +26,15 @@ protected function storeChallenge(Request $request, ?string $verify, array $opti /** * Creates a Challenge using the default timeout. * - * @param string|null $verify - * @param array $options - * @return \Laragear\WebAuthn\Challenge + * @param array{credentials?: string[]}|array{user_uuid: string, user_handle: string} $options + * @throws \Random\RandomException */ - protected function createChallenge(?string $verify, array $options = []): Challenge + protected function createChallenge(?UserVerification $verify, array $options = []): Challenge { return Challenge::random( $this->config->get('webauthn.challenge.bytes'), $this->config->get('webauthn.challenge.timeout'), - $verify === WebAuthn::USER_VERIFICATION_REQUIRED, + $verify === UserVerification::REQUIRED, $options, ); } diff --git a/src/Attestation/Validator/AttestationValidation.php b/src/Attestation/Validator/AttestationValidation.php index 550e4ac..5102d47 100644 --- a/src/Attestation/Validator/AttestationValidation.php +++ b/src/Attestation/Validator/AttestationValidation.php @@ -13,13 +13,6 @@ class AttestationValidation { /** * Create a new Attestation Validation procedure - * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user - * @param \Illuminate\Http\Request $request - * @param \Laragear\WebAuthn\Challenge|null $challenge - * @param \Laragear\WebAuthn\Attestation\AttestationObject|null $attestationObject - * @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson - * @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential */ public function __construct( public WebAuthnAuthenticatable $user, diff --git a/src/Attestation/Validator/Pipes/AttestationIsForCreation.php b/src/Attestation/Validator/Pipes/AttestationIsForCreation.php index b505acb..edf04c6 100644 --- a/src/Attestation/Validator/Pipes/AttestationIsForCreation.php +++ b/src/Attestation/Validator/Pipes/AttestationIsForCreation.php @@ -18,9 +18,6 @@ class AttestationIsForCreation /** * Handle the incoming Attestation Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ public function handle(AttestationValidation $validation, Closure $next): mixed diff --git a/src/Attestation/Validator/Pipes/CheckRelyingPartyHashSame.php b/src/Attestation/Validator/Pipes/CheckRelyingPartyHashSame.php index 05b023e..4f681ad 100644 --- a/src/Attestation/Validator/Pipes/CheckRelyingPartyHashSame.php +++ b/src/Attestation/Validator/Pipes/CheckRelyingPartyHashSame.php @@ -20,9 +20,6 @@ class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame { /** * Return the Attestation data to check the RP ID Hash. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return \Laragear\WebAuthn\Attestation\AuthenticatorData */ protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData { @@ -31,9 +28,6 @@ protected function authenticatorData(AssertionValidation|AttestationValidation $ /** * Return the Relying Party ID from the config or credential. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @return string */ protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string { diff --git a/src/Attestation/Validator/Pipes/CompileAttestationObject.php b/src/Attestation/Validator/Pipes/CompileAttestationObject.php index af04738..3da708c 100644 --- a/src/Attestation/Validator/Pipes/CompileAttestationObject.php +++ b/src/Attestation/Validator/Pipes/CompileAttestationObject.php @@ -33,18 +33,15 @@ class CompileAttestationObject /** * Handle the incoming Attestation Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ public function handle(AttestationValidation $validation, Closure $next): mixed { $data = $this->decodeCborBase64($validation->request); - // Here we would receive the attestation formats and decode them. Since we're - // only support the universal "none" we can just check if it's equal or not. - // Later we may support multiple authenticator formats through a PHP match. + // Here we would receive the attestation formats and decode them. Since we are only + // supporting the universal "none" format, we can just check if it's equal or not. + // Who knows if later we may support multiple formats through a simple PHP match. if ($data['fmt'] !== 'none') { throw AttestationException::make("Format name [{$data['fmt']}] is invalid."); } @@ -65,7 +62,6 @@ public function handle(AttestationValidation $validation, Closure $next): mixed /** * Returns an array map from a BASE64 encoded CBOR string. * - * @param \Illuminate\Http\Request $request * @return array{fmt: string, attStmt: array, authData: \Laragear\WebAuthn\ByteBuffer} * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ diff --git a/src/Attestation/Validator/Pipes/CredentialIdShouldNotBeDuplicated.php b/src/Attestation/Validator/Pipes/CredentialIdShouldNotBeDuplicated.php index afd3edf..ed5d6be 100644 --- a/src/Attestation/Validator/Pipes/CredentialIdShouldNotBeDuplicated.php +++ b/src/Attestation/Validator/Pipes/CredentialIdShouldNotBeDuplicated.php @@ -15,9 +15,6 @@ class CredentialIdShouldNotBeDuplicated /** * Handle the incoming Attestation Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ public function handle(AttestationValidation $validation, Closure $next): mixed @@ -31,9 +28,6 @@ public function handle(AttestationValidation $validation, Closure $next): mixed /** * Finds a WebAuthn Credential by the issued ID. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @return bool */ protected function credentialAlreadyExists(AttestationValidation $validation): bool { diff --git a/src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php b/src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php index 22b5ebb..9df816b 100644 --- a/src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php +++ b/src/Attestation/Validator/Pipes/MakeWebAuthnCredential.php @@ -18,8 +18,6 @@ class MakeWebAuthnCredential { /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -29,9 +27,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming Attestation Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ public function handle(AttestationValidation $validation, Closure $next): mixed @@ -57,9 +52,6 @@ public function handle(AttestationValidation $validation, Closure $next): mixed /** * Returns a public key from the credentials as a PEM string. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @return string */ protected function getPublicKeyAsPem(AttestationValidation $validation): string { diff --git a/src/Auth/WebAuthnUserProvider.php b/src/Auth/WebAuthnUserProvider.php index 7ef4566..cf06277 100644 --- a/src/Auth/WebAuthnUserProvider.php +++ b/src/Auth/WebAuthnUserProvider.php @@ -3,6 +3,7 @@ namespace Laragear\WebAuthn\Auth; use Illuminate\Auth\EloquentUserProvider; +use Illuminate\Contracts\Auth\Authenticatable as UserContract; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Hashing\Hasher as HasherContract; use Laragear\WebAuthn\Assertion\Validator\AssertionValidation; @@ -24,11 +25,6 @@ class WebAuthnUserProvider extends EloquentUserProvider { /** * Create a new database user provider. - * - * @param \Illuminate\Contracts\Hashing\Hasher $hasher - * @param string $model - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidator $validator - * @param bool $fallback */ public function __construct( HasherContract $hasher, @@ -42,7 +38,6 @@ public function __construct( /** * Retrieve a user by the given credentials. * - * @param array $credentials * @return \Illuminate\Contracts\Auth\Authenticatable|null */ public function retrieveByCredentials(array $credentials) @@ -75,9 +70,6 @@ protected function userIsWebAuthnAuthenticatable(): bool /** * Check if the credentials are for a public key signed challenge - * - * @param array $credentials - * @return bool */ protected function isSignedChallenge(array $credentials): bool { @@ -88,9 +80,6 @@ protected function isSignedChallenge(array $credentials): bool * Validate a user against the given credentials. * * @param \Illuminate\Contracts\Auth\Authenticatable|\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user - * @param array $credentials - * - * @return bool */ public function validateCredentials($user, array $credentials): bool { @@ -104,16 +93,13 @@ public function validateCredentials($user, array $credentials): bool /** * Validate the WebAuthn assertion. - * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user - * @return bool */ protected function validateWebAuthn(WebAuthnAuthenticatable $user): bool { try { // When we hit this method, we already have the user for the credential, so we will // pass it to the Assertion Validation data, thus avoiding fetching it again. - $this->validator->send(new AssertionValidation(request(), user: $user))->thenReturn(); + $this->validator->send(new AssertionValidation(request(), $user))->thenReturn(); } catch (AssertionException $e) { // If we're debugging, like under local development, push the error to the logger. if (config('app.debug')) { @@ -125,4 +111,14 @@ protected function validateWebAuthn(WebAuthnAuthenticatable $user): bool return true; } + + /** + * Rehash the user's password if required and supported. + */ + public function rehashPasswordIfRequired(UserContract $user, array $credentials, bool $force = false): void + { + if (! $this->isSignedChallenge($credentials)) { + parent::rehashPasswordIfRequired($user, $credentials, $force); + } + } } diff --git a/src/ByteBuffer.php b/src/ByteBuffer.php index 849a412..9559bdf 100644 --- a/src/ByteBuffer.php +++ b/src/ByteBuffer.php @@ -81,9 +81,6 @@ class ByteBuffer implements JsonSerializable, Jsonable, Stringable { /** * Create a new ByteBuffer - * - * @param string $binaryData - * @param int $dataLength */ final public function __construct(protected string $binaryData, protected int $dataLength = 0) { @@ -92,8 +89,6 @@ final public function __construct(protected string $binaryData, protected int $d /** * Returns the length of the ByteBuffer data. - * - * @return int */ public function getDataLength(): int { @@ -102,8 +97,6 @@ public function getDataLength(): int /** * Check if the length of the data is greater than zero. - * - * @return bool */ public function hasLength(): bool { @@ -112,8 +105,6 @@ public function hasLength(): bool /** * Check if the length of the data is zero. - * - * @return bool */ public function hasNoLength(): bool { @@ -122,8 +113,6 @@ public function hasNoLength(): bool /** * Returns the binary string verbatim. - * - * @return string */ public function getBinaryString(): string { @@ -132,9 +121,6 @@ public function getBinaryString(): string /** * Check if both Byte Buffers are equal using `hash_equals`. - * - * @param \Laragear\WebAuthn\ByteBuffer|string $buffer - * @return bool */ public function hashEqual(self|string $buffer): bool { @@ -147,9 +133,6 @@ public function hashEqual(self|string $buffer): bool /** * Check if both Byte Buffers are not equal using `hash_equals`. - * - * @param \Laragear\WebAuthn\ByteBuffer|string $buffer - * @return bool */ public function hashNotEqual(self|string $buffer): bool { @@ -158,10 +141,6 @@ public function hashNotEqual(self|string $buffer): bool /** * Returns a certain portion of these bytes. - * - * @param int $offset - * @param int|null $length - * @return string */ public function getBytes(int $offset = 0, int $length = null): string { @@ -176,9 +155,6 @@ public function getBytes(int $offset = 0, int $length = null): string /** * Returns the value of a single byte. - * - * @param int $offset - * @return int */ public function getByteVal(int $offset = 0): int { @@ -191,9 +167,6 @@ public function getByteVal(int $offset = 0): int /** * Returns the value of a single unsigned 16-bit integer. - * - * @param int $offset - * @return int */ public function getUint16Val(int $offset = 0): int { @@ -206,9 +179,6 @@ public function getUint16Val(int $offset = 0): int /** * Returns the value of a single unsigned 32-bit integer. - * - * @param int $offset - * @return int */ public function getUint32Val(int $offset = 0): int { @@ -228,9 +198,6 @@ public function getUint32Val(int $offset = 0): int /** * Returns the value of a single unsigned 64-bit integer. - * - * @param int $offset - * @return int */ public function getUint64Val(int $offset): int { @@ -254,9 +221,6 @@ public function getUint64Val(int $offset): int /** * Returns the value of a single 16-bit float. - * - * @param int $offset - * @return float */ public function getHalfFloatVal(int $offset = 0): float { @@ -279,9 +243,6 @@ public function getHalfFloatVal(int $offset = 0): float /** * Returns the value of a single 32-bit float. - * - * @param int $offset - * @return float */ public function getFloatVal(int $offset = 0): float { @@ -294,9 +255,6 @@ public function getFloatVal(int $offset = 0): float /** * Returns the value of a single 64-bit float. - * - * @param int $offset - * @return float */ public function getDoubleVal(int $offset = 0): float { @@ -309,8 +267,6 @@ public function getDoubleVal(int $offset = 0): float /** * Transforms the ByteBuffer JSON into a generic Object. * - * @param int $jsonFlags - * @return object * @throws \JsonException */ public function toObject(int $jsonFlags = 0): object @@ -320,8 +276,6 @@ public function toObject(int $jsonFlags = 0): object /** * Returns a Base64 URL representation of the byte buffer. - * - * @return string */ public function toBase64Url(): string { @@ -330,8 +284,6 @@ public function toBase64Url(): string /** * Specify data which should be serialized to JSON. - * - * @return string */ public function jsonSerialize(): string { @@ -340,8 +292,6 @@ public function jsonSerialize(): string /** * Returns a hexadecimal representation of the ByteBuffer. - * - * @return string */ public function toHex(): string { @@ -349,9 +299,7 @@ public function toHex(): string } /** - * object to string - * - * @return string + * Returns string representation of the object. */ public function __toString(): string { @@ -360,9 +308,6 @@ public function __toString(): string /** * Convert the object to its JSON representation. - * - * @param int $options - * @return string */ public function toJson($options = 0): string { @@ -380,9 +325,9 @@ public function __serialize(): array } /** - * Serializable-Interface + * Unserializes the data into this object instance. * - * @param array $data + * @param array{binaryData: string} $data */ public function __unserialize(array $data): void { @@ -392,9 +337,6 @@ public function __unserialize(array $data): void /** * Create a ByteBuffer from a BASE64 URL encoded string. - * - * @param string $base64url - * @return static */ public static function fromBase64Url(string $base64url): static { @@ -407,9 +349,6 @@ public static function fromBase64Url(string $base64url): static /** * Create a ByteBuffer from a BASE64 encoded string. - * - * @param string $base64 - * @return static */ public static function fromBase64(string $base64): static { @@ -425,9 +364,6 @@ public static function fromBase64(string $base64): static /** * Create a ByteBuffer from a hexadecimal string. - * - * @param string $hex - * @return static */ public static function fromHex(string $hex): static { @@ -441,8 +377,7 @@ public static function fromHex(string $hex): static /** * Create a random ByteBuffer * - * @param int $length - * @return static + * @throws \Random\RandomException */ public static function makeRandom(int $length): static { @@ -451,9 +386,6 @@ public static function makeRandom(int $length): static /** * Decodes a BASE64 URL string. - * - * @param string $data - * @return string|false */ protected static function decodeBase64Url(string $data): string|false { @@ -462,9 +394,6 @@ protected static function decodeBase64Url(string $data): string|false /** * Encodes a BASE64 URL string. - * - * @param string $data - * @return string|false */ protected static function encodeBase64Url(string $data): string|false { diff --git a/src/CborDecoder.php b/src/CborDecoder.php index e570259..e571ac5 100644 --- a/src/CborDecoder.php +++ b/src/CborDecoder.php @@ -77,8 +77,6 @@ class CborDecoder /** * Decodes the binary data. * - * @param \Laragear\WebAuthn\ByteBuffer|string $encoded - * @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null * @throws \Laragear\WebAuthn\Exceptions\DataException */ public static function decode(ByteBuffer|string $encoded): ByteBuffer|array|bool|float|int|string|null @@ -101,10 +99,6 @@ public static function decode(ByteBuffer|string $encoded): ByteBuffer|array|bool /** * Decodes a portion of the Byte Buffer. * - * @param ByteBuffer|string $bufOrBin - * @param int $startOffset - * @param int|null $endOffset - * @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null * @throws \Laragear\WebAuthn\Exceptions\DataException */ public static function decodePortion(ByteBuffer|string $bufOrBin, int $startOffset, ?int &$endOffset = null): ByteBuffer|array|bool|float|int|string|null @@ -121,9 +115,6 @@ public static function decodePortion(ByteBuffer|string $bufOrBin, int $startOffs /** * Parses a single item of the Byte Buffer. * - * @param ByteBuffer $buf - * @param int $offset - * @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseItem(ByteBuffer $buf, int &$offset): ByteBuffer|array|bool|float|int|string|null @@ -148,10 +139,6 @@ protected static function parseItem(ByteBuffer $buf, int &$offset): ByteBuffer|a /** * Parses a simple float value. * - * @param int $val - * @param \Laragear\WebAuthn\ByteBuffer $buf - * @param int $offset - * @return bool|float|null * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseFloatSimple(int $val, ByteBuffer $buf, int &$offset): bool|float|null @@ -187,8 +174,6 @@ protected static function parseFloatSimple(int $val, ByteBuffer $buf, int &$offs /** * Parses a simple value from CBOR. * - * @param int $val - * @return bool|null * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseSimpleValue(int $val): ?bool @@ -204,10 +189,6 @@ protected static function parseSimpleValue(int $val): ?bool /** * Parses the CBOR extra length. * - * @param int $val - * @param \Laragear\WebAuthn\ByteBuffer $buf - * @param int $offset - * @return int * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseExtraLength(int $val, ByteBuffer $buf, int &$offset): int @@ -243,11 +224,6 @@ protected static function parseExtraLength(int $val, ByteBuffer $buf, int &$offs /** * Parses the data inside a Byte Buffer. * - * @param int $type - * @param int $val - * @param \Laragear\WebAuthn\ByteBuffer $buf - * @param $offset - * @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null * @throws \Laragear\WebAuthn\Exceptions\DataException|\InvalidArgumentException */ protected static function parseItemData( @@ -289,10 +265,6 @@ protected static function parseItemData( /** * Parses an array with string keys. * - * @param \Laragear\WebAuthn\ByteBuffer $buffer - * @param int $offset - * @param int $count - * @return array * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseMap(ByteBuffer $buffer, int &$offset, int $count): array @@ -316,10 +288,6 @@ protected static function parseMap(ByteBuffer $buffer, int &$offset, int $count) /** * Parses an array from the byte buffer. * - * @param \Laragear\WebAuthn\ByteBuffer $buf - * @param int $offset - * @param int $count - * @return array * @throws \Laragear\WebAuthn\Exceptions\DataException */ protected static function parseArray(ByteBuffer $buf, int &$offset, int $count): array diff --git a/src/Challenge.php b/src/Challenge.php index 7498eb0..0546696 100644 --- a/src/Challenge.php +++ b/src/Challenge.php @@ -8,11 +8,6 @@ class Challenge { /** * Create a new Challenge instance. - * - * @param \Laragear\WebAuthn\ByteBuffer $data - * @param int $timeout - * @param bool $verify - * @param array $properties */ final public function __construct( public ByteBuffer $data, @@ -25,8 +20,6 @@ final public function __construct( /** * Check if the current challenge has expired in time and no longer valid. - * - * @return bool */ public function hasExpired(): bool { @@ -36,11 +29,7 @@ public function hasExpired(): bool /** * Creates a new Challenge instance using a random ByteBuffer of the given length. * - * @param int $length - * @param int $timeout - * @param bool $verify - * @param array $options - * @return static + * @throws \Random\RandomException */ public static function random(int $length, int $timeout, bool $verify = true, array $options = []): static { diff --git a/src/ClientDataJson.php b/src/ClientDataJson.php index e150000..df478d7 100644 --- a/src/ClientDataJson.php +++ b/src/ClientDataJson.php @@ -6,10 +6,6 @@ class ClientDataJson { /** * Create a new Client Data JSON object. - * - * @param string $type - * @param string $origin - * @param \Laragear\WebAuthn\ByteBuffer $challenge */ public function __construct(public string $type, public string $origin, public ByteBuffer $challenge) { diff --git a/src/Console/WebAuthnInstallCommand.php b/src/Console/WebAuthnInstallCommand.php new file mode 100644 index 0000000..765a66d --- /dev/null +++ b/src/Console/WebAuthnInstallCommand.php @@ -0,0 +1,49 @@ +call('vendor:publish', [ + '--provider' => WebAuthnServiceProvider::class, + '--force' => $this->option('force'), + '--tag' => ['migrations', 'config', 'controllers'], + ]); + } +} diff --git a/src/Contracts/WebAuthnAuthenticatable.php b/src/Contracts/WebAuthnAuthenticatable.php index d76edcc..d50ccd3 100644 --- a/src/Contracts/WebAuthnAuthenticatable.php +++ b/src/Contracts/WebAuthnAuthenticatable.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Ramsey\Uuid\UuidInterface; interface WebAuthnAuthenticatable { @@ -15,26 +16,24 @@ interface WebAuthnAuthenticatable public function webAuthnData(): array; /** - * Removes all credentials previously registered. + * An anonymized user identity string, as a UUID. * - * @param string ...$except - * @return void + * @see https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id + */ + public function webAuthnId(): UuidInterface; + + /** + * Removes all credentials previously registered. */ public function flushCredentials(string ...$except): void; /** * Disables all credentials for the user. - * - * @param string ...$except - * @return void */ public function disableAllCredentials(string ...$except): void; /** * Makes an instance of a WebAuthn Credential attached to this user. - * - * @param array $properties - * @return \Laragear\WebAuthn\Models\WebAuthnCredential */ public function makeWebAuthnCredential(array $properties): WebAuthnCredential; diff --git a/src/Enums/Extension.php b/src/Enums/Extension.php new file mode 100644 index 0000000..054fbac --- /dev/null +++ b/src/Enums/Extension.php @@ -0,0 +1,28 @@ +guard($guard); if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) { $this->session()->regenerate($destroySession); diff --git a/src/Http/Requests/AssertionRequest.php b/src/Http/Requests/AssertionRequest.php index 64e7f8d..fd7a632 100644 --- a/src/Http/Requests/AssertionRequest.php +++ b/src/Http/Requests/AssertionRequest.php @@ -4,42 +4,33 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\Auth; use InvalidArgumentException; use Laragear\WebAuthn\Assertion\Creator\AssertionCreation; use Laragear\WebAuthn\Assertion\Creator\AssertionCreator; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; -use Laragear\WebAuthn\WebAuthn; -use function is_int; -use function is_string; +use Laragear\WebAuthn\Enums\UserVerification; +use function auth; +use function is_array; class AssertionRequest extends FormRequest { /** * The Assertion Creation instance. - * - * @var \Laragear\WebAuthn\Assertion\Creator\AssertionCreation */ protected AssertionCreation $assertion; /** * The guard to use to retrieve the user. - * - * @var string|null */ protected ?string $guard = null; /** * If the user may or may not be verified on login. - * - * @var string|null */ protected ?string $userVerification = null; /** * Validate the class instance. - * - * @return void */ public function validateResolved(): void { @@ -48,8 +39,6 @@ public function validateResolved(): void /** * Return or make a new Assertion Creation. - * - * @return \Laragear\WebAuthn\Assertion\Creator\AssertionCreation */ protected function assertion(): AssertionCreation { @@ -59,7 +48,6 @@ protected function assertion(): AssertionCreation /** * Sets the WebAuthn-compatible guard to use. * - * @param string $guard * @return $this */ public function guard(string $guard): static @@ -76,7 +64,7 @@ public function guard(string $guard): static */ public function fastLogin(): static { - $this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED; + $this->assertion()->userVerification = UserVerification::DISCOURAGED; return $this; } @@ -88,7 +76,7 @@ public function fastLogin(): static */ public function secureLogin(): static { - $this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED; + $this->assertion()->userVerification = UserVerification::REQUIRED; return $this; } @@ -96,8 +84,6 @@ public function secureLogin(): static /** * Creates an assertion challenge for a user if found. * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|string|int|array|null $credentials - * @return \Illuminate\Contracts\Support\Responsable * @throws \Illuminate\Contracts\Container\BindingResolutionException */ public function toVerify(WebAuthnAuthenticatable|string|int|array|null $credentials = []): Responsable @@ -115,8 +101,6 @@ public function toVerify(WebAuthnAuthenticatable|string|int|array|null $credenti /** * Try to find a user to create an assertion procedure. * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|array|int|string|null $credentials - * @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null * @throws \Illuminate\Contracts\Container\BindingResolutionException */ protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable @@ -129,18 +113,19 @@ protected function findUser(WebAuthnAuthenticatable|array|int|string|null $crede return $credentials; } + $guard = $this->guard ?? $this->container->make('config')->get('auth.defaults.guard'); + + // @phpstan-ignore-next-line + $provider = auth($guard)->getProvider(); + // If the developer is using a string or integer, we will understand its trying to // retrieve by its ID, otherwise we will fall back to credentials. Once done, we // will check it uses WebAuthn if is not null, otherwise we'll fail miserably. - $user = is_string($credentials) || is_int($credentials) - // @phpstan-ignore-next-line - ? Auth::guard($this->guard)->getProvider()->retrieveById($credentials) - // @phpstan-ignore-next-line - : Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials); + $user = is_array($credentials) + ? $provider->retrieveByCredentials($credentials) + : $provider->retrieveById($credentials); if ($user && ! $user instanceof WebAuthnAuthenticatable) { - $guard = $this->guard ?? $this->container->make('config')->get('auth.defaults.guard'); - throw new InvalidArgumentException( "The user found for the [$guard] auth guard is not an instance of [WebAuthnAuthenticatable]." ); diff --git a/src/Http/Requests/AttestationRequest.php b/src/Http/Requests/AttestationRequest.php index 828a5d9..71ba4f0 100644 --- a/src/Http/Requests/AttestationRequest.php +++ b/src/Http/Requests/AttestationRequest.php @@ -7,7 +7,8 @@ use Laragear\WebAuthn\Attestation\Creator\AttestationCreation; use Laragear\WebAuthn\Attestation\Creator\AttestationCreator; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Enums\ResidentKey; +use Laragear\WebAuthn\Enums\UserVerification; /** * @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null) @@ -62,7 +63,7 @@ protected function attestation(): AttestationCreation */ public function fastRegistration(): static { - $this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED; + $this->attestation()->userVerification = UserVerification::DISCOURAGED; return $this; } @@ -74,7 +75,7 @@ public function fastRegistration(): static */ public function secureRegistration(): static { - $this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED; + $this->attestation()->userVerification = UserVerification::REQUIRED; return $this; } @@ -86,7 +87,7 @@ public function secureRegistration(): static */ public function userless(): static { - $this->attestation()->residentKey = WebAuthn::RESIDENT_KEY_REQUIRED; + $this->attestation()->residentKey = ResidentKey::REQUIRED; return $this; } diff --git a/src/Http/Requests/AttestedRequest.php b/src/Http/Requests/AttestedRequest.php index 8bdca82..d6ca416 100644 --- a/src/Http/Requests/AttestedRequest.php +++ b/src/Http/Requests/AttestedRequest.php @@ -17,16 +17,11 @@ class AttestedRequest extends FormRequest { /** * The new credential instance. - * - * @var \Laragear\WebAuthn\Models\WebAuthnCredential */ protected WebAuthnCredential $credential; /** * Determine if the user is authorized to make this request. - * - * @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user - * @return bool */ public function authorize(?WebAuthnAuthenticatable $user): bool { @@ -53,7 +48,6 @@ public function rules(): array /** * Handle a passed validation attempt. * - * @return void * @throws \Illuminate\Contracts\Container\BindingResolutionException */ protected function passedValidation(): void @@ -66,10 +60,9 @@ protected function passedValidation(): void } /** - * Save and return the generated WebAuthn Credentials. + * Save the generated WebAuthn Credentials, and return its ID. * - * @param array|callable $saving - * @return string + * @param array|callable $saving */ public function save(array|callable $saving = []): string { diff --git a/src/Http/Routes.php b/src/Http/Routes.php new file mode 100644 index 0000000..9c83cfe --- /dev/null +++ b/src/Http/Routes.php @@ -0,0 +1,44 @@ +group(static function () use ($assert, $assertController, $attest, $attestController): void { + Route::controller($attestController) + ->group(static function () use ($attest): void { + Route::post("$attest/options", 'options')->name('webauthn.register.options'); + Route::post("$attest", 'register')->name('webauthn.register'); + }); + + Route::controller($assertController) + ->group(static function () use ($assert): void { + Route::post("$assert/options", 'options')->name('webauthn.login.options'); + Route::post("$assert", 'login')->name('webauthn.login'); + }); + }); + } + + /** + * Registers a set of default WebAuthn routes. + * + * @return void + */ + public static function routes(): void + { + static::register(); + } +} diff --git a/src/JsonTransport.php b/src/JsonTransport.php index 4c75881..3b4c5ff 100644 --- a/src/JsonTransport.php +++ b/src/JsonTransport.php @@ -20,8 +20,6 @@ class JsonTransport implements Arrayable, Jsonable, JsonSerializable, Stringable { /** * Create a new JSON transport. - * - * @param array $json */ public function __construct(public array $json = []) { @@ -30,10 +28,6 @@ public function __construct(public array $json = []) /** * Adds a value to the underlying JSON array. - * - * @param string $key - * @param mixed $value - * @return void */ public function set(string $key, mixed $value): void { @@ -42,10 +36,6 @@ public function set(string $key, mixed $value): void /** * Retrieves a value from the underlying JSON array. - * - * @param string $key - * @param string|int|null $default - * @return string|int|null */ public function get(string $key, string|int $default = null): string|int|null { @@ -55,8 +45,7 @@ public function get(string $key, string|int $default = null): string|int|null /** * Convert the object to its JSON representation. * - * @param int $options - * @return string + * @throws \JsonException */ public function toJson($options = 0): string { @@ -68,15 +57,13 @@ public function toJson($options = 0): string * * @return array */ - public function toArray() + public function toArray(): array { return $this->json; } /** * Specify data which should be serialized to JSON. - * - * @return array */ public function jsonSerialize(): array { @@ -86,7 +73,7 @@ public function jsonSerialize(): array /** * Returns a string representation of the object. * - * @return string + * @throws \JsonException */ public function __toString(): string { @@ -95,9 +82,6 @@ public function __toString(): string /** * Create an HTTP response that represents the object. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse */ public function toResponse($request): JsonResponse { diff --git a/src/Migrations/WebAuthnAuthenticationMigration.php b/src/Migrations/WebAuthnAuthenticationMigration.php new file mode 100644 index 0000000..d83b2b2 --- /dev/null +++ b/src/Migrations/WebAuthnAuthenticationMigration.php @@ -0,0 +1,60 @@ +string('id', 510)->primary(); + + $this->createMorph($table, 'authenticatable'); + + // When requesting to create a credential, the app will set a "user handle" to be + // a UUID to anonymize the user personal information. If a second credential is + // created, the first UUID is copied to the new one, keeping the association. + $table->uuid('user_id'); + + // The app may allow the user to name or rename a credential to a friendly name, + // like "John's iPhone" or "Office Computer". This column is nullable, so it's + // up to the app to use an alias. Otherwise, the app can use custom columns. + $table->string('alias')->nullable(); + + // Allows to detect cloned credentials when these do not share the same counter. + $table->unsignedBigInteger('counter')->nullable(); + // Who created the credential? Should be the same reported by the Authenticator. + $table->string('rp_id'); + // Where the credential was created? Should be the same reported by the Authenticator. + $table->string('origin'); + // The available "ways to transmit" the public key between the browser and Authenticator. + // It may be generated by the authenticator when it creates it, that's why is nullable. + // On assertion, this will allow the authenticator where to look for the private key. + $table->json('transports')->nullable(); + // The "type" or "properties" of the authenticator. Sometimes these are zeroes or null. + $table->uuid('aaguid')->nullable(); // GUID are essentially UUID + + // This is the public key the server will use to verify the challenges are corrected. + $table->text('public_key'); + // The attestation of the public key. + $table->string('attestation_format')->default(Formats::NONE->value); + // This would hold the certificate chain for other different attestation formats. + $table->json('certificates')->nullable(); + + // Run the additional migration instructions... + $this->addColumns($table); + + // A way to disable the credential without deleting it. + $table->timestamp('disabled_at')->nullable(); + $table->timestamps(); + } +} diff --git a/src/Models/WebAuthnCredential.php b/src/Models/WebAuthnCredential.php index 18ba661..4952a15 100644 --- a/src/Models/WebAuthnCredential.php +++ b/src/Models/WebAuthnCredential.php @@ -5,29 +5,40 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Laragear\MetaModel\CustomizableModel; use Laragear\WebAuthn\Events\CredentialDisabled; use Laragear\WebAuthn\Events\CredentialEnabled; +use Laragear\WebAuthn\Migrations\WebAuthnAuthenticationMigration; use function parse_url; use const PHP_URL_HOST; /** * @mixin \Illuminate\Database\Eloquent\Builder * - * @method static \Illuminate\Database\Eloquent\Builder|static query() - * @method \Illuminate\Database\Eloquent\Builder|static newQuery() - * @method static static make(array $attributes = []) - * @method static static create(array $attributes = []) - * @method static static forceCreate(array $attributes) - * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrNew(array $attributes = [], array $values = []) - * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrFail($columns = ['*']) - * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrCreate(array $attributes, array $values = []) - * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOr($columns = ['*'], \Closure $callback = null) - * @method \Laragear\WebAuthn\Models\WebAuthnCredential firstWhere($column, $operator = null, $value = null, $boolean = 'and') - * @method \Laragear\WebAuthn\Models\WebAuthnCredential updateOrCreate(array $attributes, array $values = []) - * @method ?static first($columns = ['*']) - * @method static static findOrFail($id, $columns = ['*']) - * @method static static findOrNew($id, $columns = ['*']) - * @method static ?null find($id, $columns = ['*']) + * @method \Illuminate\Database\Eloquent\Builder|\static newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|\static query() + * @method static \static make(array $attributes = []) + * @method static \static create(array $attributes = []) + * @method static \static forceCreate(array $attributes) + * @method static \static forceCreateQuietly(array $attributes = []) + * @method \static|null first($columns = ['*'], string ...$columns) + * @method \static firstOrNew(array $attributes = [], array $values = []) + * @method \static firstOrFail($columns = ['*']) + * @method \static firstOrCreate(array $attributes, array $values = []) + * @method \static firstOr($columns = ['*'], \Closure $callback = null) + * @method \static firstWhere($column, $operator = null, $value = null, $boolean = 'and') + * @method \static updateOrCreate(array $attributes, array $values = []) + * @method \static createOrFirst(array $attributes, array $values = []) + * @method \static sole($columns = ['*']) + * @method \static findOrNew($id, $columns = ['*']) + * @method \Illuminate\Database\Eloquent\Collection|\static[]|\static|null find($id, $columns = ['*']) + * @method \Illuminate\Database\Eloquent\Collection|\static[]|\static findOrFail($id, $columns = ['*']) + * @method \Illuminate\Database\Eloquent\Collection|\static[]|\static findOr($id, $columns = ['*'], \Closure $callback = null) + * @method \Illuminate\Database\Eloquent\Collection|\static[] findMany($id, $columns = ['*']) + * @method \Illuminate\Database\Eloquent\Collection|\static[] fromQuery($query, $bindings = []) + * @method \Illuminate\Support\LazyCollection|\static[] lazy(int $chunkSize = 1000) + * @method \Illuminate\Support\LazyCollection|\static[] lazyById(int $chunkSize = 1000, string|null $column = null, string|null $alias = null) + * @method \Illuminate\Support\LazyCollection|\static[] lazyByIdDesc(int $chunkSize = 1000, string|null $column = null, string|null $alias = null) * * @property-read string $id * @@ -53,17 +64,12 @@ * * @property-read \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $authenticatable * - * @method \Illuminate\Database\Eloquent\Builder|static whereEnabled() - * @method \Illuminate\Database\Eloquent\Builder|static whereDisabled() + * @method \Illuminate\Database\Eloquent\Builder|\static whereEnabled() + * @method \Illuminate\Database\Eloquent\Builder|\static whereDisabled() */ class WebAuthnCredential extends Model { - /** - * The table associated with the model. - * - * @var string - */ - protected $table = 'webauthn_credentials'; + use CustomizableModel; /** * The "type" of the primary key ID. @@ -107,23 +113,17 @@ public function authenticatable(): MorphTo { return $this->morphTo('authenticatable'); } - /** * Filter the query by enabled credentials. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder */ protected function scopeWhereEnabled(Builder $query): Builder { // @phpstan-ignore-next-line return $query->whereNull('disabled_at'); } + /** * Filter the query by disabled credentials. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder */ protected function scopeWhereDisabled(Builder $query): Builder { @@ -133,8 +133,6 @@ protected function scopeWhereDisabled(Builder $query): Builder /** * Check if the credential is enabled. - * - * @return bool */ public function isEnabled(): bool { @@ -143,8 +141,6 @@ public function isEnabled(): bool /** * Check if the credential is disabled. - * - * @return bool */ public function isDisabled(): bool { @@ -153,8 +149,6 @@ public function isDisabled(): bool /** * Enables the credential to be used with WebAuthn. - * - * @return void */ public function enable(): void { @@ -171,8 +165,6 @@ public function enable(): void /** * Disables the credential for WebAuthn. - * - * @return void */ public function disable(): void { @@ -186,10 +178,7 @@ public function disable(): void } /** - * Increments the assertion counter by 1. - * - * @param int $counter - * @return void + * Sets the counter for this WebAuthn Credential. */ public function syncCounter(int $counter): void { @@ -209,4 +198,12 @@ protected function getRpIdAttribute(string $rpId): string // If the Relying Party is a URL, we will return the domain, otherwise, verbatim. return ($domain = parse_url($rpId, PHP_URL_HOST)) ? $domain : $rpId; } + + /** + * @inheritDoc + */ + protected static function migrationClass(): string + { + return WebAuthnAuthenticationMigration::class; + } } diff --git a/src/SharedPipes/CheckChallengeSame.php b/src/SharedPipes/CheckChallengeSame.php index ed629b2..3306c2f 100644 --- a/src/SharedPipes/CheckChallengeSame.php +++ b/src/SharedPipes/CheckChallengeSame.php @@ -16,9 +16,6 @@ abstract class CheckChallengeSame /** * Handle the incoming WebAuthn Ceremony Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ diff --git a/src/SharedPipes/CheckOriginSecure.php b/src/SharedPipes/CheckOriginSecure.php index 34c3c8d..c07587d 100644 --- a/src/SharedPipes/CheckOriginSecure.php +++ b/src/SharedPipes/CheckOriginSecure.php @@ -14,8 +14,6 @@ abstract class CheckOriginSecure /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -24,10 +22,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming WebAuthn Ceremony Validation. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed */ public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed { diff --git a/src/SharedPipes/CheckRelyingPartyHashSame.php b/src/SharedPipes/CheckRelyingPartyHashSame.php index a6b2817..f97a847 100644 --- a/src/SharedPipes/CheckRelyingPartyHashSame.php +++ b/src/SharedPipes/CheckRelyingPartyHashSame.php @@ -17,8 +17,6 @@ abstract class CheckRelyingPartyHashSame /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -28,9 +26,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming WebAuthn Ceremony Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ @@ -48,9 +43,6 @@ public function handle(AttestationValidation|AssertionValidation $validation, Cl /** * Return the Attestation data to check the RP ID Hash. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @return \Laragear\WebAuthn\Attestation\AuthenticatorData */ abstract protected function authenticatorData( AttestationValidation|AssertionValidation $validation @@ -58,9 +50,6 @@ abstract protected function authenticatorData( /** * Return the Relying Party ID from the config or credential. - * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @return string */ abstract protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string; } diff --git a/src/SharedPipes/CheckRelyingPartyIdContained.php b/src/SharedPipes/CheckRelyingPartyIdContained.php index 56f745f..0494019 100644 --- a/src/SharedPipes/CheckRelyingPartyIdContained.php +++ b/src/SharedPipes/CheckRelyingPartyIdContained.php @@ -20,8 +20,6 @@ abstract class CheckRelyingPartyIdContained /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -31,9 +29,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming WebAuthn Ceremony Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ diff --git a/src/SharedPipes/CheckUserInteraction.php b/src/SharedPipes/CheckUserInteraction.php index bf82ee4..a07a00f 100644 --- a/src/SharedPipes/CheckUserInteraction.php +++ b/src/SharedPipes/CheckUserInteraction.php @@ -16,9 +16,6 @@ abstract class CheckUserInteraction /** * Handle the incoming WebAuthn Ceremony Validation. * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AssertionException * @throws \Laragear\WebAuthn\Exceptions\AttestationException */ diff --git a/src/SharedPipes/CompileClientDataJson.php b/src/SharedPipes/CompileClientDataJson.php index 5c29201..82fe279 100644 --- a/src/SharedPipes/CompileClientDataJson.php +++ b/src/SharedPipes/CompileClientDataJson.php @@ -22,9 +22,6 @@ abstract class CompileClientDataJson /** * Handle the incoming WebAuthn Ceremony Validation. * - * @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation - * @param \Closure $next - * @return mixed * @throws \Laragear\WebAuthn\Exceptions\AttestationException * @throws \Laragear\WebAuthn\Exceptions\AssertionException */ diff --git a/src/SharedPipes/RetrieveChallenge.php b/src/SharedPipes/RetrieveChallenge.php index b07aeb3..f5537d6 100644 --- a/src/SharedPipes/RetrieveChallenge.php +++ b/src/SharedPipes/RetrieveChallenge.php @@ -20,8 +20,6 @@ abstract class RetrieveChallenge /** * Create a new pipe instance. - * - * @param \Illuminate\Contracts\Config\Repository $config */ public function __construct(protected Repository $config) { @@ -30,10 +28,6 @@ public function __construct(protected Repository $config) /** * Handle the incoming Assertion Validation. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param \Closure $next - * @return mixed */ public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed { @@ -48,9 +42,6 @@ public function handle(AttestationValidation|AssertionValidation $validation, Cl /** * Pulls an Attestation challenge from the Cache. - * - * @param \Illuminate\Http\Request $request - * @return \Laragear\WebAuthn\Challenge|null */ protected function retrieveChallenge(Request $request): ?Challenge { diff --git a/src/SharedPipes/ThrowsCeremonyException.php b/src/SharedPipes/ThrowsCeremonyException.php index a0f42b5..bc921a5 100644 --- a/src/SharedPipes/ThrowsCeremonyException.php +++ b/src/SharedPipes/ThrowsCeremonyException.php @@ -14,13 +14,8 @@ trait ThrowsCeremonyException { /** * Throws an exception for the validation. - * - * @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation - * @param string $message - * @return void&never - * @throws \Laragear\WebAuthn\Exceptions\AssertionException|\Laragear\WebAuthn\Exceptions\AttestationException */ - protected static function throw(AttestationValidation|AssertionValidation $validation, string $message): void + protected static function throw(AttestationValidation|AssertionValidation $validation, string $message): never { throw $validation instanceof AssertionValidation ? AssertionException::make($message) diff --git a/src/WebAuthn.php b/src/WebAuthn.php index 09c078d..279d81f 100644 --- a/src/WebAuthn.php +++ b/src/WebAuthn.php @@ -2,45 +2,12 @@ namespace Laragear\WebAuthn; -use Illuminate\Support\Facades\Route; - -class WebAuthn +/** + * @deprecated Use \Laragear\WebAuthn\Http\Routes directly + * + * @see \Laragear\WebAuthn\Http\Routes + */ +class WebAuthn extends Http\Routes { - // Constants for user verification in Attestation and Assertion. - public const USER_VERIFICATION_PREFERRED = 'preferred'; - public const USER_VERIFICATION_DISCOURAGED = 'discouraged'; - public const USER_VERIFICATION_REQUIRED = 'required'; - - // Attestation variables to limit the authenticator conveyance. - public const PLATFORMS = ['cross-platform', 'platform']; - public const TRANSPORTS = ['usb', 'nfc', 'ble', 'internal']; - public const FORMATS = ['none', 'android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm']; - - // Resident Keys requirement. - public const RESIDENT_KEY_REQUIRED = 'required'; - public const RESIDENT_KEY_PREFERRED = 'preferred'; - public const RESIDENT_KEY_DISCOURAGED = 'discouraged'; - - /** - * Registers a set of default WebAuthn routes. - * - * @return void - */ - public static function routes(): void - { - Route::middleware('web') - ->group(static function (): void { - Route::controller(\App\Http\Controllers\WebAuthn\WebAuthnRegisterController::class) - ->group(static function (): void { - Route::post('webauthn/register/options', 'options')->name('webauthn.register.options'); - Route::post('webauthn/register', 'register')->name('webauthn.register'); - }); - - Route::controller(\App\Http\Controllers\WebAuthn\WebAuthnLoginController::class) - ->group(static function (): void { - Route::post('webauthn/login/options', 'options')->name('webauthn.login.options'); - Route::post('webauthn/login', 'login')->name('webauthn.login'); - }); - }); - } + // } diff --git a/src/WebAuthnAuthentication.php b/src/WebAuthnAuthentication.php index 55b68ad..f9e1a51 100644 --- a/src/WebAuthnAuthentication.php +++ b/src/WebAuthnAuthentication.php @@ -5,7 +5,9 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\Date; +use Illuminate\Support\Str; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Ramsey\Uuid\UuidInterface; /** * @property-read \Illuminate\Database\Eloquent\Collection $webAuthnCredentials @@ -29,10 +31,17 @@ public function webAuthnData(): array } /** - * Removes all credentials previously registered. + * An anonymized user identity string, as a UUID. * - * @param string ...$except - * @return void + * @see https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id + */ + public function webAuthnId(): UuidInterface + { + return Str::uuid(); + } + + /** + * Removes all credentials previously registered. */ public function flushCredentials(string ...$except): void { @@ -51,9 +60,6 @@ public function flushCredentials(string ...$except): void /** * Disables all credentials for the user. - * - * @param string ...$except - * @return void */ public function disableAllCredentials(string ...$except): void { @@ -72,9 +78,6 @@ public function disableAllCredentials(string ...$except): void /** * Makes an instance of a WebAuthn Credential attached to this user. - * - * @param array $properties - * @return \Laragear\WebAuthn\Models\WebAuthnCredential */ public function makeWebAuthnCredential(array $properties): Models\WebAuthnCredential { @@ -84,7 +87,8 @@ public function makeWebAuthnCredential(array $properties): Models\WebAuthnCreden /** * Returns a queryable relationship for its WebAuthn Credentials. * - * @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential + * @phpstan-ignore-next-line + * @return \Illuminate\Database\Eloquent\Relations\MorphMany|\Laragear\WebAuthn\Models\WebAuthnCredential */ public function webAuthnCredentials(): MorphMany { diff --git a/src/WebAuthnServiceProvider.php b/src/WebAuthnServiceProvider.php index 1401e77..de53a63 100644 --- a/src/WebAuthnServiceProvider.php +++ b/src/WebAuthnServiceProvider.php @@ -7,13 +7,13 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; +use function method_exists; /** * @internal */ class WebAuthnServiceProvider extends ServiceProvider { - public const ROUTES = __DIR__.'/../routes/webauthn.php'; public const CONTROLLERS = __DIR__.'/../stubs/controllers'; public const CONFIG = __DIR__.'/../config/webauthn.php'; public const MIGRATIONS = __DIR__.'/../database/migrations'; @@ -32,6 +32,8 @@ public function register(): void $this->registerUser(); $this->registerUserProvider(); + + Models\WebAuthnCredential::$useTable = 'webauthn_credentials'; } /** @@ -42,9 +44,10 @@ public function register(): void */ public function boot(): void { + $this->commands(Console\WebAuthnInstallCommand::class); + if ($this->app->runningInConsole()) { - $this->publishesMigrations(static::MIGRATIONS); - $this->publishes([static::ROUTES => $this->app->basePath('routes/webauthn.php')], 'routes'); + $this->publishesPackageMigrations(static::MIGRATIONS); $this->publishes([static::CONFIG => $this->app->configPath('webauthn.php')], 'config'); // @phpstan-ignore-next-line $this->publishes([static::CONTROLLERS => $this->app->path('Http/Controllers/WebAuthn')], 'controllers'); @@ -55,13 +58,19 @@ public function boot(): void /** * Publishes migrations from the given path. * - * @param array|string $paths - * @param string $groups - * @return void + * @param string[]|string $paths * @throws \Illuminate\Contracts\Container\BindingResolutionException */ - protected function publishesMigrations(array|string $paths, string $groups = 'migrations'): void + protected function publishesPackageMigrations(array|string $paths, string $groups = 'migrations'): void { + if (method_exists(static::class, 'publishesMigrations')) { + foreach ((array) $paths as $path) { + $this->publishesMigrations([$path => $this->app->databasePath('migrations/')], 'migrations'); + } + + return; + } + $prefix = now()->format('Y_m_d_His'); $files = []; @@ -72,7 +81,9 @@ protected function publishesMigrations(array|string $paths, string $groups = 'mi $files[$file->getRealPath()] = $this->app->databasePath("migrations/{$prefix}_$filename"); } - $this->publishes($files, $groups); + method_exists($this, 'publishesMigrations') + ? $this->publishesMigrations($files, $groups) + : $this->publishes($files, $groups); } /** diff --git a/tests/Assertion/CreatorTest.php b/tests/Assertion/CreatorTest.php index 533c832..46a2a6f 100644 --- a/tests/Assertion/CreatorTest.php +++ b/tests/Assertion/CreatorTest.php @@ -7,7 +7,8 @@ use Laragear\WebAuthn\Assertion\Creator\AssertionCreation; use Laragear\WebAuthn\Assertion\Creator\AssertionCreator; use Laragear\WebAuthn\Challenge; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Enums\UserVerification; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; @@ -16,6 +17,7 @@ use function now; use function session; +#[WithMigration] class CreatorTest extends TestCase { protected Request $request; @@ -44,7 +46,7 @@ protected function setUp(): void protected function response(): TestResponse { return $this->createTestResponse( - $this->creator->send($this->creation)->thenReturn()->json->toResponse($this->request) + $this->creator->send($this->creation)->thenReturn()->json->toResponse($this->request), null ); } @@ -151,7 +153,7 @@ public function test_response_doesnt_add_credentials_blacklisted(): void public function test_forces_user_verification(): void { - $this->creation->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED; + $this->creation->userVerification = UserVerification::REQUIRED; $this->response() ->assertSessionHas('_webauthn', function (Challenge $challenge): bool { @@ -160,7 +162,7 @@ public function test_forces_user_verification(): void ->assertJson([ 'timeout' => 60000, 'challenge' => session('_webauthn')->data->toBase64Url(), - 'userVerification' => WebAuthn::USER_VERIFICATION_REQUIRED, + 'userVerification' => UserVerification::REQUIRED->value, ]); } diff --git a/tests/Assertion/ValidationTest.php b/tests/Assertion/ValidationTest.php index d36f59e..890f35b 100644 --- a/tests/Assertion/ValidationTest.php +++ b/tests/Assertion/ValidationTest.php @@ -18,7 +18,9 @@ use Laragear\WebAuthn\Exceptions\AssertionException; use Laragear\WebAuthn\Models\WebAuthnCredential; use Mockery; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\ParameterBag; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; @@ -30,6 +32,7 @@ use function now; use function session; +#[WithMigration] class ValidationTest extends TestCase { protected Request $request; @@ -42,6 +45,9 @@ protected function setUp(): void { parent::setUp(); + // Force booting the model if not booted previously. + WebAuthnCredential::make(); + $this->request = Request::create( 'https://test.app/webauthn/create', 'POST', content: json_encode(FakeAuthenticator::assertionResponse()) ); @@ -106,6 +112,44 @@ public function test_assertion_allows_user_instance_without_user_handle(): void static::assertInstanceOf(AssertionValidation::class, $this->validator->send($this->validation)->thenReturn()); } + public function test_assertion_supports_ed25519_public_key(): void + { + $assertionResponse = FakeAuthenticator::assertionResponse(); + + $assertionResponse['response']['signature'] = 'YCUMdR3mSYZl+f1/pb24wr8VYOC01A8rJ++38QFXuGl92GfwnLwdaldCuuWdIUsqOeTz5o8ucJsQqaxwFFsZAQ=='; + + $publicKey = 'txSZLg1bc1ndhdq5tjlsbplNwm4wsKd4/IwCuEuSfPw='; + + DB::table('webauthn_credentials')->where('id', FakeAuthenticator::CREDENTIAL_ID)->update([ + 'public_key' => Crypt::encryptString("-----BEGIN PUBLIC KEY-----\n$publicKey\n-----END PUBLIC KEY-----\n") + ]); + + $this->validation->request->setJson(new InputBag($assertionResponse)); + + $this->validation->user = WebAuthnAuthenticatableUser::query()->first(); + + static::assertInstanceOf(AssertionValidation::class, $this->validator->send($this->validation)->thenReturn()); + } + + public function test_assertion_supports_ed25519_public_key_with_16_byte_eddsa_header(): void + { + $assertionResponse = FakeAuthenticator::assertionResponse(); + + $assertionResponse['response']['signature'] = 'YCUMdR3mSYZl+f1/pb24wr8VYOC01A8rJ++38QFXuGl92GfwnLwdaldCuuWdIUsqOeTz5o8ucJsQqaxwFFsZAQ=='; + + $publicKey = 'MCowBQYDK2VwAyEAtxSZLg1bc1ndhdq5tjlsbplNwm4wsKd4/IwCuEuSfPw='; + + DB::table('webauthn_credentials')->where('id', FakeAuthenticator::CREDENTIAL_ID)->update([ + 'public_key' => Crypt::encryptString("-----BEGIN PUBLIC KEY-----\n$publicKey\n-----END PUBLIC KEY-----\n") + ]); + + $this->validation->request->setJson(new InputBag($assertionResponse)); + + $this->validation->user = WebAuthnAuthenticatableUser::query()->first(); + + static::assertInstanceOf(AssertionValidation::class, $this->validator->send($this->validation)->thenReturn()); + } + public function test_assertion_increases_counter(): void { static::assertInstanceOf(AssertionValidation::class, $this->validator->send($this->validation)->thenReturn()); @@ -532,7 +576,7 @@ public function test_signature_fails_if_credential_public_key_invalid(): void ]); $this->expectException(AssertionException::class); - $this->expectExceptionMessage('Assertion Error: Stored Public Key is invalid.'); + $this->expectExceptionMessageMatches("/^Assertion Error: Public key is invalid.*/m"); $this->validate(); } @@ -566,7 +610,8 @@ public function test_signature_fails_if_invalid(): void ]); $this->expectException(AssertionException::class); - $this->expectExceptionMessage('Assertion Error: Signature is invalid.'); + + $this->expectExceptionMessageMatches("/^Assertion Error: Signature is invalid.*/m"); $this->validate(); } diff --git a/tests/Attestation/CreatorTest.php b/tests/Attestation/CreatorTest.php index 755769d..8869e2f 100644 --- a/tests/Attestation/CreatorTest.php +++ b/tests/Attestation/CreatorTest.php @@ -8,7 +8,9 @@ use Laragear\WebAuthn\Attestation\Creator\AttestationCreation; use Laragear\WebAuthn\Attestation\Creator\AttestationCreator; use Laragear\WebAuthn\Challenge; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Enums\ResidentKey; +use Laragear\WebAuthn\Enums\UserVerification; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; @@ -16,6 +18,7 @@ use function now; use function session; +#[WithMigration] class CreatorTest extends TestCase { protected Request $request; @@ -44,7 +47,7 @@ protected function setUp(): void protected function response(): TestResponse { return $this->createTestResponse( - $this->creator->send($this->creation)->thenReturn()->json->toResponse($this->request) + $this->creator->send($this->creation)->thenReturn()->json->toResponse($this->request), null ); } @@ -97,7 +100,7 @@ public function test_uses_relying_party_config(): void public function test_asks_for_user_verification(): void { - $this->creation->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED; + $this->creation->userVerification = UserVerification::REQUIRED; $this->response() ->assertSessionHas('_webauthn', static function (Challenge $challenge): bool { @@ -112,7 +115,7 @@ public function test_asks_for_user_verification(): void public function test_asks_for_user_presence(): void { - $this->creation->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED; + $this->creation->userVerification = UserVerification::DISCOURAGED; $this->response() ->assertSessionHas('_webauthn', static function (Challenge $challenge): bool { @@ -127,7 +130,7 @@ public function test_asks_for_user_presence(): void public function test_asks_for_resident_key(): void { - $this->creation->residentKey = WebAuthn::RESIDENT_KEY_REQUIRED; + $this->creation->residentKey = ResidentKey::REQUIRED; $this->response() ->assertSessionHas('_webauthn', static function (Challenge $challenge): bool { diff --git a/tests/Attestation/ValidationTest.php b/tests/Attestation/ValidationTest.php index 7cc995f..9d62956 100644 --- a/tests/Attestation/ValidationTest.php +++ b/tests/Attestation/ValidationTest.php @@ -20,6 +20,7 @@ use Laragear\WebAuthn\Exceptions\AttestationException; use Laragear\WebAuthn\Models\WebAuthnCredential; use Mockery; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Symfony\Component\HttpFoundation\ParameterBag; use Tests\FakeAuthenticator; @@ -38,6 +39,7 @@ * * @see https://cbor.me */ +#[WithMigration] class ValidationTest extends TestCase { protected Request $request; diff --git a/tests/Auth/EloquentWebAuthnProviderTest.php b/tests/Auth/EloquentWebAuthnProviderTest.php index 65c9bca..c5bde28 100644 --- a/tests/Auth/EloquentWebAuthnProviderTest.php +++ b/tests/Auth/EloquentWebAuthnProviderTest.php @@ -7,12 +7,14 @@ use Laragear\WebAuthn\Exceptions\AssertionException; use Laragear\WebAuthn\Models\WebAuthnCredential; use Mockery; +use Orchestra\Testbench\Attributes\WithMigration; use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; +#[WithMigration] class EloquentWebAuthnProviderTest extends TestCase { protected function defineEnvironment($app): void diff --git a/tests/Console/WebAuthnInstallCommandTest.php b/tests/Console/WebAuthnInstallCommandTest.php new file mode 100644 index 0000000..188a5b1 --- /dev/null +++ b/tests/Console/WebAuthnInstallCommandTest.php @@ -0,0 +1,53 @@ +deleteInstalledFiles(); + } + + protected function tearDown(): void + { + $this->deleteInstalledFiles(); + + parent::tearDown(); + } + + protected function deleteInstalledFiles(): void + { + $migrations = Collection::make(File::files($this->app->databasePath('migrations'))) + ->filter(static function (SplFileInfo $file): bool { + return Str::endsWith($file->getRealPath(), 'create_webauthn_credentials.php'); + })->map->getRealPath(); + + File::delete($migrations->toArray()); + + File::delete($this->app->configPath('webauthn.php')); + File::delete($this->app->path('Http/Controllers/WebAuthn')); + } + + public function test_publishes_all_files(): void + { + $this->artisan('webauthn:install'); + + Collection::make(File::files($this->app->databasePath('migrations'))) + ->each(static function (SplFileInfo $file): void { + static::assertTrue(Str::endsWith($file->getFilename(), 'create_webauthn_credentials.php')); + }); + + static::assertFileExists($this->app->configPath('webauthn.php')); + static::assertFileExists($this->app->path('Http/Controllers/WebAuthn/WebAuthnLoginController.php')); + static::assertFileExists($this->app->path('Http/Controllers/WebAuthn/WebAuthnRegisterController.php')); + } +} diff --git a/tests/Http/Controllers/StubControllersTest.php b/tests/Http/Controllers/StubControllersTest.php index 855cfc3..185e778 100644 --- a/tests/Http/Controllers/StubControllersTest.php +++ b/tests/Http/Controllers/StubControllersTest.php @@ -8,9 +8,11 @@ use Laragear\WebAuthn\Http\Requests\AttestedRequest; use Laragear\WebAuthn\JsonTransport; use Laragear\WebAuthn\WebAuthn; +use Orchestra\Testbench\Attributes\WithMigration; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; +#[WithMigration] class StubControllersTest extends TestCase { protected function defineWebRoutes($router): void diff --git a/tests/Http/Requests/AssertedRequestTest.php b/tests/Http/Requests/AssertedRequestTest.php index 2ea09d1..8bb8741 100644 --- a/tests/Http/Requests/AssertedRequestTest.php +++ b/tests/Http/Requests/AssertedRequestTest.php @@ -13,6 +13,7 @@ use Laragear\WebAuthn\Challenge; use Laragear\WebAuthn\Http\Requests\AssertedRequest; use Mockery; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; @@ -23,6 +24,7 @@ use function now; use function session; +#[WithMigration] class AssertedRequestTest extends TestCase { protected function afterRefreshingDatabase(): void diff --git a/tests/Http/Requests/AssertionRequestTest.php b/tests/Http/Requests/AssertionRequestTest.php index ce5847b..f6cdb52 100644 --- a/tests/Http/Requests/AssertionRequestTest.php +++ b/tests/Http/Requests/AssertionRequestTest.php @@ -10,6 +10,7 @@ use Laragear\WebAuthn\Challenge; use Laragear\WebAuthn\Http\Requests\AssertionRequest; use Laragear\WebAuthn\JsonTransport; +use Orchestra\Testbench\Attributes\WithMigration; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; @@ -17,6 +18,7 @@ use function session; use function strlen; +#[WithMigration] class AssertionRequestTest extends TestCase { protected function afterRefreshingDatabase(): void diff --git a/tests/Http/Requests/AttestationRequestTest.php b/tests/Http/Requests/AttestationRequestTest.php index e729692..d64a6d6 100644 --- a/tests/Http/Requests/AttestationRequestTest.php +++ b/tests/Http/Requests/AttestationRequestTest.php @@ -8,12 +8,14 @@ use Laragear\WebAuthn\Challenge; use Laragear\WebAuthn\Http\Requests\AttestationRequest; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; use function config; +#[WithMigration] class AttestationRequestTest extends TestCase { protected function afterRefreshingDatabase(): void diff --git a/tests/Http/Requests/AttestedRequestTest.php b/tests/Http/Requests/AttestedRequestTest.php index acaf2af..730378d 100644 --- a/tests/Http/Requests/AttestedRequestTest.php +++ b/tests/Http/Requests/AttestedRequestTest.php @@ -11,12 +11,14 @@ use Laragear\WebAuthn\Events\CredentialCreated; use Laragear\WebAuthn\Http\Requests\AttestedRequest; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Orchestra\Testbench\Attributes\WithMigration; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; use Tests\TestCase; use function base64_decode; use function config; +#[WithMigration] class AttestedRequestTest extends TestCase { protected function afterRefreshingDatabase(): void diff --git a/tests/Models/WebAuthnCredentialTest.php b/tests/Models/WebAuthnCredentialTest.php index 55d3576..d72c282 100644 --- a/tests/Models/WebAuthnCredentialTest.php +++ b/tests/Models/WebAuthnCredentialTest.php @@ -7,6 +7,7 @@ use Laragear\WebAuthn\Events\CredentialDisabled; use Laragear\WebAuthn\Events\CredentialEnabled; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use Tests\FakeAuthenticator; use Tests\Stubs\WebAuthnAuthenticatableUser; @@ -15,6 +16,7 @@ use function json_encode; use function now; +#[WithMigration] class WebAuthnCredentialTest extends TestCase { protected function afterRefreshingDatabase(): void diff --git a/tests/ServiceProviderTest.php b/tests/ServiceProviderTest.php index 17bc01a..76af117 100644 --- a/tests/ServiceProviderTest.php +++ b/tests/ServiceProviderTest.php @@ -3,6 +3,7 @@ namespace Tests; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Foundation\Application; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\File; use Illuminate\Support\Fluent; @@ -10,7 +11,11 @@ use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; use Laragear\WebAuthn\WebAuthnAuthentication; use Laragear\WebAuthn\WebAuthnServiceProvider; +use Orchestra\Testbench\Attributes\DefineEnvironment; +use Orchestra\Testbench\Attributes\WithMigration; +use function version_compare; +#[WithMigration] class ServiceProviderTest extends TestCase { public function test_merges_config(): void @@ -32,11 +37,16 @@ public function test_publishes_config(): void /** * @define-env usesCustomTestTime */ + #[DefineEnvironment('usesCustomTestTime')] public function test_publishes_migrations(): void { + if (version_compare(Application::VERSION, '11', '>=')) { + $this->markTestSkipped('Laravel handles migration internally'); + } + static::assertSame( [ - realpath(WebAuthnServiceProvider::MIGRATIONS . '/2022_07_01_000000_create_webauthn_credentials.php') => + realpath(WebAuthnServiceProvider::MIGRATIONS . '/0000_00_00_000000_create_webauthn_credentials.php') => $this->app->databasePath("migrations/2020_01_01_163025_create_webauthn_credentials.php"), ], ServiceProvider::pathsToPublish(WebAuthnServiceProvider::class, 'migrations') @@ -60,12 +70,4 @@ public function test_bounds_user(): void static::assertSame($user, $this->app->make(WebAuthnAuthenticatable::class)); } - - public function test_publishes_routes_file(): void - { - static::assertSame( - [WebAuthnServiceProvider::ROUTES => $this->app->basePath('routes/webauthn.php')], - ServiceProvider::$publishGroups['routes'] - ); - } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 03a0a59..4ae5a13 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Laragear\WebAuthn\WebAuthnServiceProvider; use Orchestra\Testbench\TestCase as BaseTestCase; +use function class_exists; +use function function_exists; +use function realpath; abstract class TestCase extends BaseTestCase { @@ -12,8 +15,15 @@ abstract class TestCase extends BaseTestCase protected function defineDatabaseMigrations(): void { - $this->loadLaravelMigrations(); - $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + if (!class_exists('Orchestra\Testbench\Attributes\WithMigration')) { + $this->loadLaravelMigrations(); + } + + $this->loadMigrationsFrom( + function_exists('Orchestra\Testbench\package_path') + ? \Orchestra\Testbench\package_path('database/migrations') + : realpath(__DIR__ . '/../database/migrations') + ); } protected function getPackageProviders($app): array diff --git a/tests/WebAuthnAuthenticationTest.php b/tests/WebAuthnAuthenticationTest.php index e062218..ee033be 100644 --- a/tests/WebAuthnAuthenticationTest.php +++ b/tests/WebAuthnAuthenticationTest.php @@ -4,9 +4,11 @@ use Illuminate\Support\Carbon; use Laragear\WebAuthn\Models\WebAuthnCredential; +use Orchestra\Testbench\Attributes\WithMigration; use Ramsey\Uuid\Uuid; use function now; +#[WithMigration] class WebAuthnAuthenticationTest extends TestCase { protected Stubs\WebAuthnAuthenticatableUser $user; diff --git a/tests/WebAuthnTest.php b/tests/WebAuthnTest.php index fdcba6c..1673031 100644 --- a/tests/WebAuthnTest.php +++ b/tests/WebAuthnTest.php @@ -3,13 +3,13 @@ namespace Tests; use Illuminate\Support\Facades\Route; -use Laragear\WebAuthn\WebAuthn; +use Laragear\WebAuthn\Http\Routes; class WebAuthnTest extends TestCase { protected function defineWebRoutes($router): void { - WebAuthn::routes(); + Routes::register(); } public function test_registers_webauthn_routes(): void