From 424caae085bed85781ad7eef8904d644517d02f2 Mon Sep 17 00:00:00 2001 From: Alexis Saettler Date: Sat, 5 Feb 2022 12:46:47 +0100 Subject: [PATCH] feat: rewrite for more customizable library (#355) BREAKING CHANGE: This new version is a rewrite with a lot of breaking changes, see details in https://github.com/asbiin/laravel-webauthn/blob/main/docs/migration-v1-to-v2.md --- .github/workflows/tests.yml | 68 +++- README.md | 172 +++++--- composer.json | 44 ++- config/webauthn.php | 160 ++++---- database/factories/WebauthnKeyFactory.php | 43 ++ .../2019_03_29_163611_add_webauthn.php | 4 +- docs/migration-v1-to-v2.md | 82 ++++ phpstan.neon | 4 +- psalm.xml | 14 +- resources/lang/en/errors.php | 3 +- resources/views/register.blade.php | 2 +- src/Actions/DeleteKey.php | 28 ++ src/Actions/LoginAttempt.php | 78 ++++ src/Actions/LoginPrepare.php | 44 +++ src/Actions/RegisterKeyPrepare.php | 70 ++++ src/Actions/RegisterKeyStore.php | 84 ++++ src/Actions/UpdateKey.php | 27 ++ src/Console/PublishCommand.php | 49 --- src/Contracts/DestroyResponse.php | 10 + src/Contracts/LoginSuccessResponse.php | 10 + src/Contracts/LoginViewResponse.php | 10 + src/Contracts/RegisterSuccessResponse.php | 10 + src/Contracts/RegisterViewResponse.php | 10 + src/Contracts/UpdateResponse.php | 10 + src/Events/EventFailed.php | 28 ++ src/Events/EventUser.php | 29 ++ src/Events/WebauthnLogin.php | 23 +- src/Events/WebauthnLoginData.php | 18 +- src/Events/WebauthnLoginFailed.php | 7 + src/Events/WebauthnRegister.php | 3 +- src/Events/WebauthnRegisterData.php | 18 +- src/Events/WebauthnRegisterFailed.php | 7 + src/Facades/Webauthn.php | 12 +- .../Controllers/AuthenticateController.php | 73 ++++ src/Http/Controllers/WebauthnController.php | 273 ------------- .../Controllers/WebauthnKeyController.php | 119 ++++++ src/Http/Middleware/WebauthnMiddleware.php | 15 +- src/Http/Requests/WebauthnLoginRequest.php | 20 + src/Http/Requests/WebauthnRegisterRequest.php | 21 + src/Http/Requests/WebauthnUpdateRequest.php | 20 + src/Http/Responses/DestroyResponse.php | 22 ++ src/Http/Responses/LoginSuccessResponse.php | 40 ++ src/Http/Responses/LoginViewResponse.php | 52 +++ .../Responses/RegisterSuccessResponse.php | 70 ++++ src/Http/Responses/RegisterViewResponse.php | 52 +++ src/Http/Responses/UpdateResponse.php | 22 ++ src/Listeners/LoginViaRemember.php | 36 ++ src/Models/Casts/Base64.php | 37 ++ src/Models/Casts/TrustPath.php | 39 ++ src/Models/Casts/Uuid.php | 42 ++ src/Models/WebauthnKey.php | 128 ++---- src/Services/Webauthn.php | 222 +++++++---- src/Services/Webauthn/AbstractFactory.php | 29 -- .../Webauthn/AbstractOptionsFactory.php | 21 - .../Webauthn/AbstractValidatorFactory.php | 70 ---- .../Webauthn/CreationOptionsFactory.php | 110 ++++++ .../Webauthn/CredentialAssertionValidator.php | 70 ++++ .../CredentialAttestationValidator.php | 65 +++ .../Webauthn/CredentialRepository.php | 86 +--- src/Services/Webauthn/OptionsFactory.php | 52 +++ ...licKeyCredentialCreationOptionsFactory.php | 79 ---- ...blicKeyCredentialRequestOptionsFactory.php | 29 -- .../Webauthn/PublicKeyCredentialValidator.php | 168 -------- .../Webauthn/RequestOptionsFactory.php | 82 ++++ src/SingletonServiceProvider.php | 34 -- src/WebauthnServiceProvider.php | 373 ++++++++++++++++-- tests/Fake/FakeCredentialRepository.php | 4 +- tests/Fake/FakeWebauthn.php | 54 +-- tests/FeatureTestCase.php | 1 - tests/Unit/Actions/DeleteKeyTest.php | 54 +++ tests/Unit/Actions/LoginAttemptTest.php | 78 ++++ tests/Unit/Actions/UpdateKeyTest.php | 56 +++ .../Controllers/WebauthnControllerTest.php | 360 +++++++++++++++++ .../{ => Http/Middleware}/MiddlewareTest.php | 12 +- tests/Unit/{ => Models}/WebauthnKeyTest.php | 2 +- tests/Unit/PublishCommandTest.php | 17 - tests/Unit/Services/PsrHelpersTest.php | 53 +++ .../Webauthn}/CredentialRepositoryTest.php | 14 +- tests/Unit/{ => Services}/WebauthnTest.php | 43 +- tests/Unit/WebauthnControllerTest.php | 254 ------------ 80 files changed, 3119 insertions(+), 1635 deletions(-) create mode 100644 database/factories/WebauthnKeyFactory.php create mode 100644 docs/migration-v1-to-v2.md create mode 100644 src/Actions/DeleteKey.php create mode 100644 src/Actions/LoginAttempt.php create mode 100644 src/Actions/LoginPrepare.php create mode 100644 src/Actions/RegisterKeyPrepare.php create mode 100644 src/Actions/RegisterKeyStore.php create mode 100644 src/Actions/UpdateKey.php delete mode 100644 src/Console/PublishCommand.php create mode 100644 src/Contracts/DestroyResponse.php create mode 100644 src/Contracts/LoginSuccessResponse.php create mode 100644 src/Contracts/LoginViewResponse.php create mode 100644 src/Contracts/RegisterSuccessResponse.php create mode 100644 src/Contracts/RegisterViewResponse.php create mode 100644 src/Contracts/UpdateResponse.php create mode 100644 src/Events/EventFailed.php create mode 100644 src/Events/EventUser.php create mode 100644 src/Events/WebauthnLoginFailed.php create mode 100644 src/Events/WebauthnRegisterFailed.php create mode 100644 src/Http/Controllers/AuthenticateController.php delete mode 100644 src/Http/Controllers/WebauthnController.php create mode 100644 src/Http/Controllers/WebauthnKeyController.php create mode 100644 src/Http/Requests/WebauthnLoginRequest.php create mode 100644 src/Http/Requests/WebauthnRegisterRequest.php create mode 100644 src/Http/Requests/WebauthnUpdateRequest.php create mode 100644 src/Http/Responses/DestroyResponse.php create mode 100644 src/Http/Responses/LoginSuccessResponse.php create mode 100644 src/Http/Responses/LoginViewResponse.php create mode 100644 src/Http/Responses/RegisterSuccessResponse.php create mode 100644 src/Http/Responses/RegisterViewResponse.php create mode 100644 src/Http/Responses/UpdateResponse.php create mode 100644 src/Listeners/LoginViaRemember.php create mode 100644 src/Models/Casts/Base64.php create mode 100644 src/Models/Casts/TrustPath.php create mode 100644 src/Models/Casts/Uuid.php delete mode 100644 src/Services/Webauthn/AbstractFactory.php delete mode 100644 src/Services/Webauthn/AbstractOptionsFactory.php delete mode 100644 src/Services/Webauthn/AbstractValidatorFactory.php create mode 100644 src/Services/Webauthn/CreationOptionsFactory.php create mode 100644 src/Services/Webauthn/CredentialAssertionValidator.php create mode 100644 src/Services/Webauthn/CredentialAttestationValidator.php create mode 100644 src/Services/Webauthn/OptionsFactory.php delete mode 100644 src/Services/Webauthn/PublicKeyCredentialCreationOptionsFactory.php delete mode 100644 src/Services/Webauthn/PublicKeyCredentialRequestOptionsFactory.php delete mode 100644 src/Services/Webauthn/PublicKeyCredentialValidator.php create mode 100644 src/Services/Webauthn/RequestOptionsFactory.php delete mode 100644 src/SingletonServiceProvider.php create mode 100644 tests/Unit/Actions/DeleteKeyTest.php create mode 100644 tests/Unit/Actions/LoginAttemptTest.php create mode 100644 tests/Unit/Actions/UpdateKeyTest.php create mode 100644 tests/Unit/Http/Controllers/WebauthnControllerTest.php rename tests/Unit/{ => Http/Middleware}/MiddlewareTest.php (76%) rename tests/Unit/{ => Models}/WebauthnKeyTest.php (98%) delete mode 100644 tests/Unit/PublishCommandTest.php create mode 100644 tests/Unit/Services/PsrHelpersTest.php rename tests/Unit/{ => Services/Webauthn}/CredentialRepositoryTest.php (81%) rename tests/Unit/{ => Services}/WebauthnTest.php (84%) delete mode 100644 tests/Unit/WebauthnControllerTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9781ad1..442d8a9c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,25 +16,31 @@ on: workflow_dispatch: env: - php-version: '8.1' - laravel-version: '8.*' - node-version: 14 + default-php-version: '8.1' + default-laravel-version: '8.*' + semantic-node-version: 16 jobs: tests: runs-on: ubuntu-latest - name: PHP ${{ matrix.php-version }} | Laravel ${{ matrix.laravel_version }} + name: PHP ${{ matrix.php-version }} | Laravel ${{ matrix.laravel-version }} (${{ matrix.psr7 }}) strategy: fail-fast: false matrix: php-version: ['7.4', '8.0', '8.1'] - laravel_version: [6.*, 7.*, 8.*] + laravel-version: [7.*, 8.*] + psr7: ['guzzle'] exclude: - php-version: '8.1' - laravel_version: '6.*' + laravel-version: '7.*' + include: - php-version: '8.1' - laravel_version: '7.*' + laravel-version: '8.*' + psr7: 'nyholm' + - php-version: '8.1' + laravel-version: '8.*' + psr7: 'discovery' steps: - name: Checkout sources @@ -63,27 +69,45 @@ jobs: uses: actions/cache@v2.1.7 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }}-${{ matrix.php-version }}-${{ matrix.laravel_version }} + key: ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }}-${{ matrix.php-version }}-${{ matrix.laravel-version }} restore-keys: | - ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }}-${{ matrix.php-version }}-${{ matrix.laravel_version }} + ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }}-${{ matrix.php-version }}-${{ matrix.laravel-version }} ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }}-${{ matrix.php-version }} ${{ runner.os }}-composer-v4-${{ hashFiles('**/composer.json') }} ${{ runner.os }}-composer-v4- - - name: Install dependencies with Laravel ${{ matrix.laravel_version }} + - name: Update dependencies with Laravel ${{ matrix.laravel-version }} + run: | + export COMPOSER_ROOT_VERSION=dev-main + composer require "illuminate/support:${{ matrix.laravel-version }}" --no-update + + - name: Use psr7 variant (nyholm) + if: matrix.psr7 == 'nyholm' + run: | + composer remove psr/http-factory-implementation --no-update + composer remove --dev guzzlehttp/psr7 --no-update + composer require --dev symfony/psr-http-message-bridge nyholm/psr7 --no-update + + - name: Use psr7 variant (with php-http/discovery) + if: matrix.psr7 == 'discovery' run: | - export COMPOSER_ROOT_VERSION=dev-master - composer require "laravel/framework:${{ matrix.laravel_version }}" --no-interaction --no-progress --prefer-stable --prefer-dist + composer remove psr/http-factory-implementation --no-update + composer remove --dev guzzlehttp/psr7 --no-update + composer require --dev symfony/psr-http-message-bridge php-http/discovery laminas/laminas-diactoros php-http/curl-client --no-update + + - name: Install dependencies + run: | + composer update --no-interaction --no-progress --prefer-dist - name: Run test suite - run: phpdbg -dmemory_limit=4G -qrr vendor/bin/phpunit -c phpunit.xml --log-junit ./results/results.xml --coverage-clover ./results/coverage.xml + run: phpdbg -dmemory_limit=4G -qrr vendor/bin/phpunit -c phpunit.xml --log-junit ./results/results_${{ matrix.psr7 }}.xml --coverage-clover ./results/coverage_${{ matrix.psr7 }}.xml - name: Fix results files run: sed -i -e "s%$GITHUB_WORKSPACE/%%g" *.xml working-directory: results - name: Store results - if: matrix.php-version == '${{ env.php-version }}' && matrix.laravel_version == '${{ env.laravel-version }}' + if: matrix.php-version == env.default-php-version && matrix.laravel-version == env.default-laravel-version uses: actions/upload-artifact@v2 with: name: results @@ -112,12 +136,22 @@ jobs: name: results path: results + - name: Set coverage list + id: coverage + run: | + SONAR_COVERAGE=$(ls -m --format=comma results/coverage*.xml | sed -e ':a;N;$!ba;s/\n//g; s/ //g;') + echo "::set-output name=list::$SONAR_COVERAGE" + - name: SonarCloud Scan if: env.SONAR_TOKEN != '' uses: SonarSource/sonarcloud-github-action@v1.6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: | + -Dsonar.php.tests.reportPath=./results/results_guzzle.xml + -Dsonar.php.coverage.reportPaths=${{ steps.coverage.outputs.list }} #################### @@ -135,10 +169,10 @@ jobs: with: fetch-depth: 0 # Get all tags - - name: Use Node.js ${{ env.node-version }} + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: ${{ env.node-version }} + node-version: ${{ env.semantic-node-version }} - name: Semantic Release uses: cycjimmy/semantic-release-action@v2 @@ -149,7 +183,7 @@ jobs: with: semantic_version: 18 extra_plugins: | - @semantic-release/changelog + @semantic-release/changelog@6 semantic-release-github-pullrequest - name: New release published diff --git a/README.md b/README.md index 88cc47cb..ec0a635b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Webauthn adapter for Laravel +Webauthn adapter for Laravel ============================ LaravelWebauthn is an adapter to use Webauthn as 2FA (second-factor authentication) on Laravel. @@ -10,6 +10,21 @@ LaravelWebauthn is an adapter to use Webauthn as 2FA (second-factor authenticati [![Coverage Status](https://img.shields.io/sonar/coverage/asbiin_laravel-webauthn?server=https%3A%2F%2Fsonarcloud.io&style=flat-square&label=Coverage%20Status)](https://sonarcloud.io/dashboard?id=asbiin_laravel-webauthn) +- [Installation](#installation) + - [Configuration](#configuration) + - [Add LaravelWebauthn middleware](#add-laravelwebauthn-middleware) + - [Login via remember](#login-via-remember) +- [Usage](#usage) + - [Authenticate](#authenticate) + - [Register a new key](#register-a-new-key) + - [Important](#important) + - [Homestead](#homestead) + - [Routes](#routes) + - [Events](#events) + - [View response](#view-response) +- [Compatibility](#compatibility) +- [License](#license) + # Installation You may use Composer to install this package into your Laravel project: @@ -20,39 +35,6 @@ composer require asbiin/laravel-webauthn You don't need to add this package to your service providers. -## Support - -This package supports Laravel 5.8 and newer, and has been tested with php 7.2 and newer versions. - -It's based on [web-auth/webauthn-framework](https://github.com/web-auth/webauthn-framework). - - -## Important - -Your browser will refuse to negotiate a relay to your security device without the following: - -- domain (localhost and 127.0.0.1 will be rejected by `webauthn.js`) -- an SSL/TLS certificate trusted by your browser (self-signed is okay) -- connected HTTPS on port 443 (ports other than 443 will be rejected) - -### Homestead -If you are a Laravel Homestead user, the default is to forward ports. You can switch from NAT/port forwarding to a private network with similar `Homestead.yaml` options: - -```yaml -sites: - - map: homestead.test -networks: - - type: "private_network" - ip: "192.168.254.2" -``` - -Re-provisioning vagrant will inform your virtual machine of the new network and install self-signed SSL/TLS certificates automatically: `vagrant reload --provision` - -If you haven't done so already, describe your site domain and network in your hosts file: -``` -192.168.254.2 homestead.test -``` - ## Configuration @@ -60,7 +42,7 @@ You can publish the LaravelWebauthn configuration in a file named `config/webaut Just run this artisan command: ```sh -php artisan laravelwebauthn:publish +php artisan vendor:publish --provider="LaravelWebauthn\WebauthnServiceProvider" ``` If desired, you may disable LaravelWebauthn entirely using the `enabled` configuration option: @@ -87,6 +69,23 @@ Route::middleware(['auth', 'webauthn'])->group(function () { This way users would have to validate their key on login. +### Login via remember + +When session expires, but the user set the `remember` token, you can revalidate webauthn session by adding this in your `App\Providers\EventServiceProvider` file: + +```php +use Illuminate\Auth\Events\Login; +use LaravelWebauthn\Listeners\LoginViaRemember; + +class EventServiceProvider extends ServiceProvider +{ + protected $listen = [ + Login::class => [ + LoginViaRemember::class, + ], + ]; +... +``` # Usage @@ -184,10 +183,36 @@ If `postSuccessRedirectRoute` is empty, the return will be JSON form: } ``` +## Important + +Your browser will refuse to negotiate a relay to your security device without the following: + +- domain (localhost and 127.0.0.1 will be rejected by `webauthn.js`) +- an SSL/TLS certificate trusted by your browser (self-signed is okay) +- connected HTTPS on port 443 (ports other than 443 will be rejected) + +### Homestead +If you are a Laravel Homestead user, the default is to forward ports. You can switch from NAT/port forwarding to a private network with similar `Homestead.yaml` options: + +```yaml +sites: + - map: homestead.test +networks: + - type: "private_network" + ip: "192.168.254.2" +``` + +Re-provisioning vagrant will inform your virtual machine of the new network and install self-signed SSL/TLS certificates automatically: `vagrant reload --provision` + +If you haven't done so already, describe your site domain and network in your hosts file: +``` +192.168.254.2 homestead.test +``` -## Urls -These url are used +## Routes + +These reoutes are defined: * GET `/webauthn/auth` / `route('webauthn.login')` The login page. @@ -195,24 +220,83 @@ These url are used * POST `/webauthn/auth` / `route('webauthn.auth')` Post datas after a WebAuthn login validate. -* GET `/webauthn/register` / `route('webauthn.register')` +* GET `/webauthn/keys/create` / `route('webauthn.create')` Get datas to register a new key -* POST `/webauthn/register` / `route('webauthn.create')` +* POST `/webauthn/keys` / `route('webauthn.store')` Post datas after a WebAuthn register check -* DELETE `/webauthn/{id}` / `route('webauthn.destroy')` - Get register datas +* DELETE `/webauthn/keys/{id}` / `route('webauthn.destroy')` + Delete an existing key + +* UPDATE `/webauthn/keys/{id}` / `route('webauthn.update')` + Update key properties +You can modify the first part of the url by setting `prefix` value in the config file. + ## Events Events are dispatched by LaravelWebauthn: -* `\LaravelWebauthn\Events\WebauthnLoginData` on creating authentication datas -* `\LaravelWebauthn\Events\WebauthnLogin` on login with WebAuthn check -* `\LaravelWebauthn\Events\WebauthnRegisterData` on creating register datas +* `\LaravelWebauthn\Events\WebauthnLoginData` on preparing authentication data +* `\LaravelWebauthn\Events\WebauthnLogin` on login with Webauthn check +* `\LaravelWebauthn\Events\WebauthnLoginFailed` on a failed login check +* `\LaravelWebauthn\Events\WebauthnRegisterData` on preparing register data * `\LaravelWebauthn\Events\WebauthnRegister` on registering a new key +* `\LaravelWebauthn\Events\WebauthnRegisterFailed` on failing registering a new key + + +## View response + +You can easily change the view responses with the Webauthn service: + +```php +use LaravelWebauthn\Services\Webauthn; + +class AppServiceProvider extends ServiceProvider +{ + public function register() + { + Webauthn::loginViewResponseUsing(LoginViewResponse::class); + } +} +``` + +```php +use LaravelWebauthn\Http\Responses\LoginViewResponse as LoginViewResponseBase; + +class LoginViewResponse extends LoginViewResponseBase +{ + public function toResponse($request) + { + $publicKey = $this->publicKeyRequest($request); + + return Inertia::render('Webauthn/WebauthnLogin', [ + 'publicKey' => $publicKey + ]); + } +} +``` + +List of methods and their expected response contracts: + +| Webauthn | LaravelWebauthn\Contracts | +|------------------------------|---------------------------------| +| loginViewResponseUsing | LoginViewResponseContract | +| loginSuccessResponseUsing | LoginSuccessResponseContract | +| registerViewResponseUsing | RegisterViewResponseContract | +| registerSuccessResponseUsing | RegisterSuccessResponseContract | +| destroyViewResponseUsing | DestroyResponseContract | +| updateViewResponseUsing | UpdateResponseContract | + + +# Compatibility + +| Laravel | [asbiin/laravel-webauthn](https://github.com/asbiin/laravel-webauthn) | +|----------|----------| +| 5.8-8.x | <= 1.2.0 | +| 7.x-8.x | 2.0.0 | # License diff --git a/composer.json b/composer.json index 5664dbcf..93862633 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,8 @@ } ], "require": { - "guzzlehttp/psr7": "^1.5 || ^2.0", - "laravel/framework": "^5.8 || ^6.0 || ^7.0 || ^8.0", - "php-http/discovery": "^1.6", - "php-http/httplug": "^1.0 || ^2.0", - "php-http/message": "^1.7", - "psr/http-client": "^1.0", + "illuminate/support": "^7.0 || ^8.0", + "psr/http-factory-implementation": "1.0", "thecodingmachine/safe": "^1.0", "web-auth/cose-lib": "^3.0", "web-auth/webauthn-lib": "^3.0", @@ -33,23 +29,28 @@ }, "require-dev": { "ext-sqlite3": "*", + "guzzlehttp/psr7": "^2.1", "laravel/legacy-factories": "^1.0", - "nunomaduro/larastan": "^0.4 || ^0.5 || ^0.6 || ^0.7", + "nunomaduro/larastan": "^1.0", "ocramius/package-versions": "^1.5 || ^2.0", - "orchestra/testbench": "^3.5 || ^5.0 || ^6.0", - "phpstan/phpstan-deprecation-rules": "^0.12", - "phpstan/phpstan-phpunit": "^0.12", - "phpstan/phpstan-strict-rules": "^0.12", - "phpunit/phpunit": "^6.0 || ^7.0 || ^8.0 || ^9.0", + "orchestra/testbench": "^5.0 || ^6.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.0", "psalm/plugin-laravel": "^1.4", "thecodingmachine/phpstan-safe-rule": "^1.0", - "vimeo/psalm": "^3.9 || ^4.0" + "vimeo/psalm": "^4.0" }, "suggest": { - "php-http/client-implementation": "Recommended for the AndroidSafetyNet Attestation Statement support", - "web-token/jwt-signature-algorithm-rsa": "Mandatory for the AndroidSafetyNet Attestation Statement support", - "web-token/jwt-signature-algorithm-ecdsa": "Recommended for the AndroidSafetyNet Attestation Statement support", - "web-token/jwt-signature-algorithm-eddsa": "Recommended for the AndroidSafetyNet Attestation Statement support" + "guzzlehttp/psr7": "To provide a psr/http-factory-implementation implementation", + "symfony/psr-http-message-bridge": "To find a psr/http-factory-implementation implementation", + "php-http/discovery": "To find a psr/http-factory-implementation implementation", + "psr/http-client-implementation": "Required for the AndroidSafetyNet Attestation Statement support", + "web-token/jwt-signature-algorithm-rsa": "Required for the AndroidSafetyNet Attestation Statement support", + "web-token/jwt-signature-algorithm-ecdsa": "Required for the AndroidSafetyNet Attestation Statement support", + "web-token/jwt-signature-algorithm-eddsa": "Required for the AndroidSafetyNet Attestation Statement support", + "web-token/jwt-key-mgmt": "Required for the AndroidSafetyNet Attestation Statement support" }, "autoload": { "psr-4": { @@ -58,19 +59,22 @@ }, "autoload-dev": { "psr-4": { - "LaravelWebauthn\\Tests\\": "tests" + "LaravelWebauthn\\Tests\\": "tests", + "Database\\Factories\\LaravelWebauthn\\Models\\": "database/factories/" } }, "extra": { "laravel": { "providers": [ - "LaravelWebauthn\\SingletonServiceProvider", "LaravelWebauthn\\WebauthnServiceProvider" ] } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true + } }, "minimum-stability": "dev", "prefer-stable": true diff --git a/config/webauthn.php b/config/webauthn.php index 0ac5ce2b..3b141a7e 100644 --- a/config/webauthn.php +++ b/config/webauthn.php @@ -40,64 +40,38 @@ 'prefix' => 'webauthn', - 'authenticate' => [ - /* - |-------------------------------------------------------------------------- - | View to load after middleware login request. - |-------------------------------------------------------------------------- - | - | The name of blade template to load whe a user login and it request to validate - | the Webauthn 2nd factor. - | - */ - 'view' => 'webauthn::authenticate', - - /* - |-------------------------------------------------------------------------- - | Redirect with callback url after login. - |-------------------------------------------------------------------------- - | - | Save the destination url, then after a succesful login, redirect to this - | url. - | - */ - 'postSuccessCallback' => true, - - /* - |-------------------------------------------------------------------------- - | Redirect route - |-------------------------------------------------------------------------- - | - | If postSuccessCallback if false, redirect to this route after login - | request is complete. - | If empty, send a json response to let the client side redirection. - | - */ - 'postSuccessRedirectRoute' => '', + /* + |-------------------------------------------------------------------------- + | Redirect routes + |-------------------------------------------------------------------------- + | + | When using navigation, redirects to these url on success: + | - login: after a successfull login. + | - register: after a successfull Webauthn key creation. + | + | Redirects are not used in case of application/json requests. + | + */ + + 'redirects' => [ + 'login' => null, + 'register' => null, ], - 'register' => [ - /* - |-------------------------------------------------------------------------- - | View to load on register request. - |-------------------------------------------------------------------------- - | - | The name of blade template to load when a user request a creation of - | Webauthn key. - | - */ - 'view' => 'webauthn::register', - - /* - |-------------------------------------------------------------------------- - | Redirect route - |-------------------------------------------------------------------------- - | - | The route to redirect to after register key request is complete. - | If empty, send a json response to let the client side redirection. - | - */ - 'postSuccessRedirectRoute' => '/', + /* + |-------------------------------------------------------------------------- + | View to load after middleware login request. + |-------------------------------------------------------------------------- + | + | The name of blade template to load: + | - authenticate: when a user login, and has to validate Webauthn 2nd factor. + | - register: when a user request to create a Webauthn key. + | + */ + + 'views' => [ + 'authenticate' => 'webauthn::authenticate', + 'register' => 'webauthn::register', ], /* @@ -155,7 +129,7 @@ | */ - 'icon' => null, + 'icon' => env('WEBAUTHN_ICON'), /* |-------------------------------------------------------------------------- @@ -164,11 +138,12 @@ | | This parameter specify the preference regarding the attestation conveyance | during credential generation. - | See https://www.w3.org/TR/webauthn/#attestation-convey + | See https://www.w3.org/TR/webauthn/#enum-attestation-convey | + | Supported: "none", "indirect", "direct", "enterprise". */ - 'attestation_conveyance' => \Webauthn\PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + 'attestation_conveyance' => 'none', /* |-------------------------------------------------------------------------- @@ -180,7 +155,7 @@ | */ - 'google_safetynet_api_key' => '', + 'google_safetynet_api_key' => env('GOOGLE_SAFETYNET_API_KEY'), /* |-------------------------------------------------------------------------- @@ -188,38 +163,69 @@ |-------------------------------------------------------------------------- | | List of allowed Cryptographic Algorithm Identifier. - | See https://www.w3.org/TR/webauthn/#alg-identifier + | See https://www.w3.org/TR/webauthn/#sctn-alg-identifier | */ 'public_key_credential_parameters' => [ - \Cose\Algorithms::COSE_ALGORITHM_ES256, - \Cose\Algorithms::COSE_ALGORITHM_RS256, + \Cose\Algorithms::COSE_ALGORITHM_ES256, // ECDSA with SHA-256 + \Cose\Algorithms::COSE_ALGORITHM_ES512, // ECDSA with SHA-512 + \Cose\Algorithms::COSE_ALGORITHM_RS256, // RSASSA-PKCS1-v1_5 with SHA-256 + \Cose\Algorithms::COSE_ALGORITHM_EdDSA, // EdDSA + \Cose\Algorithms::COSE_ALGORITHM_ES384, // ECDSA with SHA-384 ], /* |-------------------------------------------------------------------------- - | Webauthn Authenticator Selection Criteria + | Credentials Attachment. |-------------------------------------------------------------------------- | - | Requirement for the creation operation. - | See https://www.w3.org/TR/webauthn/#authenticatorSelection + | Authentication can be tied to the current device (like when using Windows + | Hello or Touch ID) or a cross-platform device (like USB Key). When this + | is "null" the user will decide where to store his authentication info. + | + | See https://www.w3.org/TR/webauthn/#enum-attachment + | + | Supported: "null", "cross-platform", "platform". | */ - 'authenticator_selection_criteria' => [ + 'attachment_mode' => null, + + /* + |-------------------------------------------------------------------------- + | User presence and verification + |-------------------------------------------------------------------------- + | + | Most authenticators and smartphones will ask the user to actively verify + | themselves for log in. Use "required" to always ask verify, "preferred" + | to ask when possible, and "discouraged" to just ask for user presence. + | + | See https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement + | + | Supported: "required", "preferred", "discouraged". + | + */ - /* - | See https://www.w3.org/TR/webauthn/#attachment - */ - 'attachment_mode' => \Webauthn\AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE, + 'user_verification' => 'preferred', - 'require_resident_key' => false, + /* + |-------------------------------------------------------------------------- + | Userless (One touch, Typeless) login + |-------------------------------------------------------------------------- + | + | By default, users must input their email to receive a list of credentials + | ID to use for authentication, but they can also login without specifying + | one if the device can remember them, allowing for true one-touch login. + | + | If required or preferred, login verification will be always required. + | + | See https://www.w3.org/TR/webauthn/#enum-residentKeyRequirement + | + | Supported: "null", "required", "preferred", "discouraged". + | + */ - /* - | See https://www.w3.org/TR/webauthn/#userVerificationRequirement - */ - 'user_verification' => \Webauthn\AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED, - ], + 'userless' => null, ]; diff --git a/database/factories/WebauthnKeyFactory.php b/database/factories/WebauthnKeyFactory.php new file mode 100644 index 00000000..e6f91f70 --- /dev/null +++ b/database/factories/WebauthnKeyFactory.php @@ -0,0 +1,43 @@ + function (array $attributes) { + $user = new Authenticated(); + $user->email = 'john@doe.com'; + + return $user->getAuthIdentifier(); + }, + 'name' => 'key', + 'credentialId' => 'credentialId', + 'type' => 'public-key', + 'transports' => 'transports', + 'attestationType' => 'attestationType', + 'trustPath' => 'trustPath', + 'aaguid' => 'aaguid', + 'credentialPublicKey' => 'credentialPublicKey', + 'counter' => 0, + ]; + } +} diff --git a/database/migrations/2019_03_29_163611_add_webauthn.php b/database/migrations/2019_03_29_163611_add_webauthn.php index 7255dcef..5a7e1aa8 100644 --- a/database/migrations/2019_03_29_163611_add_webauthn.php +++ b/database/migrations/2019_03_29_163611_add_webauthn.php @@ -14,7 +14,7 @@ class AddWebauthn extends Migration public function up() { Schema::create('webauthn_keys', function (Blueprint $table) { - $table->increments('id'); + $table->id(); $table->bigInteger('user_id')->unsigned(); $table->string('name')->default('key'); @@ -25,7 +25,7 @@ public function up() $table->text('trustPath'); $table->text('aaguid'); $table->text('credentialPublicKey'); - $table->integer('counter'); + $table->bigInteger('counter')->unsigned(); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 00000000..92d3ca10 --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,82 @@ +# Migration from v1 to v2 + +- [Routes](#routes) +- [Config file](#config-file) + - [Navigation](#navigation) + - [Facade](#facade) +- [Dependencies](#dependencies) + - [Laravel](#laravel) + - [PSR-7](#psr-7) + +V2 of Webauthn introduces multiple breaking changes: + + +## Routes + +| Request | Route v1 | Route v2 | +|------------------------------|-------------------|-----------------| +| GET `/webauthn/register` | webauthn.register | webauthn.create | +| POST `/webauthn/register` | webauthn.create | webauthn.store | +| UPDATE `/webauthn/keys/{id}` | | webauthn.update | + +Other routes are not modified: +- GET `/webauthn/auth` / `route('webauthn.login')` +- POST `/webauthn/auth` / `route('webauthn.auth')` +- DELETE `/webauthn/keys/{id}` / `route('webauthn.destroy')` + + +## Config file + +`config/webauthn.php` file structure has changed. + +You should re-publish it with +```console +php artisan vendor:publish --provider="LaravelWebauthn\WebauthnServiceProvider" +``` + +### Navigation + +`authenticate` and `register` arrays are not used anymore. + +To define how navigation is handled after a success login or register key, you can now set the `redirects` array: + +```php + 'redirects' => [ + 'login' => null, + 'register' => null, + ]; +``` + +You can define here the urls to redirect to after a success login or register key. +Note that redirects are not used in case of application/json requests. + + +### Facade + +`LaravelWebauthn\Facades\Webauthn` facade has changed. + +* Removed methods: + - getRegisterData(\Illuminate\Contracts\Auth\Authenticatable $user) + - doRegister(\Illuminate\Contracts\Auth\Authenticatable $user, PublicKeyCredentialCreationOptions $publicKey, string $data, string $keyName) + - getAuthenticateData(\Illuminate\Contracts\Auth\Authenticatable $user) + - doAuthenticate(\Illuminate\Contracts\Auth\Authenticatable $user, PublicKeyCredentialRequestOptions $publicKey, string $data) + - forceAuthenticate() + +* New methods: + - create(\Illuminate\Contracts\Auth\Authenticatable $user, string $keyName, \Webauthn\PublicKeyCredentialSource $publicKeyCredentialSource) + - login() + - logout() + - webauthnEnabled() + - hasKey(\Illuminate\Contracts\Auth\Authenticatable $user) + + +## Dependencies + +### Laravel + +v2 requires Laravel 7 or later. + +### PSR-7 + +`guzzlehttp/psr7` is no longer a required dependency. +However, you will need a `psr/http-factory-implementation` implementation, like `guzzlehttp/psr7`. diff --git a/phpstan.neon b/phpstan.neon index 6e549590..95ce5551 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,6 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon - - ./vendor/phpstan/phpstan-phpunit/extension.neon - ./vendor/phpstan/phpstan-deprecation-rules/rules.neon - ./vendor/phpstan/phpstan-strict-rules/rules.neon @@ -12,8 +11,7 @@ parameters: level: 5 ignoreErrors: # Level 2 - - '#Call to an undefined method Illuminate\\View\\View::with[a-zA-Z0-9\\_]+\(\)\.#' - - '#Access to an undefined property LaravelWebauthn\\Models\\WebauthnKey::\$.*\.#' + - '#Access to an undefined property LaravelWebauthn\\Models\\WebauthnKey::.*\.#' - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable::\$email\.#' - '#Dynamic call to static method Illuminate\\Database\\Eloquent\\Builder::count\(\)\.#' excludes_analyse: diff --git a/psalm.xml b/psalm.xml index 539af1b6..7d61283a 100644 --- a/psalm.xml +++ b/psalm.xml @@ -53,23 +53,11 @@ - - - - - - - + - - - - - - diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index d0279cea..e05ae9fb 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -1,10 +1,11 @@ 'Authentication failed', 'user_unauthenticated' => 'You need to log in before doing a Webauthn authentication', 'auth_data_not_found' => 'Authentication data not found', 'create_data_not_found' => 'Register data not found', - 'cannot_register_new_key' => 'You need to log in before adding a new Webauthn authentication', + 'cannot_register_new_key' => 'The user cannot register a new key at this time', 'object_not_found' => 'Object not found', 'not_supported' => 'Your browser doesn’t currently support WebAuthn.', diff --git a/resources/views/register.blade.php b/resources/views/register.blade.php index 8bf5b022..90d70e9a 100644 --- a/resources/views/register.blade.php +++ b/resources/views/register.blade.php @@ -63,7 +63,7 @@ -
+ @csrf diff --git a/src/Actions/DeleteKey.php b/src/Actions/DeleteKey.php new file mode 100644 index 00000000..8690d39f --- /dev/null +++ b/src/Actions/DeleteKey.php @@ -0,0 +1,28 @@ +getAuthIdentifier()) + ->findOrFail($webauthnKeyId) + ->delete(); + + if (! Webauthn::hasKey($user)) { + Webauthn::logout(); + } + } +} diff --git a/src/Actions/LoginAttempt.php b/src/Actions/LoginAttempt.php new file mode 100644 index 00000000..962f3fee --- /dev/null +++ b/src/Actions/LoginAttempt.php @@ -0,0 +1,78 @@ +app = $app; + } + + /** + * Authenticate a user. + * + * @param Authenticatable $user + * @param PublicKeyCredentialRequestOptions $publicKey + * @param string $data + * @return bool + */ + public function __invoke(Authenticatable $user, PublicKeyCredentialRequestOptions $publicKey, string $data): bool + { + try { + $result = $this->app[CredentialAssertionValidator::class]($user, $publicKey, $data); + + if ($result === true) { + Webauthn::login(); + + WebauthnLogin::dispatch($user); + + return true; + } + } catch (Exception $e) { + $this->throwFailedLoginException($user, $e); + } + + return false; + } + + /** + * Throw a failed login validation exception. + * + * @param Authenticatable $user + * @param Exception|null $e + * @return void + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function throwFailedLoginException(Authenticatable $user, ?Exception $e = null) + { + WebauthnLoginFailed::dispatch($user, $e); + + throw ValidationException::withMessages([ + 'data' => [trans('webauthn::errors.login_failed')], + ]); + } +} diff --git a/src/Actions/LoginPrepare.php b/src/Actions/LoginPrepare.php new file mode 100644 index 00000000..0b236488 --- /dev/null +++ b/src/Actions/LoginPrepare.php @@ -0,0 +1,44 @@ +app = $app; + } + + /** + * Get data to authenticate a user. + * + * @param Authenticatable $user + * @return PublicKeyCredentialRequestOptions + */ + public function __invoke(Authenticatable $user): PublicKeyCredentialRequestOptions + { + $publicKey = $this->app[RequestOptionsFactory::class]($user); + + WebauthnLoginData::dispatch($user, $publicKey); + + return $publicKey; + } +} diff --git a/src/Actions/RegisterKeyPrepare.php b/src/Actions/RegisterKeyPrepare.php new file mode 100644 index 00000000..53850ca0 --- /dev/null +++ b/src/Actions/RegisterKeyPrepare.php @@ -0,0 +1,70 @@ +app = $app; + } + + /** + * Get data to register a new key. + * + * @param Authenticatable $user + * @return PublicKeyCredentialCreationOptions + */ + public function __invoke(Authenticatable $user): PublicKeyCredentialCreationOptions + { + if (! Webauthn::canRegister($user)) { + $this->throwFailedRegisterException($user); + } + + $publicKey = $this->app[CreationOptionsFactory::class]($user); + + WebauthnRegisterData::dispatch($user, $publicKey); + + return $publicKey; + } + + /** + * Throw a failed register validation exception. + * + * @param Authenticatable $user + * @param Exception|null $e + * @return void + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function throwFailedRegisterException($user, ?Exception $e = null) + { + WebauthnRegisterFailed::dispatch($user, $e); + + throw ValidationException::withMessages([ + 'register' => [trans('webauthn::errors.cannot_register_new_key')], + ]); + } +} diff --git a/src/Actions/RegisterKeyStore.php b/src/Actions/RegisterKeyStore.php new file mode 100644 index 00000000..84954c92 --- /dev/null +++ b/src/Actions/RegisterKeyStore.php @@ -0,0 +1,84 @@ +app = $app; + } + + /** + * Register a new key. + * + * @param Authenticatable $user + * @param PublicKeyCredentialCreationOptions $publicKey + * @param string $data + * @param string $keyName + * @return WebauthnKey|null + */ + public function __invoke(Authenticatable $user, PublicKeyCredentialCreationOptions $publicKey, string $data, string $keyName): ?WebauthnKey + { + if (! Webauthn::canRegister($user)) { + $this->throwFailedRegisterException($user); + } + + try { + $publicKeyCredentialSource = $this->app[CredentialAttestationValidator::class]($publicKey, $data); + + $webauthnKey = Webauthn::create($user, $keyName, $publicKeyCredentialSource); + + WebauthnRegister::dispatch($webauthnKey); + + Webauthn::login(); + + return $webauthnKey; + } catch (Exception $e) { + $this->throwFailedRegisterException($user, $e); + } + + return null; + } + + /** + * Throw a failed register validation exception. + * + * @param Authenticatable $user + * @param Exception|null $e + * @return void + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function throwFailedRegisterException(Authenticatable $user, ?Exception $e = null) + { + WebauthnRegisterFailed::dispatch($user, $e); + + throw ValidationException::withMessages([ + 'register' => [trans('webauthn::errors.cannot_register_new_key')], + ]); + } +} diff --git a/src/Actions/UpdateKey.php b/src/Actions/UpdateKey.php new file mode 100644 index 00000000..4ea4ad03 --- /dev/null +++ b/src/Actions/UpdateKey.php @@ -0,0 +1,27 @@ +getAuthIdentifier()) + ->findOrFail($webauthnKeyId); + + $webauthnKey->name = $keyName; + $webauthnKey->save(); + + return $webauthnKey; + } +} diff --git a/src/Console/PublishCommand.php b/src/Console/PublishCommand.php deleted file mode 100644 index de9e1118..00000000 --- a/src/Console/PublishCommand.php +++ /dev/null @@ -1,49 +0,0 @@ -publish('webauthn-config'); - $this->publish('webauthn-migrations'); - $this->publish('webauthn-assets'); - $this->publish('webauthn-views'); - } - - /** - * Publish one asset. - * - * @param string $tag - * @return void - */ - private function publish($tag) - { - $this->call('vendor:publish', [ - '--tag' => $tag, - '--force' => $this->option('force'), - ]); - } -} diff --git a/src/Contracts/DestroyResponse.php b/src/Contracts/DestroyResponse.php new file mode 100644 index 00000000..24a4212f --- /dev/null +++ b/src/Contracts/DestroyResponse.php @@ -0,0 +1,10 @@ +exception = $exception; + } +} diff --git a/src/Events/EventUser.php b/src/Events/EventUser.php new file mode 100644 index 00000000..50ff060c --- /dev/null +++ b/src/Events/EventUser.php @@ -0,0 +1,29 @@ +user = $user; + } +} diff --git a/src/Events/WebauthnLogin.php b/src/Events/WebauthnLogin.php index 549d9e01..2487fdbb 100644 --- a/src/Events/WebauthnLogin.php +++ b/src/Events/WebauthnLogin.php @@ -2,27 +2,6 @@ namespace LaravelWebauthn\Events; -use Illuminate\Contracts\Auth\Authenticatable as User; -use Illuminate\Queue\SerializesModels; - -class WebauthnLogin +class WebauthnLogin extends EventUser { - use SerializesModels; - - /** - * The authenticated user. - * - * @var \Illuminate\Contracts\Auth\Authenticatable - */ - public $user; - - /** - * Create a new event instance. - * - * @param \Illuminate\Contracts\Auth\Authenticatable $user - */ - public function __construct(User $user) - { - $this->user = $user; - } } diff --git a/src/Events/WebauthnLoginData.php b/src/Events/WebauthnLoginData.php index eaffa682..881253a5 100644 --- a/src/Events/WebauthnLoginData.php +++ b/src/Events/WebauthnLoginData.php @@ -2,21 +2,11 @@ namespace LaravelWebauthn\Events; -use Illuminate\Contracts\Auth\Authenticatable as User; -use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Auth\Authenticatable; use Webauthn\PublicKeyCredentialRequestOptions; -class WebauthnLoginData +class WebauthnLoginData extends EventUser { - use SerializesModels; - - /** - * The authenticated user. - * - * @var \Illuminate\Contracts\Auth\Authenticatable - */ - public $user; - /** * The authentication data. * @@ -30,9 +20,9 @@ class WebauthnLoginData * @param \Illuminate\Contracts\Auth\Authenticatable $user * @param PublicKeyCredentialRequestOptions $publicKey */ - public function __construct(User $user, PublicKeyCredentialRequestOptions $publicKey) + public function __construct(Authenticatable $user, PublicKeyCredentialRequestOptions $publicKey) { - $this->user = $user; + parent::__construct($user); $this->publicKey = $publicKey; } } diff --git a/src/Events/WebauthnLoginFailed.php b/src/Events/WebauthnLoginFailed.php new file mode 100644 index 00000000..00f7bbe5 --- /dev/null +++ b/src/Events/WebauthnLoginFailed.php @@ -0,0 +1,7 @@ +user = $user; + parent::__construct($user); $this->publicKey = $publicKey; } } diff --git a/src/Events/WebauthnRegisterFailed.php b/src/Events/WebauthnRegisterFailed.php new file mode 100644 index 00000000..f469ac70 --- /dev/null +++ b/src/Events/WebauthnRegisterFailed.php @@ -0,0 +1,7 @@ +app = $app; + } + + /** + * Show the login Webauthn request after a login authentication. + * + * @param \Illuminate\Http\Request $request + * @return LoginViewResponse + */ + public function login(Request $request) + { + $publicKey = $this->app[LoginPrepare::class]($request->user()); + + $request->session()->put(WebauthnService::SESSION_PUBLICKEY_REQUEST, $publicKey); + + return $this->app[LoginViewResponse::class]; + } + + /** + * Authenticate a webauthn request. + * + * @param WebauthnLoginRequest $request + * @return LoginSuccessResponse + */ + public function auth(WebauthnLoginRequest $request) + { + $publicKey = $request->session()->pull(WebauthnService::SESSION_PUBLICKEY_REQUEST); + + if (! $publicKey instanceof \Webauthn\PublicKeyCredentialRequestOptions) { + Log::debug('Webauthn wrong publickKey type'); + abort(404); + } + + $this->app[LoginAttempt::class]( + $request->user(), + $publicKey, + $request->input('data') + ); + + return $this->app[LoginSuccessResponse::class]; + } +} diff --git a/src/Http/Controllers/WebauthnController.php b/src/Http/Controllers/WebauthnController.php deleted file mode 100644 index 90fdfbc3..00000000 --- a/src/Http/Controllers/WebauthnController.php +++ /dev/null @@ -1,273 +0,0 @@ -config = $config; - } - - /** - * Show the login Webauthn request after a login authentication. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse|\Illuminate\View\View - */ - public function login(Request $request) - { - $publicKey = Webauthn::getAuthenticateData($request->user()); - - $request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey); - - return $this->redirectViewAuth($request, $publicKey); - } - - /** - * Return the redirect destination on login. - * - * @param Request $request - * @param PublicKeyCredentialRequestOptions $publicKey - * @return \Illuminate\Http\JsonResponse|\Illuminate\View\View - */ - protected function redirectViewAuth(Request $request, PublicKeyCredentialRequestOptions $publicKey) - { - if ($this->config->get('webauthn.authenticate.view', '') !== '') { - return view($this->config->get('webauthn.authenticate.view')) - ->withPublicKey($publicKey); - } else { - return Response::json([ - 'publicKey' => $publicKey, - ]); - } - } - - /** - * Authenticate a webauthn request. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse - */ - public function auth(Request $request) - { - try { - $publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_REQUEST); - if (! $publicKey instanceof PublicKeyCredentialRequestOptions) { - throw new ModelNotFoundException(trans('webauthn::errors.auth_data_not_found')); - } - - $result = Webauthn::doAuthenticate( - $request->user(), - $publicKey, - $this->input($request, 'data') - ); - - return $this->redirectAfterSuccessAuth($request, $result); - } catch (\Exception $e) { - return Response::json([ - 'error' => [ - 'message' => $e->getMessage(), - ], - ], 403); - } - } - - /** - * Return the redirect destination after a successfull auth. - * - * @param bool $result - * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse - */ - protected function redirectAfterSuccessAuth(Request $request, bool $result) - { - if ((bool) $this->config->get('webauthn.authenticate.postSuccessCallback', true)) { - return Redirect::intended(); - } elseif ($this->config->get('webauthn.authenticate.postSuccessRedirectRoute', '') !== '') { - return Redirect::intended($this->config->get('webauthn.authenticate.postSuccessRedirectRoute')); - } else { - $callback = $request->session()->pull('url.intended', '/'); - - return Response::json([ - 'result' => $result, - 'callback' => $callback, - ]); - } - } - - /** - * Return the register data to attempt a Webauthn registration. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse|\Illuminate\View\View - */ - public function register(Request $request) - { - if (! Webauthn::canRegister($request->user())) { - return Response::json([ - 'error' => [ - 'message' => trans('webauthn::errors.cannot_register_new_key'), - ], - ], 403); - } - - $publicKey = Webauthn::getRegisterData($request->user()); - - $request->session()->put(self::SESSION_PUBLICKEY_CREATION, $publicKey); - - return $this->redirectViewRegister($request, $publicKey); - } - - /** - * Return the redirect destination on register. - * - * @param Request $request - * @param PublicKeyCredentialCreationOptions $publicKey - * @return \Illuminate\Http\JsonResponse|\Illuminate\View\View - */ - protected function redirectViewRegister(Request $request, PublicKeyCredentialCreationOptions $publicKey) - { - if ($this->config->get('webauthn.register.view', '') !== '') { - return view($this->config->get('webauthn.register.view')) - ->withPublicKey($publicKey) - ->withName($request->input('name')); - } else { - return Response::json([ - 'publicKey' => $publicKey, - ]); - } - } - - /** - * Validate and create the Webauthn request. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse - */ - public function create(Request $request) - { - if (! Webauthn::canRegister($request->user())) { - return Response::json([ - 'error' => [ - 'message' => trans('webauthn::errors.cannot_register_new_key'), - ], - ], 403); - } - - try { - $publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_CREATION); - if (! $publicKey instanceof PublicKeyCredentialCreationOptions) { - throw new ModelNotFoundException(trans('webauthn::errors.create_data_not_found')); - } - - $webauthnKey = Webauthn::doRegister( - $request->user(), - $publicKey, - $this->input($request, 'register'), - $this->input($request, 'name') - ); - - return $this->redirectAfterSuccessRegister($webauthnKey); - } catch (\Exception $e) { - return Response::json([ - 'error' => [ - 'message' => $e->getMessage(), - ], - ], 403); - } - } - - /** - * Return the redirect destination after a successfull register. - * - * @param WebauthnKey $webauthnKey - * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse - */ - protected function redirectAfterSuccessRegister(WebauthnKey $webauthnKey) - { - if ($this->config->get('webauthn.register.postSuccessRedirectRoute', '') !== '') { - return Redirect::intended($this->config->get('webauthn.register.postSuccessRedirectRoute')); - } else { - return Response::json([ - 'result' => true, - 'id' => $webauthnKey->id, - 'object' => 'webauthnKey', - 'name' => $webauthnKey->name, - 'counter' => $webauthnKey->counter, - ], 201); - } - } - - /** - * Remove an existing Webauthn key. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse - */ - public function destroy(Request $request, int $webauthnKeyId) - { - try { - WebauthnKey::where('user_id', $request->user()->getAuthIdentifier()) - ->findOrFail($webauthnKeyId) - ->delete(); - - return Response::json([ - 'deleted' => true, - 'id' => $webauthnKeyId, - ]); - } catch (ModelNotFoundException $e) { - return Response::json([ - 'error' => [ - 'message' => trans('webauthn::errors.object_not_found'), - ], - ], 404); - } - } - - /** - * Retrieve the input with a string result. - * - * @param \Illuminate\Http\Request $request - * @param string $name - * @param string $default - * @return string - */ - private function input(Request $request, string $name, string $default = ''): string - { - $result = $request->input($name); - - return is_string($result) ? $result : $default; - } -} diff --git a/src/Http/Controllers/WebauthnKeyController.php b/src/Http/Controllers/WebauthnKeyController.php new file mode 100644 index 00000000..ea084fa7 --- /dev/null +++ b/src/Http/Controllers/WebauthnKeyController.php @@ -0,0 +1,119 @@ +app = $app; + } + + /** + * Return the register data to attempt a Webauthn registration. + * + * @param \Illuminate\Http\Request $request + * @return RegisterViewResponse + */ + public function create(Request $request) + { + $publicKey = $this->app[RegisterKeyPrepare::class]($request->user()); + + $request->session()->put(Webauthn::SESSION_PUBLICKEY_CREATION, $publicKey); + + return $this->app[RegisterViewResponse::class]; + } + + /** + * Validate and create the Webauthn request. + * + * @param WebauthnRegisterRequest $request + * @return RegisterSuccessResponse + */ + public function store(WebauthnRegisterRequest $request) + { + $publicKey = $request->session()->pull(Webauthn::SESSION_PUBLICKEY_CREATION); + + if (! $publicKey instanceof \Webauthn\PublicKeyCredentialCreationOptions) { + Log::debug('Webauthn wrong publickKey type'); + abort(404); + } + + /** @var \LaravelWebauthn\Models\WebauthnKey|null */ + $webauthnKey = $this->app[RegisterKeyStore::class]( + $request->user(), + $publicKey, + $request->input('register'), + $request->input('name') + ); + + if ($webauthnKey !== null) { + $request->session()->put(Webauthn::SESSION_WEBAUTHNID_CREATED, $webauthnKey->id); + } + + return $this->app[RegisterSuccessResponse::class]; + } + + /** + * Update an existing Webauthn key. + * + * @param WebauthnUpdateRequest $request + * @param int $webauthnKeyId + * @return UpdateResponse + */ + public function update(WebauthnUpdateRequest $request, int $webauthnKeyId) + { + $this->app[UpdateKey::class]( + $request->user(), + $webauthnKeyId, + $request->input('name') + ); + + return $this->app[UpdateResponse::class]; + } + + /** + * Delete an existing Webauthn key. + * + * @param \Illuminate\Http\Request $request + * @param int $webauthnKeyId + * @return DestroyResponse + */ + public function destroy(Request $request, int $webauthnKeyId) + { + $this->app[DeleteKey::class]( + $request->user(), + $webauthnKeyId + ); + + return $this->app[DestroyResponse::class]; + } +} diff --git a/src/Http/Middleware/WebauthnMiddleware.php b/src/Http/Middleware/WebauthnMiddleware.php index 5aa89549..5be3234d 100644 --- a/src/Http/Middleware/WebauthnMiddleware.php +++ b/src/Http/Middleware/WebauthnMiddleware.php @@ -4,19 +4,11 @@ use Closure; use Illuminate\Contracts\Auth\Factory as AuthFactory; -use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Support\Facades\Redirect; use LaravelWebauthn\Facades\Webauthn; class WebauthnMiddleware { - /** - * The config repository instance. - * - * @var \Illuminate\Contracts\Config\Repository - */ - protected $config; - /** * The auth factory instance. * @@ -27,12 +19,10 @@ class WebauthnMiddleware /** * Create a Webauthn. * - * @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Contracts\Auth\Factory $auth */ - public function __construct(Config $config, AuthFactory $auth) + public function __construct(AuthFactory $auth) { - $this->config = $config; $this->auth = $auth; } @@ -46,8 +36,7 @@ public function __construct(Config $config, AuthFactory $auth) */ public function handle($request, Closure $next, $guard = null) { - if ((bool) $this->config->get('webauthn.enable', true) && - ! Webauthn::check()) { + if (Webauthn::webauthnEnabled() && ! Webauthn::check()) { abort_if($this->auth->guard($guard)->guest(), 401, trans('webauthn::errors.user_unauthenticated')); if (Webauthn::enabled($request->user($guard))) { diff --git a/src/Http/Requests/WebauthnLoginRequest.php b/src/Http/Requests/WebauthnLoginRequest.php new file mode 100644 index 00000000..eea73c1e --- /dev/null +++ b/src/Http/Requests/WebauthnLoginRequest.php @@ -0,0 +1,20 @@ + 'required|string', + ]; + } +} diff --git a/src/Http/Requests/WebauthnRegisterRequest.php b/src/Http/Requests/WebauthnRegisterRequest.php new file mode 100644 index 00000000..9211614b --- /dev/null +++ b/src/Http/Requests/WebauthnRegisterRequest.php @@ -0,0 +1,21 @@ + 'required|string', + 'name' => 'required|string', + ]; + } +} diff --git a/src/Http/Requests/WebauthnUpdateRequest.php b/src/Http/Requests/WebauthnUpdateRequest.php new file mode 100644 index 00000000..a7f26d9b --- /dev/null +++ b/src/Http/Requests/WebauthnUpdateRequest.php @@ -0,0 +1,20 @@ + 'required|string', + ]; + } +} diff --git a/src/Http/Responses/DestroyResponse.php b/src/Http/Responses/DestroyResponse.php new file mode 100644 index 00000000..59fce215 --- /dev/null +++ b/src/Http/Responses/DestroyResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? Response::noContent() + : back()->with('status', 'webauthn-destroyed'); + } +} diff --git a/src/Http/Responses/LoginSuccessResponse.php b/src/Http/Responses/LoginSuccessResponse.php new file mode 100644 index 00000000..97b5df4c --- /dev/null +++ b/src/Http/Responses/LoginSuccessResponse.php @@ -0,0 +1,40 @@ +wantsJson() + ? $this->jsonResponse($request) + : Redirect::intended(Webauthn::redirects('login')); + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function jsonResponse($request) + { + $callback = $request->session()->pull('url.intended', Webauthn::redirects('login')); + + return Response::json([ + 'result' => Webauthn::check(), + 'callback' => $callback, + ]); + } +} diff --git a/src/Http/Responses/LoginViewResponse.php b/src/Http/Responses/LoginViewResponse.php new file mode 100644 index 00000000..1fde200d --- /dev/null +++ b/src/Http/Responses/LoginViewResponse.php @@ -0,0 +1,52 @@ +config = $config; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + $publicKey = $this->publicKeyRequest($request); + + $view = $this->config->get('webauthn.views.authenticate', ''); + + return $request->wantsJson() + ? Response::json(['publicKey' => $publicKey]) + : Response::view($view, ['publicKey' => $publicKey]); + } + + /** + * Get public key request data. + * + * @param \Illuminate\Http\Request $request + * @return \Webauthn\PublicKeyCredentialRequestOptions + */ + protected function publicKeyRequest($request): PublicKeyCredentialRequestOptions + { + return $request->session()->get(Webauthn::SESSION_PUBLICKEY_REQUEST); + } +} diff --git a/src/Http/Responses/RegisterSuccessResponse.php b/src/Http/Responses/RegisterSuccessResponse.php new file mode 100644 index 00000000..eadf6957 --- /dev/null +++ b/src/Http/Responses/RegisterSuccessResponse.php @@ -0,0 +1,70 @@ +getWebauthnKey($request); + + return $request->wantsJson() + ? $this->jsonResponse($request, $webauthnKey) + : Redirect::intended(Webauthn::redirects('register')); + } + + /** + * Get the created WebauthnKey. + * + * @param \Illuminate\Http\Request $request + * @return WebauthnKey + */ + protected function getWebauthnKey($request): WebauthnKey + { + $webauthnId = $this->webauthnId($request); + + return WebauthnKey::where('user_id', $request->user()->getAuthIdentifier()) + ->findOrFail($webauthnId); + } + + /** + * Get the id of the registerd key. + * + * @param \Illuminate\Http\Request $request + * @return int + */ + protected function webauthnId($request) + { + return $request->session()->pull(Webauthn::SESSION_WEBAUTHNID_CREATED); + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @param WebauthnKey $webauthnKey + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function jsonResponse($request, WebauthnKey $webauthnKey) + { + return Response::json([ + 'result' => true, + 'id' => $webauthnKey->id, + 'object' => 'webauthnKey', + 'name' => $webauthnKey->name, + 'counter' => $webauthnKey->counter, + ], 201); + } +} diff --git a/src/Http/Responses/RegisterViewResponse.php b/src/Http/Responses/RegisterViewResponse.php new file mode 100644 index 00000000..fb60e81b --- /dev/null +++ b/src/Http/Responses/RegisterViewResponse.php @@ -0,0 +1,52 @@ +config = $config; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + $publicKey = $this->publicKeyRequest($request); + + $view = $this->config->get('webauthn.register.view', ''); + + return $request->wantsJson() + ? Response::json(['publicKey' => $publicKey]) + : Response::view($view, ['publicKey' => $publicKey]); + } + + /** + * Get public key creation data. + * + * @param \Illuminate\Http\Request $request + * @return \Webauthn\PublicKeyCredentialCreationOptions + */ + protected function publicKeyRequest($request): PublicKeyCredentialCreationOptions + { + return $request->session()->get(Webauthn::SESSION_PUBLICKEY_CREATION); + } +} diff --git a/src/Http/Responses/UpdateResponse.php b/src/Http/Responses/UpdateResponse.php new file mode 100644 index 00000000..d453d668 --- /dev/null +++ b/src/Http/Responses/UpdateResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? Response::noContent() + : back()->with('status', 'webauthn-updated'); + } +} diff --git a/src/Listeners/LoginViaRemember.php b/src/Listeners/LoginViaRemember.php new file mode 100644 index 00000000..85e10055 --- /dev/null +++ b/src/Listeners/LoginViaRemember.php @@ -0,0 +1,36 @@ +registerWebauthn($event->user); + } + } + + /** + * Force register Webauthn login. + * + * @param User $user + */ + private function registerWebauthn(User $user) + { + if (Webauthn::enabled($user)) { + Webauthn::login(); + } + } +} diff --git a/src/Models/Casts/Base64.php b/src/Models/Casts/Base64.php new file mode 100644 index 00000000..77a54da4 --- /dev/null +++ b/src/Models/Casts/Base64.php @@ -0,0 +1,37 @@ +toString() : (string) $value; + } +} diff --git a/src/Models/WebauthnKey.php b/src/Models/WebauthnKey.php index a44befb3..28b23d70 100644 --- a/src/Models/WebauthnKey.php +++ b/src/Models/WebauthnKey.php @@ -4,13 +4,11 @@ use Illuminate\Database\Eloquent\Model; use LaravelWebauthn\Exceptions\WrongUserHandleException; -use Ramsey\Uuid\Uuid; -use Ramsey\Uuid\UuidInterface; -use function Safe\base64_decode; -use function Safe\json_decode; -use function Safe\json_encode; +use LaravelWebauthn\Models\Casts\Base64; +use LaravelWebauthn\Models\Casts\TrustPath; +use LaravelWebauthn\Models\Casts\Uuid; +use Ramsey\Uuid\Uuid as UuidConvert; use Webauthn\PublicKeyCredentialSource; -use Webauthn\TrustPath\TrustPath; class WebauthnKey extends Model { @@ -43,112 +41,32 @@ class WebauthnKey extends Model ]; /** - * The attributes that should be cast to native types. + * The attributes that should be visible in serialization. * * @var array */ - protected $casts = [ - 'counter' => 'integer', - 'transports' => 'array', + protected $visible = [ + 'id', + 'name', + 'type', + 'transports', + 'created_at', + 'updated_at', ]; /** - * Get the credentialId. - * - * @param string|null $value - * @return string|null - */ - public function getCredentialIdAttribute($value) - { - return ! is_null($value) ? base64_decode($value) : $value; - } - - /** - * Set the credentialId. - * - * @param string|null $value - * @return void - */ - public function setCredentialIdAttribute($value) - { - $this->attributes['credentialId'] = ! is_null($value) ? base64_encode($value) : $value; - } - - /** - * Get the CredentialPublicKey. - * - * @param string|null $value - * @return string|null - */ - public function getCredentialPublicKeyAttribute($value) - { - return ! is_null($value) ? base64_decode($value) : $value; - } - - /** - * Set the CredentialPublicKey. - * - * @param string|null $value - * @return void - */ - public function setCredentialPublicKeyAttribute($value) - { - $this->attributes['credentialPublicKey'] = ! is_null($value) ? base64_encode($value) : $value; - } - - /** - * Get the TrustPath. - * - * @param string|null $value - * @return TrustPath|null - */ - public function getTrustPathAttribute($value): ?TrustPath - { - if (! is_null($value)) { - $json = json_decode($value, true); - - return \Webauthn\TrustPath\TrustPathLoader::loadTrustPath($json); - } - - return null; - } - - /** - * Set the TrustPath. - * - * @param TrustPath|null $value - * @return void - */ - public function setTrustPathAttribute($value) - { - $this->attributes['trustPath'] = json_encode($value); - } - - /** - * Get the Aaguid. - * - * @param string|null $value - * @return UuidInterface|null - */ - public function getAaguidAttribute($value): ?UuidInterface - { - if (! is_null($value) && Uuid::isValid($value)) { - return Uuid::fromString($value); - } - - return null; - } - - /** - * Set the Aaguid. + * The attributes that should be cast to native types. * - * @param UuidInterface|string|null $value - * @return void + * @var array */ - public function setAaguidAttribute($value) - { - $this->attributes['aaguid'] = $value instanceof UuidInterface ? $value->toString() : (string) $value; - } + protected $casts = [ + 'counter' => 'integer', + 'transports' => 'array', + 'credentialId' => Base64::class, + 'credentialPublicKey' => Base64::class, + 'aaguid' => Uuid::class, + 'trustPath' => TrustPath::class, + ]; /** * Get PublicKeyCredentialSource object from WebauthnKey attributes. @@ -163,7 +81,7 @@ public function getPublicKeyCredentialSourceAttribute(): PublicKeyCredentialSour $this->transports, $this->attestationType, $this->trustPath, - $this->aaguid ?? Uuid::fromString(Uuid::NIL), + $this->aaguid ?? UuidConvert::fromString(UuidConvert::NIL), $this->credentialPublicKey, (string) $this->user_id, $this->counter diff --git a/src/Services/Webauthn.php b/src/Services/Webauthn.php index cc92a53c..3aa4a109 100644 --- a/src/Services/Webauthn.php +++ b/src/Services/Webauthn.php @@ -7,19 +7,37 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Session\Session; -use LaravelWebauthn\Events\WebauthnLogin; -use LaravelWebauthn\Events\WebauthnLoginData; -use LaravelWebauthn\Events\WebauthnRegister; -use LaravelWebauthn\Events\WebauthnRegisterData; -use LaravelWebauthn\Models\WebauthnKey; -use LaravelWebauthn\Services\Webauthn\PublicKeyCredentialCreationOptionsFactory; -use LaravelWebauthn\Services\Webauthn\PublicKeyCredentialRequestOptionsFactory; -use LaravelWebauthn\Services\Webauthn\PublicKeyCredentialValidator; -use Webauthn\PublicKeyCredentialCreationOptions; -use Webauthn\PublicKeyCredentialRequestOptions; class Webauthn extends WebauthnRepository { + /** + * PublicKey Creation session name. + * + * @var string + */ + public const SESSION_PUBLICKEY_CREATION = 'webauthn.publicKeyCreation'; + + /** + * Webauthn Created ID. + * + * @var string + */ + public const SESSION_WEBAUTHNID_CREATED = 'webauthn.idCreated'; + + /** + * PublicKey Request session name. + * + * @var string + */ + public const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest'; + + /** + * PublicKey Request session name. + * + * @var string + */ + public const SESSION_AUTH_RESULT = 'webauthn.publicKeyRequest'; + /** * Laravel application. * @@ -65,92 +83,58 @@ public function __construct(Application $app, Config $config, Session $session, } /** - * Get datas to register a new key. + * Get a completion redirect path for a specific feature. * - * @param User $user - * @return PublicKeyCredentialCreationOptions + * @param string $redirect + * @return string */ - public function getRegisterData(User $user): PublicKeyCredentialCreationOptions + public static function redirects(string $redirect, $default = null) { - $publicKey = $this->app->make(PublicKeyCredentialCreationOptionsFactory::class) - ->create($user); - - $this->events->dispatch(new WebauthnRegisterData($user, $publicKey)); - - return $publicKey; + return config('webauthn.redirects.'.$redirect) ?? $default ?? config('webauthn.home'); } /** - * Register a new key. + * Save authentication in session. * - * @param User $user - * @param PublicKeyCredentialCreationOptions $publicKey - * @param string $data - * @param string $keyName - * @return WebauthnKey + * @return void */ - public function doRegister(User $user, PublicKeyCredentialCreationOptions $publicKey, string $data, string $keyName): WebauthnKey + public function login() { - $publicKeyCredentialSource = $this->app->make(PublicKeyCredentialValidator::class) - ->validate($publicKey, $data); - - $webauthnKey = $this->create($user, $keyName, $publicKeyCredentialSource); - - $this->forceAuthenticate(); - - $this->events->dispatch(new WebauthnRegister($webauthnKey)); - - return $webauthnKey; + $this->session->put([$this->sessionName() => true]); } /** - * Get datas to authenticate a user. + * Remove authentication from session. * - * @param User $user - * @return PublicKeyCredentialRequestOptions + * @return void */ - public function getAuthenticateData(User $user): PublicKeyCredentialRequestOptions + public function logout() { - $publicKey = $this->app->make(PublicKeyCredentialRequestOptionsFactory::class) - ->create($user); - - $this->events->dispatch(new WebauthnLoginData($user, $publicKey)); - - return $publicKey; + $this->session->forget($this->sessionName()); } /** - * Authenticate a user. + * Force authentication in session. * - * @param User $user - * @param PublicKeyCredentialRequestOptions $publicKey - * @param string $data - * @return bool + * @return void + * + * @deprecated use login() instead */ - public function doAuthenticate(User $user, PublicKeyCredentialRequestOptions $publicKey, string $data): bool + public function forceAuthenticate() { - $result = $this->app->make(PublicKeyCredentialValidator::class) - ->check($user, $publicKey, $data); - - if ($result) { - $this->forceAuthenticate(); - - $this->events->dispatch(new WebauthnLogin($user)); - - return true; - } - - return false; + $this->login(); } /** - * Force authentication in session. + * Force remove authentication in session. * * @return void + * + * @deprecated use logout() instead */ - public function forceAuthenticate() + public function forgetAuthenticate() { - $this->session->put([$this->config->get('webauthn.sessionName') => true]); + $this->logout(); } /** @@ -160,28 +144,120 @@ public function forceAuthenticate() */ public function check(): bool { - return (bool) $this->session->get($this->config->get('webauthn.sessionName'), false); + return (bool) $this->session->get($this->sessionName(), false); } /** - * Test if the user has one webauthn key set or more. + * Get webauthn session store name. + * + * @return string + */ + private function sessionName(): string + { + return $this->config->get('webauthn.sessionName'); + } + + /** + * Test if the user has one or more webauthn key. * * @param \Illuminate\Contracts\Auth\Authenticatable $user * @return bool */ public function enabled(User $user): bool { - return (bool) $this->config->get('webauthn.enable', true) && $this->hasKey($user); + return $this->webauthnEnabled() && $this->hasKey($user); } /** - * Test if the user can register a new token. + * Test if the user can register a new key. * * @param \Illuminate\Contracts\Auth\Authenticatable $user * @return bool */ public function canRegister(User $user): bool { - return ! $this->enabled($user) || $this->check(); + return $this->webauthnEnabled() && (! $this->enabled($user) || $this->check()); + } + + /** + * Test if webauthn is enabled. + * + * @return bool + */ + public function webauthnEnabled(): bool + { + return (bool) $this->config->get('webauthn.enable', true); + } + + /** + * Register a class / callback that should be used to the destroy view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function destroyViewResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\DestroyResponse::class, $callback); + } + + /** + * Register a class / callback that should be used to the update view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function updateViewResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\UpdateResponse::class, $callback); + } + + /** + * Register a class / callback that should be used to the login success view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function loginSuccessResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\LoginSuccessResponse::class, $callback); + } + + /** + * Register a class / callback that should be used to the login view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function loginViewResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\LoginViewResponse::class, $callback); + } + + /** + * Register a class / callback that should be used to the register key success view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function registerSuccessResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\RegisterSuccessResponse::class, $callback); + } + + /** + * Register a class / callback that should be used to the register creation view response. + * + * @param string $callback + * @return void + * @codeCoverageIgnore + */ + public static function registerViewResponseUsing(string $callback) + { + app()->singleton(\LaravelWebauthn\Contracts\RegisterViewResponse::class, $callback); } } diff --git a/src/Services/Webauthn/AbstractFactory.php b/src/Services/Webauthn/AbstractFactory.php deleted file mode 100644 index 71077388..00000000 --- a/src/Services/Webauthn/AbstractFactory.php +++ /dev/null @@ -1,29 +0,0 @@ -config = $config; - // Credential Repository - $this->repository = $repository; - } -} diff --git a/src/Services/Webauthn/AbstractOptionsFactory.php b/src/Services/Webauthn/AbstractOptionsFactory.php deleted file mode 100644 index 5ae5c60e..00000000 --- a/src/Services/Webauthn/AbstractOptionsFactory.php +++ /dev/null @@ -1,21 +0,0 @@ -config->get('webauthn.extensions', []); - foreach ($array as $k => $v) { - $extensions->add(new AuthenticationExtension($k, $v)); - } - - return $extensions; - } -} diff --git a/src/Services/Webauthn/AbstractValidatorFactory.php b/src/Services/Webauthn/AbstractValidatorFactory.php deleted file mode 100644 index e0a6f54b..00000000 --- a/src/Services/Webauthn/AbstractValidatorFactory.php +++ /dev/null @@ -1,70 +0,0 @@ -add(new NoneAttestationStatementSupport()); - - // https://www.w3.org/TR/webauthn/#fido-u2f-attestation - $attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport()); - - // https://www.w3.org/TR/webauthn/#android-safetynet-attestation - if ($this->config->get('webauthn.google_safetynet_api_key') != null) { - try { - $client = \Http\Discovery\Psr18ClientDiscovery::find(); - $attestationStatementSupportManager->add(new AndroidSafetyNetAttestationStatementSupport($client, $this->config->get('webauthn.google_safetynet_api_key'))); - } catch (\Http\Discovery\Exception\NotFoundException $e) { - // ignore - } - } - - // https://www.w3.org/TR/webauthn/#android-key-attestation - $attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport()); - - // https://www.w3.org/TR/webauthn/#tpm-attestation - $attestationStatementSupportManager->add(new TPMAttestationStatementSupport()); - - // https://www.w3.org/TR/webauthn/#packed-attestation - $attestationStatementSupportManager->add(new PackedAttestationStatementSupport($coseAlgorithmManager)); - - return $attestationStatementSupportManager; - } - - /** - * Get the Public Key Credential Loader. - * - * @param AttestationStatementSupportManager $attestationStatementSupportManager - * @return PublicKeyCredentialLoader - */ - protected function getPublicKeyCredentialLoader(AttestationStatementSupportManager $attestationStatementSupportManager): PublicKeyCredentialLoader - { - // Attestation Object Loader - $attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager); - - // Public Key Credential Loader - return new PublicKeyCredentialLoader($attestationObjectLoader); - } -} diff --git a/src/Services/Webauthn/CreationOptionsFactory.php b/src/Services/Webauthn/CreationOptionsFactory.php new file mode 100644 index 00000000..016f03ff --- /dev/null +++ b/src/Services/Webauthn/CreationOptionsFactory.php @@ -0,0 +1,110 @@ +publicKeyCredentialRpEntity = $publicKeyCredentialRpEntity; + $this->authenticatorSelectionCriteria = $authenticatorSelectionCriteria; + $this->algorithmManager = $algorithmManager; + $this->attestationConveyance = $config->get('webauthn.attestation_conveyance', 'none'); + } + + /** + * Create a new PublicKeyCredentialCreationOptions object. + * + * @param User $user + * @return PublicKeyCredentialCreationOptions + */ + public function __invoke(User $user): PublicKeyCredentialCreationOptions + { + return (new PublicKeyCredentialCreationOptions( + $this->publicKeyCredentialRpEntity, + $this->getUserEntity($user), + $this->getChallenge(), + $this->createCredentialParameters(), + $this->timeout + )) + ->excludeCredentials($this->getExcludedCredentials($user)) + ->setAuthenticatorSelection($this->authenticatorSelectionCriteria) + ->setAttestation($this->attestationConveyance); + } + + /** + * Return the credential user entity. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return PublicKeyCredentialUserEntity + */ + private function getUserEntity(User $user): PublicKeyCredentialUserEntity + { + return new PublicKeyCredentialUserEntity( + $user->email ?? '', + $user->getAuthIdentifier(), + $user->email ?? '', + null + ); + } + + /** + * @return PublicKeyCredentialParameters[] + */ + private function createCredentialParameters(): array + { + return collect($this->algorithmManager->list()) + ->map(function ($algorithm): PublicKeyCredentialParameters { + return new PublicKeyCredentialParameters( + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + $algorithm + ); + }) + ->toArray(); + } + + /** + * Get the excluded credentials. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return array + */ + protected function getExcludedCredentials(User $user): array + { + return $this->repository->getRegisteredKeys($user); + } +} diff --git a/src/Services/Webauthn/CredentialAssertionValidator.php b/src/Services/Webauthn/CredentialAssertionValidator.php new file mode 100644 index 00000000..170924fa --- /dev/null +++ b/src/Services/Webauthn/CredentialAssertionValidator.php @@ -0,0 +1,70 @@ +serverRequest = $serverRequest; + $this->loader = $loader; + $this->validator = $validator; + } + + /** + * Validate an authentication request. + * + * @param User $user + * @param PublicKeyCredentialRequestOptions $requestOptions + * @param string $data + * @return bool + * + * @throws ResponseMismatchException + */ + public function __invoke(User $user, PublicKeyCredentialRequestOptions $requestOptions, string $data): bool + { + // Load the data + $publicKeyCredentials = $this->loader->load($data); + + $response = $publicKeyCredentials->getResponse(); + + // Check if the response is an Authenticator Assertion Response + if (! $response instanceof AuthenticatorAssertionResponse) { + throw new ResponseMismatchException('Not an authenticator assertion response'); + } + + // Check the response against the request + $this->validator->check( + $publicKeyCredentials->getRawId(), + $response, + $requestOptions, + $this->serverRequest, + $user->getAuthIdentifier() + ); + + return true; + } +} diff --git a/src/Services/Webauthn/CredentialAttestationValidator.php b/src/Services/Webauthn/CredentialAttestationValidator.php new file mode 100644 index 00000000..3310c06f --- /dev/null +++ b/src/Services/Webauthn/CredentialAttestationValidator.php @@ -0,0 +1,65 @@ +serverRequest = $serverRequest; + $this->publicKeyCredentialLoader = $publicKeyCredentialLoader; + $this->authenticatorAttestationResponseValidator = $authenticatorAttestationResponseValidator; + } + + /** + * Validate a creation request. + * + * @param PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions + * @param string $data + * @return PublicKeyCredentialSource + * + * @throws ResponseMismatchException + */ + public function __invoke(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $data): PublicKeyCredentialSource + { + // Load the data + $publicKeyCredential = $this->publicKeyCredentialLoader->load($data); + + $response = $publicKeyCredential->getResponse(); + + // Check if the response is an Authenticator Attestation Response + if (! $response instanceof AuthenticatorAttestationResponse) { + throw new ResponseMismatchException('Not an authenticator attestation response'); + } + + // Check the response against the request + return $this->authenticatorAttestationResponseValidator->check( + $response, + $publicKeyCredentialCreationOptions, + $this->serverRequest + ); + } +} diff --git a/src/Services/Webauthn/CredentialRepository.php b/src/Services/Webauthn/CredentialRepository.php index 6eb2a538..4241f4f5 100644 --- a/src/Services/Webauthn/CredentialRepository.php +++ b/src/Services/Webauthn/CredentialRepository.php @@ -2,11 +2,11 @@ namespace LaravelWebauthn\Services\Webauthn; +use Base64Url\Base64Url; use Illuminate\Contracts\Auth\Authenticatable as User; use Illuminate\Contracts\Auth\Guard; use Illuminate\Database\Eloquent\ModelNotFoundException; use LaravelWebauthn\Models\WebauthnKey; -use Webauthn\AttestedCredentialData; use Webauthn\PublicKeyCredentialDescriptor; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSourceRepository; @@ -41,7 +41,7 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKey { try { $webauthnKey = $this->model($publicKeyCredentialId); - if (! is_null($webauthnKey)) { + if ($webauthnKey !== null) { return $webauthnKey->publicKeyCredentialSource; } } catch (ModelNotFoundException $e) { @@ -73,7 +73,7 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void { $webauthnKey = $this->model($publicKeyCredentialSource->getPublicKeyCredentialId()); - if (! is_null($webauthnKey)) { + if ($webauthnKey !== null) { $webauthnKey->publicKeyCredentialSource = $publicKeyCredentialSource; $webauthnKey->save(); } @@ -85,13 +85,12 @@ public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredent * @param int|string $userId * @return \Illuminate\Support\Collection collection of PublicKeyCredentialSource */ - protected function getAllRegisteredKeys($userId) + protected function getAllRegisteredKeys($userId): \Illuminate\Support\Collection { return WebauthnKey::where('user_id', $userId) ->get() - ->map(function ($webauthnKey): PublicKeyCredentialSource { - return $webauthnKey->publicKeyCredentialSource; - }); + ->map + ->publicKeyCredentialSource; } /** @@ -103,9 +102,8 @@ protected function getAllRegisteredKeys($userId) public function getRegisteredKeys(User $user): array { return $this->getAllRegisteredKeys($user->getAuthIdentifier()) - ->map(function ($publicKey) { - return $publicKey->getPublicKeyCredentialDescriptor(); - }) + ->map + ->getPublicKeyCredentialDescriptor() ->toArray(); } @@ -123,7 +121,7 @@ private function model(string $credentialId): ?WebauthnKey /** @var WebauthnKey */ $webauthnKey = WebauthnKey::where([ 'user_id' => $this->guard->id(), - 'credentialId' => base64_encode($credentialId), + 'credentialId' => Base64Url::encode($credentialId), ])->firstOrFail(); return $webauthnKey; @@ -131,70 +129,4 @@ private function model(string $credentialId): ?WebauthnKey return null; } - - // deprecated CredentialRepository interface : - - /** - * @deprecated - * @codeCoverageIgnore - */ - public function has(string $credentialId): bool - { - return $this->findOneByCredentialId($credentialId) !== null; - } - - /** - * @deprecated - * @codeCoverageIgnore - */ - public function get(string $credentialId): AttestedCredentialData - { - $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId); - if (is_null($publicKeyCredentialSource)) { - throw new ModelNotFoundException('Wrong credentialId'); - } - - return $publicKeyCredentialSource->getAttestedCredentialData(); - } - - /** - * @deprecated - * @codeCoverageIgnore - */ - public function getUserHandleFor(string $credentialId): string - { - $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId); - if (is_null($publicKeyCredentialSource)) { - throw new ModelNotFoundException('Wrong credentialId'); - } - - return $publicKeyCredentialSource->getUserHandle(); - } - - /** - * @deprecated - * @codeCoverageIgnore - */ - public function getCounterFor(string $credentialId): int - { - $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId); - if (is_null($publicKeyCredentialSource)) { - throw new ModelNotFoundException('Wrong credentialId'); - } - - return $publicKeyCredentialSource->getCounter(); - } - - /** - * @deprecated - * @codeCoverageIgnore - */ - public function updateCounterFor(string $credentialId, int $newCounter): void - { - $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId); - if (is_null($publicKeyCredentialSource)) { - throw new ModelNotFoundException('Wrong credentialId'); - } - $publicKeyCredentialSource->setCounter($newCounter); - } } diff --git a/src/Services/Webauthn/OptionsFactory.php b/src/Services/Webauthn/OptionsFactory.php new file mode 100644 index 00000000..88433357 --- /dev/null +++ b/src/Services/Webauthn/OptionsFactory.php @@ -0,0 +1,52 @@ +repository = $repository; + } + + $this->challengeLength = (int) $config->get('webauthn.challenge_length', 32); + $this->timeout = (int) $config->get('webauthn.timeout', 30); + } + + /** + * Get a challenge sequence. + * + * @return string + * + * @psalm-suppress ArgumentTypeCoercion + */ + protected function getChallenge(): string + { + return \random_bytes($this->challengeLength); + } +} diff --git a/src/Services/Webauthn/PublicKeyCredentialCreationOptionsFactory.php b/src/Services/Webauthn/PublicKeyCredentialCreationOptionsFactory.php deleted file mode 100644 index 74d0bffc..00000000 --- a/src/Services/Webauthn/PublicKeyCredentialCreationOptionsFactory.php +++ /dev/null @@ -1,79 +0,0 @@ -email ?? '', - $user->getAuthIdentifier(), - $user->email ?? '', - null - ); - - return new PublicKeyCredentialCreationOptions( - $this->createRpEntity(), - $userEntity, - random_bytes($this->config->get('webauthn.challenge_length', 32)), - $this->createCredentialParameters(), - $this->config->get('webauthn.timeout', 60000), - $this->repository->getRegisteredKeys($user), - $this->createAuthenticatorSelectionCriteria(), - $this->config->get('webauthn.attestation_conveyance') ?? PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, - $this->createExtensions() - ); - } - - private function createAuthenticatorSelectionCriteria(): AuthenticatorSelectionCriteria - { - return new AuthenticatorSelectionCriteria( - $this->config->get('webauthn.authenticator_selection_criteria.attachment_mode') ?? AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE, - $this->config->get('webauthn.authenticator_selection_criteria.require_resident_key', false), - $this->config->get('webauthn.authenticator_selection_criteria.user_verification') ?? AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED - ); - } - - private function createRpEntity(): PublicKeyCredentialRpEntity - { - return new PublicKeyCredentialRpEntity( - $this->config->get('app.name', 'Laravel'), - Request::getHost(), - $this->config->get('webauthn.icon') - ); - } - - /** - * @return PublicKeyCredentialParameters[] - */ - private function createCredentialParameters(): array - { - $callback = function ($algorithm): PublicKeyCredentialParameters { - return new PublicKeyCredentialParameters( - PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, - $algorithm - ); - }; - - return array_map($callback, $this->config->get('public_key_credential_parameters') ?? [ - \Cose\Algorithms::COSE_ALGORITHM_ES256, - \Cose\Algorithms::COSE_ALGORITHM_RS256, - ]); - } -} diff --git a/src/Services/Webauthn/PublicKeyCredentialRequestOptionsFactory.php b/src/Services/Webauthn/PublicKeyCredentialRequestOptionsFactory.php deleted file mode 100644 index f300c508..00000000 --- a/src/Services/Webauthn/PublicKeyCredentialRequestOptionsFactory.php +++ /dev/null @@ -1,29 +0,0 @@ -config->get('webauthn.challenge_length', 32)), - $this->config->get('webauthn.timeout', 60000), - Request::getHttpHost(), - $this->repository->getRegisteredKeys($user), - $this->config->get('webauthn.authenticator_selection_criteria.user_verification') ?? AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED, - $this->createExtensions() - ); - } -} diff --git a/src/Services/Webauthn/PublicKeyCredentialValidator.php b/src/Services/Webauthn/PublicKeyCredentialValidator.php deleted file mode 100644 index 508e6464..00000000 --- a/src/Services/Webauthn/PublicKeyCredentialValidator.php +++ /dev/null @@ -1,168 +0,0 @@ -getCoseAlgorithmManager(); - - $attestationStatementSupportManager = $this->getAttestationStatementSupportManager($coseAlgorithmManager); - - // Public Key Credential Loader - $publicKeyCredentialLoader = $this->getPublicKeyCredentialLoader($attestationStatementSupportManager); - - // Load the data - $publicKeyCredential = $publicKeyCredentialLoader->load($data); - - $response = $publicKeyCredential->getResponse(); - - // Check if the response is an Authenticator Attestation Response - if (! $response instanceof AuthenticatorAttestationResponse) { - throw new ResponseMismatchException('Not an authenticator attestation response'); - } - - // Authenticator Attestation Response Validator - $authenticatorAttestationResponseValidator = $this->getAuthenticatorAttestationResponseValidator($attestationStatementSupportManager); - - // Check the response against the request - return $authenticatorAttestationResponseValidator->check( - $response, - $publicKeyCredentialCreationOptions, - ServerRequest::fromGlobals() - ); - } - - /** - * Validate an authentication request. - * - * @param User $user - * @param PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions - * @param string $data - * @return bool - * - * @throws ResponseMismatchException - */ - public function check(User $user, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, string $data): bool - { - $coseAlgorithmManager = $this->getCoseAlgorithmManager(); - - $attestationStatementSupportManager = $this->getAttestationStatementSupportManager($coseAlgorithmManager); - - // Public Key Credential Loader - $publicKeyCredentialLoader = $this->getPublicKeyCredentialLoader($attestationStatementSupportManager); - - // Load the data - $publicKeyCredential = $publicKeyCredentialLoader->load($data); - - $response = $publicKeyCredential->getResponse(); - - // Check if the response is an Authenticator Assertion Response - if (! $response instanceof AuthenticatorAssertionResponse) { - throw new ResponseMismatchException('Not an authenticator assertion response'); - } - - // Authenticator Assertion Response Validator - $authenticatorAssertionResponseValidator = $this->getAuthenticatorAssertionResponseValidator($coseAlgorithmManager); - - // Check the response against the request - $authenticatorAssertionResponseValidator->check( - $publicKeyCredential->getRawId(), - $response, - $publicKeyCredentialRequestOptions, - ServerRequest::fromGlobals(), - $user->getAuthIdentifier() - ); - - return true; - } - - /** - * Get the Authenticator Assertion Response Validator. - * - * @param Manager $coseAlgorithmManager - * @return AuthenticatorAssertionResponseValidator - */ - private function getAuthenticatorAssertionResponseValidator(Manager $coseAlgorithmManager): AuthenticatorAssertionResponseValidator - { - // The token binding handler - $tokenBindingHandler = new TokenBindingNotSupportedHandler(); - - $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); - - // Authenticator Attestation Response Validator - return new AuthenticatorAssertionResponseValidator( - $this->repository, - $tokenBindingHandler, - $extensionOutputCheckerHandler, - $coseAlgorithmManager - ); - } - - /** - * Get the Authenticator Attestation Response Validator. - * - * @param AttestationStatementSupportManager $attestationStatementSupportManager - * @return AuthenticatorAttestationResponseValidator - */ - private function getAuthenticatorAttestationResponseValidator(AttestationStatementSupportManager $attestationStatementSupportManager): AuthenticatorAttestationResponseValidator - { - // The token binding handler - $tokenBindingHandler = new TokenBindingNotSupportedHandler(); - - $extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); - - // Authenticator Attestation Response Validator - return new AuthenticatorAttestationResponseValidator( - $attestationStatementSupportManager, - $this->repository, - $tokenBindingHandler, - $extensionOutputCheckerHandler - ); - } - - /** - * Get the Cose Algorithm Manager. - * - * @return Manager - */ - private function getCoseAlgorithmManager() - { - $coseAlgorithmManager = new Manager(); - - $coseAlgorithmManager->add(new Signature\ECDSA\ES256()); - $coseAlgorithmManager->add(new Signature\ECDSA\ES512()); - $coseAlgorithmManager->add(new Signature\EdDSA\EdDSA()); - $coseAlgorithmManager->add(new Signature\RSA\RS1()); - $coseAlgorithmManager->add(new Signature\RSA\RS256()); - $coseAlgorithmManager->add(new Signature\RSA\RS512()); - - return $coseAlgorithmManager; - } -} diff --git a/src/Services/Webauthn/RequestOptionsFactory.php b/src/Services/Webauthn/RequestOptionsFactory.php new file mode 100644 index 00000000..8ea44f6a --- /dev/null +++ b/src/Services/Webauthn/RequestOptionsFactory.php @@ -0,0 +1,82 @@ +publicKeyCredentialRpEntity = $publicKeyCredentialRpEntity; + $this->userVerification = self::getUserVerification($config); + } + + /** + * Create a new PublicKeyCredentialCreationOptions object. + * + * @param User $user + * @return PublicKeyCredentialRequestOptions + */ + public function __invoke(User $user): PublicKeyCredentialRequestOptions + { + return (new PublicKeyCredentialRequestOptions( + $this->getChallenge(), + $this->timeout + )) + ->allowCredentials($this->getAllowedCredentials($user)) + ->setRpId($this->getRpId()) + ->setUserVerification($this->userVerification); + } + + /** + * Get user verification preference. + * + * @param \Illuminate\Contracts\Config\Repository $config + * @return string|null + */ + private static function getUserVerification(Config $config): ?string + { + return in_array($config->get('webauthn.userless'), ['required', 'preferred'], true) + ? 'required' + : $config->get('webauthn.user_verification', 'preferred'); + } + + /** + * Get the list of allowed keys. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return array + */ + private function getAllowedCredentials(User $user): array + { + return $this->repository->getRegisteredKeys($user); + } + + /** + * Get the rpEntity Id. + * + * @return string|null + */ + private function getRpId(): ?string + { + return $this->publicKeyCredentialRpEntity->getId(); + } +} diff --git a/src/SingletonServiceProvider.php b/src/SingletonServiceProvider.php deleted file mode 100644 index 856a03c9..00000000 --- a/src/SingletonServiceProvider.php +++ /dev/null @@ -1,34 +0,0 @@ -provides() as $singleton) { - $this->app->singleton($singleton, $singleton); - } - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() - { - return [ - \LaravelWebauthn\Services\Webauthn\CredentialRepository::class, - \LaravelWebauthn\Services\Webauthn::class, - ]; - } -} diff --git a/src/WebauthnServiceProvider.php b/src/WebauthnServiceProvider.php index 2eb9225a..70ac1ce3 100644 --- a/src/WebauthnServiceProvider.php +++ b/src/WebauthnServiceProvider.php @@ -2,8 +2,64 @@ namespace LaravelWebauthn; +use Cose\Algorithm\Manager as CoseAlgorithmManager; +use Cose\Algorithm\ManagerFactory as CoseAlgorithmManagerFactory; +use Cose\Algorithm\Signature\ECDSA; +use Cose\Algorithm\Signature\EdDSA; +use Cose\Algorithm\Signature\RSA; +use Http\Discovery\Exception\NotFoundException; +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; +use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use LaravelWebauthn\Contracts\DestroyResponse as DestroyResponseContract; +use LaravelWebauthn\Contracts\LoginSuccessResponse as LoginSuccessResponseContract; +use LaravelWebauthn\Contracts\LoginViewResponse as LoginViewResponseContract; +use LaravelWebauthn\Contracts\RegisterSuccessResponse as RegisterSuccessResponseContract; +use LaravelWebauthn\Contracts\RegisterViewResponse as RegisterViewResponseContract; +use LaravelWebauthn\Contracts\UpdateResponse as UpdateResponseContract; +use LaravelWebauthn\Facades\Webauthn as WebauthnFacade; +use LaravelWebauthn\Http\Controllers\AuthenticateController; +use LaravelWebauthn\Http\Controllers\WebauthnKeyController; +use LaravelWebauthn\Http\Responses\DestroyResponse; +use LaravelWebauthn\Http\Responses\LoginSuccessResponse; +use LaravelWebauthn\Http\Responses\LoginViewResponse; +use LaravelWebauthn\Http\Responses\RegisterSuccessResponse; +use LaravelWebauthn\Http\Responses\RegisterViewResponse; +use LaravelWebauthn\Http\Responses\UpdateResponse; +use LaravelWebauthn\Services\Webauthn; +use LaravelWebauthn\Services\Webauthn\CredentialRepository; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ServerRequestFactoryInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\UploadedFileFactoryInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport; +use Webauthn\AttestationStatement\AndroidSafetyNetAttestationStatementSupport; +use Webauthn\AttestationStatement\AppleAttestationStatementSupport; +use Webauthn\AttestationStatement\AttestationObjectLoader; +use Webauthn\AttestationStatement\AttestationStatementSupportManager; +use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport; +use Webauthn\AttestationStatement\NoneAttestationStatementSupport; +use Webauthn\AttestationStatement\PackedAttestationStatementSupport; +use Webauthn\AttestationStatement\TPMAttestationStatementSupport; +use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs; +use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; +use Webauthn\AuthenticatorAssertionResponseValidator; +use Webauthn\AuthenticatorAttestationResponseValidator; +use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\Counter\CounterChecker; +use Webauthn\Counter\ThrowExceptionIfInvalid; +use Webauthn\PublicKeyCredentialLoader; +use Webauthn\PublicKeyCredentialRpEntity; +use Webauthn\PublicKeyCredentialSourceRepository; +use Webauthn\TokenBinding\IgnoreTokenBindingHandler; +use Webauthn\TokenBinding\TokenBindingHandler; class WebauthnServiceProvider extends ServiceProvider { @@ -15,17 +71,33 @@ class WebauthnServiceProvider extends ServiceProvider private const MIDDLEWARE_GROUP = 'laravel-webauthn'; /** - * Bootstrap any package services. + * Bootstrap any application services. * * @return void */ public function boot() { - Route::middlewareGroup(self::MIDDLEWARE_GROUP, config('webauthn.middleware', [])); + $this->configurePublishing(); + $this->configureRoutes(); + $this->configureResources(); + } - $this->registerRoutes(); - $this->registerPublishing(); - $this->registerResources(); + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->app->singleton(WebauthnFacade::class, Webauthn::class); + + $this->registerResponseBindings(); + $this->bindWebAuthnPackage(); + $this->bindPsrInterfaces(); + + $this->mergeConfigFrom( + __DIR__.'/../config/webauthn.php', 'webauthn' + ); } /** @@ -35,24 +107,41 @@ public function boot() * * @return void */ - private function registerRoutes() + private function configureRoutes() { - Route::group($this->routeConfiguration(), function (\Illuminate\Routing\Router $router): void { - $router->get('auth', 'WebauthnController@login')->name('webauthn.login'); - $router->post('auth', 'WebauthnController@auth')->name('webauthn.auth'); + Route::middlewareGroup(self::MIDDLEWARE_GROUP, config('webauthn.middleware', [])); + Route::group($this->routeAttributes(), function (\Illuminate\Routing\Router $router): void { + $router->get('auth', [AuthenticateController::class, 'login'])->name('webauthn.login'); + $router->post('auth', [AuthenticateController::class, 'auth'])->name('webauthn.auth'); - $router->get('register', 'WebauthnController@register')->name('webauthn.register'); - $router->post('register', 'WebauthnController@create')->name('webauthn.create'); - $router->delete('{id}', 'WebauthnController@destroy')->name('webauthn.destroy'); + $router->get('keys/create', [WebauthnKeyController::class, 'create'])->name('webauthn.create'); + $router->post('keys', [WebauthnKeyController::class, 'store'])->name('webauthn.store'); + $router->delete('keys/{id}', [WebauthnKeyController::class, 'destroy'])->name('webauthn.destroy'); + $router->put('keys/{id}', [WebauthnKeyController::class, 'update'])->name('webauthn.update'); }); } + /** + * Register the response bindings. + * + * @return void + */ + public function registerResponseBindings() + { + $this->app->singleton(DestroyResponseContract::class, DestroyResponse::class); + $this->app->singleton(LoginSuccessResponseContract::class, LoginSuccessResponse::class); + $this->app->singleton(LoginViewResponseContract::class, LoginViewResponse::class); + $this->app->singleton(RegisterSuccessResponseContract::class, RegisterSuccessResponse::class); + $this->app->singleton(RegisterViewResponseContract::class, RegisterViewResponse::class); + $this->app->singleton(UpdateResponseContract::class, UpdateResponse::class); + } + /** * Get the route group configuration array. * * @return array */ - private function routeConfiguration() + private function routeAttributes() { return [ 'middleware' => self::MIDDLEWARE_GROUP, @@ -62,12 +151,248 @@ private function routeConfiguration() ]; } + /** + * Bind all the WebAuthn package services to the Service Container. + * + * @return void + */ + protected function bindWebAuthnPackage(): void + { + $this->app->bind(PublicKeyCredentialSourceRepository::class, CredentialRepository::class); + $this->app->bind(TokenBindingHandler::class, IgnoreTokenBindingHandler::class); + $this->app->bind(ExtensionOutputCheckerHandler::class, ExtensionOutputCheckerHandler::class); + $this->app->bind(AuthenticationExtensionsClientInputs::class, AuthenticationExtensionsClientInputs::class); + + $this->app->bind(NoneAttestationStatementSupport::class, NoneAttestationStatementSupport::class); + $this->app->bind(FidoU2FAttestationStatementSupport::class, FidoU2FAttestationStatementSupport::class); + $this->app->bind(AndroidKeyAttestationStatementSupport::class, AndroidKeyAttestationStatementSupport::class); + $this->app->bind(TPMAttestationStatementSupport::class, TPMAttestationStatementSupport::class); + $this->app->bind(AppleAttestationStatementSupport::class, AppleAttestationStatementSupport::class); + $this->app->bind( + PackedAttestationStatementSupport::class, + fn ($app) => new PackedAttestationStatementSupport( + $app[CoseAlgorithmManager::class] + ) + ); + $this->app->bind( + AndroidSafetyNetAttestationStatementSupport::class, + fn ($app) => (new AndroidSafetyNetAttestationStatementSupport()) + ->enableApiVerification( + $app[ClientInterface::class], + $app['config']->get('webauthn.google_safetynet_api_key'), + $app[RequestFactoryInterface::class] + ) + ); + $this->app->bind( + AttestationStatementSupportManager::class, + fn ($app) => tap(new AttestationStatementSupportManager(), function ($manager) use ($app) { + // https://www.w3.org/TR/webauthn/#sctn-none-attestation + $manager->add($app[NoneAttestationStatementSupport::class]); + + // https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation + $manager->add($app[FidoU2FAttestationStatementSupport::class]); + + // https://www.w3.org/TR/webauthn/#sctn-android-key-attestation + $manager->add($app[AndroidKeyAttestationStatementSupport::class]); + + // https://www.w3.org/TR/webauthn/#sctn-tpm-attestation + $manager->add($app[TPMAttestationStatementSupport::class]); + + // https://www.w3.org/TR/webauthn/#sctn-packed-attestation + $manager->add($app[PackedAttestationStatementSupport::class]); + + // https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation + if ($app['config']->get('webauthn.google_safetynet_api_key') !== null) { + $manager->add($app[AndroidSafetyNetAttestationStatementSupport::class]); + } + + // https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation + $manager->add($app[AppleAttestationStatementSupport::class]); + }) + ); + $this->app->bind( + AttestationObjectLoader::class, + fn ($app) => (new AttestationObjectLoader( + $app[AttestationStatementSupportManager::class] + )) + ->setLogger($app['log']) + ); + + $this->app->bind( + CounterChecker::class, + fn ($app) => new ThrowExceptionIfInvalid($app['log']) + ); + + $this->app->bind( + AuthenticatorAttestationResponseValidator::class, + fn ($app) => (new AuthenticatorAttestationResponseValidator( + $app[AttestationStatementSupportManager::class], + $app[PublicKeyCredentialSourceRepository::class], + $app[TokenBindingHandler::class], + $app[ExtensionOutputCheckerHandler::class] + )) + ->setLogger($app['log']) + ); + $this->app->bind( + AuthenticatorAssertionResponseValidator::class, + fn ($app) => (new AuthenticatorAssertionResponseValidator( + $app[PublicKeyCredentialSourceRepository::class], + $app[TokenBindingHandler::class], + $app[ExtensionOutputCheckerHandler::class], + $app[CoseAlgorithmManager::class] + )) + ->setCounterChecker($app[CounterChecker::class]) + ->setLogger($app['log']) + ); + $this->app->bind( + AuthenticatorSelectionCriteria::class, + fn ($app) => tap(new AuthenticatorSelectionCriteria(), function ($authenticatorSelectionCriteria) use ($app) { + $authenticatorSelectionCriteria + ->setAuthenticatorAttachment($app['config']->get('webauthn.attachment_mode', 'null')) + ->setUserVerification($app['config']->get('webauthn.user_verification', 'preferred')); + + if (($userless = $app['config']->get('webauthn.userless')) !== null) { + $authenticatorSelectionCriteria->setResidentKey($userless); + } + }) + ); + + $this->app->bind( + PublicKeyCredentialRpEntity::class, + fn ($app) => new PublicKeyCredentialRpEntity( + $app['config']->get('app.name', 'Laravel'), + $app->make('request')->getHost(), + $app['config']->get('webauthn.icon') + ) + ); + $this->app->bind( + PublicKeyCredentialLoader::class, + fn ($app) => (new PublicKeyCredentialLoader( + $app[AttestationObjectLoader::class] + )) + ->setLogger($app['log']) + ); + + $this->app->bind( + CoseAlgorithmManager::class, + fn ($app) => $app[CoseAlgorithmManagerFactory::class] + ->create($app['config']->get('webauthn.public_key_credential_parameters')) + ); + $this->app->bind( + CoseAlgorithmManagerFactory::class, + fn () => tap(new CoseAlgorithmManagerFactory, function ($factory) { + // list of existing algorithms + $algorithms = [ + RSA\RS1::class, + RSA\RS256::class, + RSA\RS384::class, + RSA\RS512::class, + RSA\PS256::class, + RSA\PS384::class, + RSA\PS512::class, + ECDSA\ES256::class, + ECDSA\ES256K::class, + ECDSA\ES384::class, + ECDSA\ES512::class, + EdDSA\ED256::class, + EdDSA\ED512::class, + EdDSA\Ed25519::class, + EdDSA\EdDSA::class, + ]; + + foreach ($algorithms as $algorithm) { + $factory->add((string) $algorithm::identifier(), new $algorithm); + } + }) + ); + } + + /** + * @psalm-suppress UndefinedClass + * @psalm-suppress PossiblyInvalidArgument + */ + protected function bindPsrInterfaces(): void + { + $this->app->bind(ClientInterface::class, function () { + if (class_exists(Psr18ClientDiscovery::class) && class_exists(NotFoundException::class)) { + try { + return Psr18ClientDiscovery::find(); + // @codeCoverageIgnoreStart + } catch (NotFoundException $e) { + Log::error('Could not find PSR-18 Client Factory.', ['exception' => $e]); + throw new BindingResolutionException('Unable to resolve PSR-18 Client Factory. Please install a psr/http-client-implementation implementation like \'guzzlehttp/guzzle\'.'); + } + } + + throw new BindingResolutionException('Unable to resolve PSR-18 request. Please install php-http/discovery and implementations for psr/http-client-implementation.'); + // @codeCoverageIgnoreEnd + }); + + $this->app->bind(RequestFactoryInterface::class, function () { + if (class_exists(Psr17FactoryDiscovery::class) && class_exists(NotFoundException::class)) { + try { + return Psr17FactoryDiscovery::findRequestFactory(); + // @codeCoverageIgnoreStart + } catch (NotFoundException $e) { + Log::error('Could not find PSR-17 Request Factory.', ['exception' => $e]); + throw new BindingResolutionException('Unable to resolve PSR-17 Request Factory. Please install psr/http-factory-implementation implementation like \'guzzlehttp/psr7\'.'); + } + } + + throw new BindingResolutionException('Unable to resolve PSR-17 request. Please install php-http/discovery and implementations for psr/http-factory-implementation.'); + // @codeCoverageIgnoreEnd + }); + + $this->app->bind(ServerRequestInterface::class, function ($app) { + if (class_exists(PsrHttpFactory::class)) { + return $app[PsrHttpFactory::class] + ->createRequest($app->make('request')); + } + + if (class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) { + return \GuzzleHttp\Psr7\ServerRequest::fromGlobals(); + } + + throw new BindingResolutionException('Unable to resolve PSR-7 Server Request. Please install the guzzlehttp/psr7 or symfony/psr-http-message-bridge, php-http/discovery and a psr/http-factory-implementation implementation.'); // @codeCoverageIgnore + }); + + if (class_exists(PsrHttpFactory::class)) { + $this->app->bind(PsrHttpFactory::class, function () { + if (class_exists(\Nyholm\Psr7\Factory\Psr17Factory::class) && class_exists(PsrHttpFactory::class)) { + /** + * @var ServerRequestFactoryInterface|StreamFactoryInterface|UploadedFileFactoryInterface|ResponseFactoryInterface + */ + $psr17Factory = new \Nyholm\Psr7\Factory\Psr17Factory; + + return new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + } elseif (class_exists(Psr17FactoryDiscovery::class) + && class_exists(NotFoundException::class) + && class_exists(PsrHttpFactory::class)) { + try { + $uploadFileFactory = Psr17FactoryDiscovery::findUploadedFileFactory(); + $responseFactory = Psr17FactoryDiscovery::findResponseFactory(); + $serverRequestFactory = Psr17FactoryDiscovery::findServerRequestFactory(); + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + return new PsrHttpFactory($serverRequestFactory, $streamFactory, $uploadFileFactory, $responseFactory); + // @codeCoverageIgnoreStart + } catch (NotFoundException $e) { + Log::error('Could not find PSR-17 Factory.', ['exception' => $e]); + } + } + + throw new BindingResolutionException('Unable to resolve PSR-17 Factory. Please install psr/http-factory-implementation implementation like \'guzzlehttp/psr7\'.'); + // @codeCoverageIgnoreEnd + }); + } + } + /** * Register the package's publishable resources. * * @return void */ - private function registerPublishing() + private function configurePublishing() { if ($this->app->runningInConsole()) { $this->publishes([ @@ -93,27 +418,9 @@ private function registerPublishing() * * @return void */ - private function registerResources() + private function configureResources() { $this->loadViewsFrom(__DIR__.'/../resources/views/', 'webauthn'); $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'webauthn'); } - - /** - * Register any package services. - * - * @return void - */ - public function register() - { - $this->mergeConfigFrom( - __DIR__.'/../config/webauthn.php', 'webauthn' - ); - - if ($this->app->runningInConsole()) { - $this->commands([ - Console\PublishCommand::class, - ]); - } - } } diff --git a/tests/Fake/FakeCredentialRepository.php b/tests/Fake/FakeCredentialRepository.php index aa4201c9..46ff341b 100644 --- a/tests/Fake/FakeCredentialRepository.php +++ b/tests/Fake/FakeCredentialRepository.php @@ -35,9 +35,7 @@ public function create(User $user, string $keyName, PublicKeyCredentialSource $p public function getRegisteredKeys(User $user): array { return collect($this->publicKeyCredentialSources) - ->map(function ($publicKey) { - return $publicKey->getPublicKeyCredentialDescriptor(); - }) + ->map->getPublicKeyCredentialDescriptor() ->toArray(); } diff --git a/tests/Fake/FakeWebauthn.php b/tests/Fake/FakeWebauthn.php index d9ea3a2c..49083cf9 100644 --- a/tests/Fake/FakeWebauthn.php +++ b/tests/Fake/FakeWebauthn.php @@ -5,12 +5,9 @@ use Illuminate\Contracts\Auth\Authenticatable as User; use Illuminate\Contracts\Foundation\Application; use LaravelWebauthn\Models\WebauthnKey; -use LaravelWebauthn\Services\Webauthn\PublicKeyCredentialCreationOptionsFactory; -use LaravelWebauthn\Services\Webauthn\PublicKeyCredentialRequestOptionsFactory; -use Webauthn\PublicKeyCredentialCreationOptions; -use Webauthn\PublicKeyCredentialRequestOptions; +use LaravelWebauthn\Services\WebauthnRepository; -class FakeWebauthn +class FakeWebauthn extends WebauthnRepository { /** * Laravel application. @@ -31,54 +28,34 @@ public function __construct(Application $app) protected $authenticate = true; - public function getRegisterData(User $user): PublicKeyCredentialCreationOptions + public static function redirects(string $redirect, $default = null) { - $publicKey = $this->app->make(PublicKeyCredentialCreationOptionsFactory::class) - ->create($user); - - return $publicKey; + return config('webauthn.redirects.'.$redirect) ?? $default ?? config('webauthn.home'); } - public function doRegister(User $user, PublicKeyCredentialCreationOptions $publicKey, string $data, string $keyName): WebauthnKey - { - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - 'name' => $keyName, - ]); - - $this->forceAuthenticate(); - - return $webauthnKey; - } - - public function getAuthenticateData(User $user): PublicKeyCredentialRequestOptions + public function setAuthenticate(bool $authenticate) { - return $this->app->make(PublicKeyCredentialRequestOptionsFactory::class) - ->create($user); + $this->authenticate = $authenticate; } - public function doAuthenticate(User $user, PublicKeyCredentialRequestOptions $publicKey, string $data): bool + public function login() { - if ($this->authenticate) { - $this->forceAuthenticate(); - } - - return $this->authenticate; + $this->app['session']->put([$this->sessionName() => true]); } - public function setAuthenticate(bool $authenticate) + public function logout() { - $this->authenticate = $authenticate; + $this->app['session']->forget($this->sessionName()); } - public function forceAuthenticate() + private function sessionName(): string { - $this->app['session']->put([$this->app['config']->get('webauthn.sessionName') => true]); + return $this->app['config']->get('webauthn.sessionName'); } public function check(): bool { - return (bool) $this->app['session']->get($this->app['config']->get('webauthn.sessionName'), false); + return (bool) $this->app['session']->get($this->sessionName(), false); } public function enabled(User $user): bool @@ -91,4 +68,9 @@ public function canRegister(User $user): bool { return (bool) ! $this->enabled($user) || $this->check(); } + + public function hasKey(User $user): bool + { + return WebauthnKey::where('user_id', $user->getAuthIdentifier())->count() > 0; + } } diff --git a/tests/FeatureTestCase.php b/tests/FeatureTestCase.php index d8e8bfc3..f3dddd16 100644 --- a/tests/FeatureTestCase.php +++ b/tests/FeatureTestCase.php @@ -10,7 +10,6 @@ class FeatureTestCase extends TestCase protected function getPackageProviders($app) { return [ - \LaravelWebauthn\SingletonServiceProvider::class, \LaravelWebauthn\WebauthnServiceProvider::class, ]; } diff --git a/tests/Unit/Actions/DeleteKeyTest.php b/tests/Unit/Actions/DeleteKeyTest.php new file mode 100644 index 00000000..8f30a66e --- /dev/null +++ b/tests/Unit/Actions/DeleteKeyTest.php @@ -0,0 +1,54 @@ +user(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + + app(DeleteKey::class)($user, $webauthnKey->id); + + $this->assertDatabaseMissing('webauthn_keys', [ + 'id' => $webauthnKey->id, + ]); + } + + /** + * @test + */ + public function it_fails_if_wrong_user() + { + $user = $this->user(); + $webauthnKey = factory(WebauthnKey::class)->create(); + + $this->expectException(ModelNotFoundException::class); + app(DeleteKey::class)($user, $webauthnKey->id); + } + + /** + * @test + */ + public function it_fails_if_wrong_id() + { + $user = $this->user(); + + $this->expectException(ModelNotFoundException::class); + app(DeleteKey::class)($user, 0); + } +} diff --git a/tests/Unit/Actions/LoginAttemptTest.php b/tests/Unit/Actions/LoginAttemptTest.php new file mode 100644 index 00000000..f0148fc0 --- /dev/null +++ b/tests/Unit/Actions/LoginAttemptTest.php @@ -0,0 +1,78 @@ +user(); + + $publicKeyCredentialRequestOptions = $this->app[RequestOptionsFactory::class]($user); + + $this->mock(CredentialAssertionValidator::class, function (MockInterface $mock) use ($user, $publicKeyCredentialRequestOptions) { + $mock->shouldReceive('__invoke') + ->with($user, $publicKeyCredentialRequestOptions, 'x') + ->andReturn(true); + }); + + $this->app[LoginAttempt::class]($user, $publicKeyCredentialRequestOptions, 'x'); + + $this->assertTrue(Webauthn::check()); + } + + /** + * @test + */ + public function it_does_not_login() + { + $user = $this->user(); + + $publicKeyCredentialRequestOptions = $this->app[RequestOptionsFactory::class]($user); + + $this->mock(CredentialAssertionValidator::class, function (MockInterface $mock) use ($user, $publicKeyCredentialRequestOptions) { + $mock->shouldReceive('__invoke') + ->with($user, $publicKeyCredentialRequestOptions, 'x') + ->andReturn(false); + }); + + $this->app[LoginAttempt::class]($user, $publicKeyCredentialRequestOptions, 'x'); + + $this->assertFalse(Webauthn::check()); + } + + /** + * @test + */ + public function it_fails_login() + { + $user = $this->user(); + + $publicKeyCredentialRequestOptions = $this->app[RequestOptionsFactory::class]($user); + + $this->mock(CredentialAssertionValidator::class, function (MockInterface $mock) use ($user, $publicKeyCredentialRequestOptions) { + $mock->shouldReceive('__invoke') + ->with($user, $publicKeyCredentialRequestOptions, 'x') + ->andThrow(new \Exception()); + }); + + $this->expectException(ValidationException::class); + $this->app[LoginAttempt::class]($user, $publicKeyCredentialRequestOptions, 'x'); + + $this->assertFalse(Webauthn::check()); + } +} diff --git a/tests/Unit/Actions/UpdateKeyTest.php b/tests/Unit/Actions/UpdateKeyTest.php new file mode 100644 index 00000000..94f5280e --- /dev/null +++ b/tests/Unit/Actions/UpdateKeyTest.php @@ -0,0 +1,56 @@ +user(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + + app(UpdateKey::class)($user, $webauthnKey->id, 'new-name'); + + $this->assertDatabaseHas('webauthn_keys', [ + 'id' => $webauthnKey->id, + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'new-name', + ]); + } + + /** + * @test + */ + public function it_fails_if_wrong_user() + { + $user = $this->user(); + $webauthnKey = factory(WebauthnKey::class)->create(); + + $this->expectException(ModelNotFoundException::class); + app(UpdateKey::class)($user, $webauthnKey->id, 'new-name'); + } + + /** + * @test + */ + public function it_fails_if_wrong_id() + { + $user = $this->user(); + + $this->expectException(ModelNotFoundException::class); + app(UpdateKey::class)($user, 0, 'new-name'); + } +} diff --git a/tests/Unit/Http/Controllers/WebauthnControllerTest.php b/tests/Unit/Http/Controllers/WebauthnControllerTest.php new file mode 100644 index 00000000..1aafc579 --- /dev/null +++ b/tests/Unit/Http/Controllers/WebauthnControllerTest.php @@ -0,0 +1,360 @@ + [ + 'name', + 'id', + 'displayName', + ], + 'challenge', + 'attestation', + 'timeout', + 'rp' => [ + 'name', + 'id', + ], + 'pubKeyCredParams' => [ + '*' => [ + 'type', + 'alg', + ], + ], + 'authenticatorSelection' => [ + 'requireResidentKey', + 'userVerification', + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + Webauthn::swap(new FakeWebauthn($this->app)); + } + + /** + * @test + */ + public function it_auth_get() + { + $user = $this->signIn(); + + $response = $this->get('/webauthn/auth', ['accept' => 'application/json']); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'publicKey', + ]); + } + + /** + * @test + */ + public function it_auth_success() + { + $user = $this->signIn(); + $this->session(['webauthn.publicKeyRequest' => app(LoginPrepare::class)($user)]); + $this->mock(LoginAttempt::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturnUsing(function () { + Webauthn::login(); + + return true; + }); + }); + + $response = $this->post('/webauthn/auth', ['data' => 'x'], ['accept' => 'application/json']); + + $response->assertStatus(200); + $response->assertJson([ + 'result' => 'true', + ]); + } + + /** + * @test + */ + public function it_auth_exception() + { + $user = $this->signIn(); + + $response = $this->post('/webauthn/auth', ['data' => 'x'], ['accept' => 'application/json']); + + $response->assertStatus(404); + } + + /** + * @test + */ + public function it_auth_success_with_redirect() + { + config(['webauthn.redirects.login' => 'redirect']); + + $user = $this->signIn(); + $this->session(['webauthn.publicKeyRequest' => app(LoginPrepare::class)($user)]); + $this->mock(CredentialAssertionValidator::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturn(true); + }); + + $response = $this->post('/webauthn/auth', ['data' => 'x']); + + $response->assertStatus(302); + $response->assertRedirect('redirect'); + } + + /** + * @test + */ + public function it_register_get_data() + { + $user = $this->signIn(); + + $response = $this->get('/webauthn/keys/create', ['accept' => 'application/json']); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'publicKey' => $this->publicKeyForm, + ]); + } + + /** + * @test + */ + public function it_register_create_without_check() + { + $user = $this->signIn(); + $response = $this->post('/webauthn/keys', [ + 'register' => 'x', + 'name' => 'keyname', + ], ['accept' => 'application/json']); + + $response->assertStatus(404); + } + + /** + * @test + */ + public function it_register_create() + { + $user = $this->signIn(); + $this->session(['webauthn.publicKeyCreation' => app(RegisterKeyPrepare::class)($user)]); + $this->mock(RegisterKeyStore::class, function (MockInterface $mock) use ($user) { + $mock->shouldReceive('__invoke')->andReturn(factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ])); + }); + + $response = $this->post('/webauthn/keys', [ + 'register' => 'x', + 'name' => 'keyname', + ], ['accept' => 'application/json']); + + $response->assertStatus(201); + $response->assertJson([ + 'result' => true, + ]); + + $this->assertDatabaseHas('webauthn_keys', [ + 'user_id' => $user->getAuthIdentifier(), + ]); + } + + /** + * @test + */ + public function it_register_create_exception() + { + $user = $this->signIn(); + + $response = $this->post('/webauthn/keys', [ + 'register' => '', + 'name' => 'keyname', + ], ['accept' => 'application/json']); + + $response->assertStatus(422); + $response->assertJson([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'register' => [ + 'The register field is required.', + ], + ], + ]); + } + + /** + * @test + */ + public function it_destroy_key() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + + $response = $this->delete('/webauthn/keys/'.$webauthnKey->id, ['accept' => 'application/json']); + + $response->assertStatus(302); + + $this->assertDataBaseMissing('webauthn_keys', [ + 'user_id' => $user->getAuthIdentifier(), + ]); + } + + /** + * @test + */ + public function it_destroy_wrong_id() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + $response = $this->delete('/webauthn/keys/0', ['accept' => 'application/json']); + + $response->assertStatus(404); + $response->assertJson([ + 'error' => [ + 'message' => 'Object not found', + ], + ]); + + $this->assertDatabaseHas('webauthn_keys', [ + 'id' => $webauthnKey->id, + 'user_id' => $user->getAuthIdentifier(), + ]); + } + + /** + * @test + */ + public function it_destroy_wrong_user() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + $otherWebauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $this->user()->getAuthIdentifier(), + ]); + + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + $response = $this->delete('/webauthn/keys/'.$otherWebauthnKey->id, ['accept' => 'application/json']); + + $response->assertStatus(404); + $response->assertJson([ + 'error' => [ + 'message' => 'Object not found', + ], + ]); + + $this->assertDatabaseHas('webauthn_keys', [ + 'id' => $webauthnKey->id, + 'user_id' => $user->getAuthIdentifier(), + ]); + } + + /** + * @test + */ + public function it_update_webauthnkey() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + ]); + + $response = $this->put('/webauthn/keys/'.$webauthnKey->id, [ + 'name' => 'new name', + ], ['accept' => 'application/json']); + + $response->assertStatus(204); + + $this->assertDataBaseHas('webauthn_keys', [ + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'new name', + ]); + } + + /** + * @test + */ + public function it_not_update_wrong_id() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'name', + ]); + + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + $response = $this->put('/webauthn/keys/0', [ + 'name' => 'new name', + ]); + + $response->assertStatus(404); + $response->assertJson([ + 'error' => [ + 'message' => 'Object not found', + ], + ]); + + $this->assertDatabaseHas('webauthn_keys', [ + 'id' => $webauthnKey->id, + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'name', + ]); + } + + /** + * @test + */ + public function it_not_update_wrong_user() + { + $user = $this->signIn(); + $webauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'name', + ]); + $otherWebauthnKey = factory(WebauthnKey::class)->create([ + 'user_id' => $this->user()->getAuthIdentifier(), + ]); + + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + $response = $this->put('/webauthn/keys/'.$otherWebauthnKey->id, [ + 'name' => 'new name', + ]); + + $response->assertStatus(404); + $response->assertJson([ + 'error' => [ + 'message' => 'Object not found', + ], + ]); + + $this->assertDatabaseHas('webauthn_keys', [ + 'id' => $webauthnKey->id, + 'user_id' => $user->getAuthIdentifier(), + 'name' => 'name', + ]); + } +} diff --git a/tests/Unit/MiddlewareTest.php b/tests/Unit/Http/Middleware/MiddlewareTest.php similarity index 76% rename from tests/Unit/MiddlewareTest.php rename to tests/Unit/Http/Middleware/MiddlewareTest.php index 4f8f22a5..8f1e7cc1 100644 --- a/tests/Unit/MiddlewareTest.php +++ b/tests/Unit/Http/Middleware/MiddlewareTest.php @@ -1,6 +1,6 @@ expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); - $this->app->make(WebauthnMiddleware::class)->handle($request, function () { + $this->app[WebauthnMiddleware::class]->handle($request, function () { }); } @@ -24,7 +24,7 @@ public function test_middleware_user_not_enabled() $user = $this->signIn(); $request = $this->getRequest($user); - $result = $this->app->make(WebauthnMiddleware::class)->handle($request, function () { + $result = $this->app[WebauthnMiddleware::class]->handle($request, function () { return 'next'; }); @@ -36,9 +36,9 @@ public function test_middleware_user_authenticated() $user = $this->signIn(); $request = $this->getRequest($user); - $this->app->make(Webauthn::class)->forceAuthenticate(); + $this->app[Webauthn::class]->login(); - $result = $this->app->make(WebauthnMiddleware::class)->handle($request, function () { + $result = $this->app[WebauthnMiddleware::class]->handle($request, function () { return 'next'; }); @@ -54,7 +54,7 @@ public function test_middleware_user_enabled() $request = $this->getRequest($user); - $result = $this->app->make(WebauthnMiddleware::class)->handle($request, function () { + $result = $this->app[WebauthnMiddleware::class]->handle($request, function () { return 'next'; }); diff --git a/tests/Unit/WebauthnKeyTest.php b/tests/Unit/Models/WebauthnKeyTest.php similarity index 98% rename from tests/Unit/WebauthnKeyTest.php rename to tests/Unit/Models/WebauthnKeyTest.php index 478c431b..7ed99ec0 100644 --- a/tests/Unit/WebauthnKeyTest.php +++ b/tests/Unit/Models/WebauthnKeyTest.php @@ -1,6 +1,6 @@ artisan('laravelwebauthn:publish') - ->expectsOutput('Publishing complete.') - ->expectsOutput('Publishing complete.') - ->expectsOutput('Publishing complete.') - ->expectsOutput('Publishing complete.'); - } -} diff --git a/tests/Unit/Services/PsrHelpersTest.php b/tests/Unit/Services/PsrHelpersTest.php new file mode 100644 index 00000000..86ae9bab --- /dev/null +++ b/tests/Unit/Services/PsrHelpersTest.php @@ -0,0 +1,53 @@ +markTestSkipped('PSR-17 Request Factory not found.'); + + return; + } + + $client = app(ClientInterface::class); + + $this->assertInstanceOf(ClientInterface::class, $client); + } + + /** + * @test + */ + public function it_get_request_factory() + { + if (! class_exists(\Http\Discovery\Psr17FactoryDiscovery::class)) { + $this->markTestSkipped('PSR-17 Request Factory not found.'); + + return; + } + + $requestFactory = app(RequestFactoryInterface::class); + + $this->assertInstanceOf(RequestFactoryInterface::class, $requestFactory); + } + + /** + * @test + */ + public function it_get_server_request_interface() + { + $serverRequest = app(ServerRequestInterface::class); + + $this->assertInstanceOf(ServerRequestInterface::class, $serverRequest); + } +} diff --git a/tests/Unit/CredentialRepositoryTest.php b/tests/Unit/Services/Webauthn/CredentialRepositoryTest.php similarity index 81% rename from tests/Unit/CredentialRepositoryTest.php rename to tests/Unit/Services/Webauthn/CredentialRepositoryTest.php index 2984229d..84374007 100644 --- a/tests/Unit/CredentialRepositoryTest.php +++ b/tests/Unit/Services/Webauthn/CredentialRepositoryTest.php @@ -1,11 +1,11 @@ $user->getAuthIdentifier(), ]); - $publicKey = $this->app->make(CredentialRepository::class) + $publicKey = $this->app[PublicKeyCredentialSourceRepository::class] ->findOneByCredentialId($webauthnKey->credentialId); $this->assertNotNull($publicKey); @@ -29,7 +29,7 @@ public function test_find_one_null() { $user = $this->signIn(); - $publicKey = $this->app->make(CredentialRepository::class) + $publicKey = $this->app[PublicKeyCredentialSourceRepository::class] ->findOneByCredentialId('123'); $this->assertNull($publicKey); @@ -42,7 +42,7 @@ public function test_find_one_null_wrong_user() 'user_id' => '1', ]); - $publicKey = $this->app->make(CredentialRepository::class) + $publicKey = $this->app[PublicKeyCredentialSourceRepository::class] ->findOneByCredentialId($webauthnKey->credentialId); $this->assertNull($publicKey); @@ -58,7 +58,7 @@ public function test_find_all() 'user_id' => $user->getAuthIdentifier(), ]); - $publicKeys = $this->app->make(CredentialRepository::class) + $publicKeys = $this->app[PublicKeyCredentialSourceRepository::class] ->findAllForUserEntity(new PublicKeyCredentialUserEntity('name', $user->getAuthIdentifier(), 'name')); $this->assertNotNull($publicKeys); @@ -75,7 +75,7 @@ public function test_save_credential() $publicKeyCredentialSource = $webauthnKey->publicKeyCredentialSource; $publicKeyCredentialSource->setCounter(154); - $this->app->make(CredentialRepository::class) + $this->app[PublicKeyCredentialSourceRepository::class] ->saveCredentialSource($publicKeyCredentialSource); $this->assertDatabaseHas('webauthn_keys', [ diff --git a/tests/Unit/WebauthnTest.php b/tests/Unit/Services/WebauthnTest.php similarity index 84% rename from tests/Unit/WebauthnTest.php rename to tests/Unit/Services/WebauthnTest.php index 4d743837..80badc51 100644 --- a/tests/Unit/WebauthnTest.php +++ b/tests/Unit/Services/WebauthnTest.php @@ -1,6 +1,6 @@ signIn(); - factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - $publicKey = $this->app->make(Webauthn::class)->getRegisterData($user); + $publicKey = $this->app[RegisterKeyPrepare::class]($user); $this->assertInstanceOf(\Webauthn\PublicKeyCredentialCreationOptions::class, $publicKey); @@ -40,23 +41,23 @@ public function test_do_register_data() { $user = $this->signIn(); - $publicKey = $this->app->make(Webauthn::class)->getRegisterData($user); + $publicKey = $this->app[RegisterKeyPrepare::class]($user); $this->assertInstanceOf(\Webauthn\PublicKeyCredentialCreationOptions::class, $publicKey); $data = $this->getAttestationData($publicKey); - $this->app->make(Webauthn::class)->doRegister($user, $publicKey, json_encode($data), 'name'); + $this->app[RegisterKeyStore::class]($user, $publicKey, json_encode($data), 'name'); $this->assertDatabaseHas('webauthn_keys', [ 'user_id' => $user->getAuthIdentifier(), 'name' => 'name', - 'credentialId' => 'MA==', + 'credentialId' => 'MA', 'type' => 'public-key', 'transports' => '[]', 'attestationType' => 'none', 'trustPath' => '{"type":"Webauthn\\\\TrustPath\\\\EmptyTrustPath"}', 'aaguid' => '00000000-0000-0000-0000-000000000000', - 'credentialPublicKey' => 'oWNrZXlldmFsdWU=', + 'credentialPublicKey' => 'oWNrZXlldmFsdWU', 'counter' => '1', ]); } @@ -68,7 +69,7 @@ public function test_get_authenticate_data() 'user_id' => $user->getAuthIdentifier(), ]); - $publicKey = $this->app->make(Webauthn::class)->getAuthenticateData($user); + $publicKey = $this->app[LoginPrepare::class]($user); $this->assertInstanceOf(\Webauthn\PublicKeyCredentialRequestOptions::class, $publicKey); @@ -98,7 +99,7 @@ public function test_do_authenticate() ]), ]); - $publicKey = $this->app->make(Webauthn::class)->getAuthenticateData($user); + $publicKey = $this->app[LoginPrepare::class]($user); $this->assertInstanceOf(\Webauthn\PublicKeyCredentialRequestOptions::class, $publicKey); $data = [ @@ -133,8 +134,8 @@ public function test_do_authenticate() ], ]; - $this->expectException(\Assert\InvalidArgumentException::class); - $result = $this->app->make(Webauthn::class)->doAuthenticate($user, $publicKey, json_encode($data)); + $this->expectException(\Illuminate\Validation\ValidationException::class); + $result = $this->app[LoginAttempt::class]($user, $publicKey, json_encode($data)); $this->assertTrue($result); // Not yet ... } @@ -146,13 +147,13 @@ public function test_wrong_do_authenticate() 'user_id' => $user->getAuthIdentifier(), ]); - $publicKey = $this->app->make(Webauthn::class)->getAuthenticateData($user); + $publicKey = $this->app[LoginPrepare::class]($user); $this->assertInstanceOf(\Webauthn\PublicKeyCredentialRequestOptions::class, $publicKey); $data = $this->getAttestationData($publicKey); - $this->expectException(\LaravelWebauthn\Exceptions\ResponseMismatchException::class); - $result = $this->app->make(Webauthn::class)->doAuthenticate($user, $publicKey, json_encode($data)); + $this->expectException(\Illuminate\Validation\ValidationException::class); + $result = $this->app[LoginAttempt::class]($user, $publicKey, json_encode($data)); } private function getAttestationData($publicKey) @@ -197,24 +198,24 @@ private function getAttestationData($publicKey) public function test_force_authenticate() { - $this->assertFalse($this->app->make(Webauthn::class)->check()); + $this->assertFalse($this->app[Webauthn::class]->check()); - $this->app->make(Webauthn::class)->forceAuthenticate(); + $this->app[Webauthn::class]->login(); - $this->assertTrue($this->app->make(Webauthn::class)->check()); + $this->assertTrue($this->app[Webauthn::class]->check()); } public function test_enabled() { $user = $this->signIn(); - $this->assertFalse($this->app->make(Webauthn::class)->enabled($user)); + $this->assertFalse($this->app[Webauthn::class]->enabled($user)); factory(WebauthnKey::class)->create([ 'user_id' => $user->getAuthIdentifier(), ]); - $this->assertTrue($this->app->make(Webauthn::class)->enabled($user)); + $this->assertTrue($this->app[Webauthn::class]->enabled($user)); } public function test_aaguid_null() diff --git a/tests/Unit/WebauthnControllerTest.php b/tests/Unit/WebauthnControllerTest.php deleted file mode 100644 index caa37d63..00000000 --- a/tests/Unit/WebauthnControllerTest.php +++ /dev/null @@ -1,254 +0,0 @@ - [ - 'name', - 'id', - 'displayName', - ], - 'challenge', - 'attestation', - 'timeout', - 'rp' => [ - 'name', - 'id', - ], - 'pubKeyCredParams' => [ - '*' => [ - 'type', - 'alg', - ], - ], - 'authenticatorSelection' => [ - 'requireResidentKey', - 'userVerification', - ], - ]; - - protected function setUp(): void - { - parent::setUp(); - - Webauthn::swap(new FakeWebauthn($this->app)); - } - - public function test_auth_get() - { - config(['webauthn.authenticate.view' => '']); - - $user = $this->signIn(); - - $response = $this->get('/webauthn/auth'); - - $response->assertStatus(200); - $response->assertJsonStructure([ - 'publicKey', - ]); - } - - public function test_auth_success() - { - config(['webauthn.authenticate.postSuccessCallback' => false]); - - $user = $this->signIn(); - $this->session(['webauthn.publicKeyRequest' => Webauthn::getAuthenticateData($user)]); - - $response = $this->post('/webauthn/auth', ['data' => '']); - - $response->assertStatus(200); - $response->assertJson([ - 'result' => 'true', - ]); - } - - public function test_auth_exception() - { - $user = $this->signIn(); - - $response = $this->post('/webauthn/auth', ['data' => '']); - - $response->assertStatus(403); - $response->assertJson([ - 'error' => [ - 'message' => 'Authentication data not found', - ], - ]); - } - - public function test_auth_success_with_redirect() - { - config(['webauthn.authenticate.postSuccessCallback' => false]); - config(['webauthn.authenticate.postSuccessRedirectRoute' => 'redirect']); - - $user = $this->signIn(); - $this->session(['webauthn.publicKeyRequest' => Webauthn::getAuthenticateData($user)]); - - $response = $this->post('/webauthn/auth', ['data' => '']); - - $response->assertStatus(302); - $response->assertRedirect('redirect'); - } - - public function test_register_get_data() - { - config(['webauthn.register.view' => '']); - - $user = $this->signIn(); - - $response = $this->get('/webauthn/register'); - - $response->assertStatus(200); - $response->assertJsonStructure([ - 'publicKey' => $this->publicKeyForm, - ]); - } - - public function test_register_view_without_check() - { - config(['webauthn.register.view' => '']); - $user = $this->signIn(); - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - - $response = $this->get('/webauthn/register'); - $response->assertStatus(403); - } - - public function test_register_create_without_check() - { - config(['webauthn.register.postSuccessRedirectRoute' => '']); - - $user = $this->signIn(); - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - - $this->session(['webauthn.publicKeyCreation' => Webauthn::getRegisterData($user)]); - - $response = $this->post('/webauthn/register', [ - 'register' => '', - 'name' => 'keyname', - ]); - - $response->assertStatus(403); - } - - public function test_register_create() - { - config(['webauthn.register.postSuccessRedirectRoute' => '']); - - $user = $this->signIn(); - $this->session(['webauthn.publicKeyCreation' => Webauthn::getRegisterData($user)]); - - $response = $this->post('/webauthn/register', [ - 'register' => '', - 'name' => 'keyname', - ]); - - $response->assertStatus(201); - $response->assertJson([ - 'result' => true, - ]); - - $this->assertDataBaseHas('webauthn_keys', [ - 'user_id' => $user->getAuthIdentifier(), - ]); - } - - public function test_register_create_exception() - { - $user = $this->signIn(); - - $response = $this->post('/webauthn/register', [ - 'register' => '', - 'name' => 'keyname', - ]); - - $response->assertStatus(403); - $response->assertJson([ - 'error' => [ - 'message' => 'Register data not found', - ], - ]); - } - - public function test_destroy() - { - $user = $this->signIn(); - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - - $response = $this->delete('/webauthn/'.$webauthnKey->id); - - $response->assertStatus(200); - $response->assertJson([ - 'deleted' => true, - 'id' => $webauthnKey->id, - ]); - - $this->assertDataBaseMissing('webauthn_keys', [ - 'user_id' => $user->getAuthIdentifier(), - ]); - } - - public function test_destroy_wrong_id() - { - $user = $this->signIn(); - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - - $response = $this->delete('/webauthn/0'); - - $response->assertStatus(404); - $response->assertJson([ - 'error' => [ - 'message' => 'Object not found', - ], - ]); - - $this->assertDataBaseHas('webauthn_keys', [ - 'id' => $webauthnKey->id, - 'user_id' => $user->getAuthIdentifier(), - ]); - } - - public function test_destroy_wrong_user() - { - $user = $this->signIn(); - $webauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $user->getAuthIdentifier(), - ]); - $otherWebauthnKey = factory(WebauthnKey::class)->create([ - 'user_id' => $this->user()->getAuthIdentifier(), - ]); - - $response = $this->delete('/webauthn/'.$otherWebauthnKey->id); - - $response->assertStatus(404); - $response->assertJson([ - 'error' => [ - 'message' => 'Object not found', - ], - ]); - - $this->assertDataBaseHas('webauthn_keys', [ - 'id' => $webauthnKey->id, - 'user_id' => $user->getAuthIdentifier(), - ]); - } -}