diff --git a/.gitignore b/.gitignore index 808f8c5..3b99cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build composer.lock docs vendor -coverage \ No newline at end of file +coverage +.idea \ No newline at end of file diff --git a/README.md b/README.md index 6435851..a3bc8b2 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ Authenticate users with just their device, fingerprint or biometric data. Goodbye passwords! -This enables WebAuthn authentication using Laravel authentication driver. +This enables WebAuthn authentication inside Laravel authentication driver, and comes with everything but the kitchen sink. ## Requisites * PHP 7.2.15+ -* Laravel 7 +* Laravel 7.18 (July 2020) ## Installation @@ -21,23 +21,52 @@ Just hit the console and require it with Composer. composer require darkghosthunter/larapass +# Table of contents + +- [What is WebAuthn? How it uses fingerprints or else?](#what-is-webauthn-how-it-uses-fingerprints-or-else) +- [Set up](#set-up) +- [Confirmation Middleware](#confirmation-middleware) +- [Events](#events) +- [Operations with WebAuthn](#operations-with-webauthn) +- [Advanced Configuration](#advanced-configuration) + - [Relaying Party Information](#relaying-party-information) + - [Challenge configuration](#challenge-configuration) + - [Algorithms](#algorithms) + - [Key Attachment](#key-attachment) + - [Attestation conveyance](#attestation-conveyance) + - [Login verification](#login-verification) + - [Userless login (One touch, Typeless)](#userless-login-one-touch-typeless) + - [Unique](#unique) + - [Password Fallback](#password-fallback) + - [Confirmation timeout](#confirmation-timeout) +- [Attestation and Metadata statements support](#attestation-and-metadata-statements-support) +- [Security](#security) +- [FAQ](#faq) +- [License](#license) + ## What is WebAuthn? How it uses fingerprints or else? In a nutshell, [mayor browsers are compatible with Web Authentication API](https://caniuse.com/#feat=webauthn), pushing authentication to the device (fingerprints, Face ID, patterns, codes, etc) instead of plain-text passwords. -This package validates authentication responses from the devices using a custom [user provider](https://laravel.com/docs/authentication#adding-custom-user-providers). +This package validates the WebAuthn payload from the devices using a custom [user provider](https://laravel.com/docs/authentication#adding-custom-user-providers). -If you have any doubts about WebAuthn, [check this small FAQ](#faq). +If you have any doubts about WebAuthn, [check this small FAQ](#faq). For a more deep dive, check [WebAuthn.io](https://webauthn.io/), [WebAuthn.me](https://webauthn.me/) and [Google WebAuthn tutorial](https://codelabs.developers.google.com/codelabs/webauthn-reauth/). ## Set up -1. Add the `eloquent-webauthn` driver to your authentication configuration in `config/auth.php`. -2. Migrate the `webauthn_credentials` table. -3. Implement the `WebAuthnAuthenticatable` contract and `WebAuthnAuthentication` trait to your User(s) classes. -4. Register WebAuthn routes. -4. Add the Javascript helper. +We need to make sure your users can register their devices and authenticate with them. + +1. [Add the `eloquent-webauthn` driver](#1-add-the-eloquent-webauthn-driver). +2. [Create the `webauthn_credentials` table.](#2-create-the-webauthn_credentials-table) +3. [Implement the contract and trait](#3-implement-the-contract-and-trait) + +After that, you can quick start WebAuthn with the included controllers and helpers to make your life easier. + +4. [Register the routes](#4-register-the-routes-optional) +5. [Use the Javascript helper](#5-use-the-javascript-helper-optional) +6. [Set up account recovery](#6-set-up-account-recovery-optional) -### 1. Add the `eloquent-webauthn` driver. +### 1. Add the `eloquent-webauthn` driver This package comes with an Eloquent-compatible [user provider](https://laravel.com/docs/authentication#adding-custom-user-providers) that validates WebAuthn responses from the devices. @@ -61,21 +90,20 @@ return [ ### 2. Create the `webauthn_credentials` table -Create the `webauthn_credentials` table by running the migrations: +Create the `webauthn_credentials` table by publishing the migration files and migrating the table: + php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="migrations" php artisan migrate -> If you need to modify the migration from this package, you can publish it to override whatever you need. -> -> php artisan vendor:publish --provider="DarkGhostHunter\Larapass\LarapassServiceProvider" --tag="migrations" - -### 3. Add the WebAuthn contract and trait +### 3. Implement the contract and trait Add the `WebAuthnAuthenticatable` contract and the `WebAuthnAuthentication` trait to the `Authenticatable` user class, or any that uses authentication. ```php The trait is used to basically tie the User model to the WebAuthn data contained in the database. +> The trait is used to tie the User model to the WebAuthn data contained in the database. -### 4. Register the routes +### 4. Register the routes (optional) Finally, you will need to add the routes for registering and authenticating users. If you want a quick start, just publish the controllers included in Larapass. @@ -112,9 +140,9 @@ Route::post('webauthn/login', 'Auth\WebAuthnLoginController@login') In your frontend scripts, point the requests to these routes. -> If you want full control, you can opt-out of these helper controllers and use your own logic. Use the [`AttestWebAuthn`](src/Http/AttestsWebAuthn.php) and [`AssertsWebAuthn`](src/Http/AssertsWebAuthn.php) traits if you need to start with something. +> If you want full control, you can opt-out of these helper controllers and use your own logic. Use the [`AttestWebAuthn`](src/Http/RegistersWebAuthn.php) and [`AssertsWebAuthn`](src/Http/AuthenticatesWebAuthn.php) traits if you need to start with something. -### 5. Frontend integration +### 5. Use the Javascript helper (optional) This package includes a convenient script to handle registration and login via WebAuthn. To use it, just publish the `larapass.js` asset into your application public resources. @@ -170,11 +198,11 @@ new Larapass({ }) ``` -> If the script doesn't suit your needs, you're free to create your own script to handle WebAuthn, or just copy-paste it and import into a transpiler like [Laravel Mix](https://laravel.com/docs/mix#running-mix), [Babel](https://babeljs.io/) or [Webpack](https://webpack.js.org/). +> You can copy-paste it and import into a transpiler like [Laravel Mix](https://laravel.com/docs/mix#running-mix), [Babel](https://babeljs.io/) or [Webpack](https://webpack.js.org/). If the script doesn't suit your needs, you're free to create your own. -### Remembering Users +#### Remembering Users -You can enable it by just issuing the `WebAuthn-Remember` header value to `true` when pushing the signed login challenge from your frontend. We can do this easily with the [included Javascript helper](#5-frontend-integration). +You can enable it by just issuing the `WebAuthn-Remember` header value to `true` when pushing the signed login challenge from your frontend. We can do this easily with the [included Javascript helper](#5-use-the-javascript-helper-optional). ```javascript new Larapass.login({ @@ -186,13 +214,85 @@ new Larapass.login({ Alternatively, you can add the `remember` key to the outgoing JSON Payload if you're using your own scripts. Both ways are accepted. -> You can override this behaviour in the [`AssertsWebAuthn`](src/Http/AssertsWebAuthn.php) trait. +> You can override this behaviour in the [`AssertsWebAuthn`](src/Http/AuthenticatesWebAuthn.php) trait. + +### 6. Set up account recovery (optional) + +Probably you will want to offer a way to "recover" an account if the user loses his credentials, which is basically a way to attach a new one. You can use controllers [which are also published](#4-register-the-routes-optional), along with these routes: + +```php +Route::get('webauthn/lost', 'Auth\WebAuthnDeviceLostController@showDeviceLostForm') + ->name('webauthn.lost.form'); +Route::post('webauthn/lost', 'Auth\WebAuthnDeviceLostController@sendRecoveryEmail') + ->name('webauthn.lost.send'); + +Route::get('webauthn/recover', 'Auth\WebAuthnRecoveryController@showResetForm') + ->name('webauthn.recover.form'); +Route::post('webauthn/recover/options', 'Auth\WebAuthnRecoveryController@options') + ->name('webauthn.recover.options'); +Route::post('webauthn/recover/register', 'Auth\WebAuthnRecoveryController@recover') + ->name('webauthn.recover'); +``` + +These come with [new views](resources/views) and [translation lines](resources/lang), so you can override them if you're not happy with what is included. + +You can also override the views in `resources/vendor/larapass` and the notification being sent using the `sendCredentialRecoveryNotification` method of the user. + +After that, don't forget to add a new token broker in your `config/auth.php`. We will need it to store the tokens from the recovery procedure. + +```php +return [ + // ... + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_resets', + 'expire' => 60, + 'throttle' => 60, + ], + + // New for WebAuthn + 'webauthn' => [ + 'provider' => 'users', // The user provider using WebAuthn. + 'table' => 'web_authn_recoveries', // The table to store the recoveries. + 'expire' => 60, + 'throttle' => 60, + ], + ], +]; +``` + +## Confirmation middleware + +Following the same principle of the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), Larapass includes a the `webauthn.confirm` middleware that will ask the user to confirm with his device before entering a given route. + +```php +Route::get('this/is/important', function () { + return 'This is very important!'; +})->middleware('webauthn.confirm'); +``` + +When [publishing the controllers](#4-register-the-routes-optional), the `WebAuthnConfirmController` will be in your controller files ready to accept confirmations. You just need to register the route by just copy-pasting these: + +```php +Route::get('webauthn/confirm', 'Auth\WebAuthnConfirmController@showConfirmForm') + ->name('webauthn.confirm.form'); +Route::post('webauthn/confirm/options', 'Auth\WebAuthnConfirmController@options') + ->name('webauthn.confirm.options'); +Route::post('webauthn/confirm', 'Auth\WebAuthnConfirmController@confirm') + ->name('webauthn.confirm'); +``` + +As always, you can opt-out with your own logic. For these case take a look into the [`ConfirmsWebAuthn`](src/Http/ConfirmsWebAuthn.php) trait to start. + +> You can change how much time to remember the confirmation [in the configuration](#confirmation-timeout). ## Events -Since all authentication is handled by Laravel itself, the only [event](https://laravel.com/docs/events) included is [`AttestationSuccessful`](src/Events/AttestationSuccessful.php), which fires when the registration is successful. It includes the user and the credentials persisted. +Since all authentication is handled by Laravel itself, the only [event](https://laravel.com/docs/events) included is [`AttestationSuccessful`](src/Events/AttestationSuccessful.php), which fires when the registration is successful. It includes the user with the credentials persisted. -You can use this event to, for example, notify the user a new device has been registered and with what ID. For that, you can use a [listener](https://laravel.com/docs/events#defining-listeners). +You can use this event to, for example, notify the user a new device has been registered. For that, you can use a [listener](https://laravel.com/docs/events#defining-listeners). ```php public function handle(AttestationSuccessful $event) @@ -258,11 +358,10 @@ For assertion, simply create a request using `generateAssertion` and validate it use App\User; use DarkGhostHunter\Larapass\Facades\WebAuthn; -$email = request()->input('email'); - -$user = User::where('email', $email)->firstOrFail(); +// Find the user to assert, if there is any +$user = User::where('email', request()->input('email'))->first(); -// Create an assertion for the given user. +// Create an assertion for the given user (or a blank one if not found); return WebAuthn::generateAssertion($user); ``` @@ -275,7 +374,7 @@ use App\User; use Illuminate\Support\Facades\Auth; use DarkGhostHunter\Larapass\Facades\WebAuthn; -// Verify it +// Verify the incoming assertion. $credentials = WebAuthn::validateAssertion( request()->json()->all() ); @@ -333,7 +432,9 @@ return [ 'conveyance' => 'none', 'login_verify' => 'preferred', 'userless' => null, + 'unique' => false, 'fallback' => true, + 'confirm_timeout' => 10800, ]; ``` @@ -395,7 +496,7 @@ return [ ]; ``` -By default, the user decides what to use to register. If you wish to exclusively use a cross-platform authentication (like USB Keys, CA Servers or Certificates) set this to `true`, or `false` if you want to enforce device-only authentication. +By default, the user decides what to use for registration. If you wish to exclusively use a cross-platform authentication (like USB Keys, CA Servers or Certificates) set this to `true`, or `false` if you want to enforce device-only authentication. ### Attestation conveyance @@ -421,7 +522,7 @@ By default, most authenticators will require the user verification when login in You can also use `discouraged` to only check for user presence (like a "Continue" button), which may make the login faster but making it slightly less secure. -> When setting [userless](#userless-login-one-touch-typeless) as `preferred` or `required`, this will be overridden to `required` automatically. +> When setting [userless](#userless-login-one-touch-typeless) as `preferred` or `required` will override this to `required` automatically. ### Userless login (One touch, Typeless) @@ -431,10 +532,22 @@ return [ ]; ``` -You can activate _userless_ login, also known as one-touch login or typless login. You should change this to `preferred` in that case, since not all devices support the feature. +You can activate _userless_ login, also known as one-touch login or typless login, for devices when they're being registered. You should change this to `preferred` in that case, since not all devices support the feature. If this is activated (not `null` or `discouraged`), login verification will be mandatory. +> This doesn't affect the login procedure, only the attestation (registration). + +### Unique + +```php +return [ + 'unique' => false, +]; +``` + +If true, the device will limit the creation of only one credential by device. This is done by telling the device the list of credentials ID the user already has. If at least one if already present in the device, the latter will return an error. + ### Password Fallback ```php @@ -445,7 +558,17 @@ return [ By default, this package allows to re-use the same `eloquent-webauthn` driver to log in users with passwords when the credentials are not a WebAuthn JSON payload. -Disabling the fallback will only check for WebAuthn credentials. To handle classic user/password scenarios, you should create a separate guard. +Disabling the fallback will only validate the WebAuthn credentials. To handle classic user/password scenarios, you may create a separate guard. + +### Confirmation timeout + +```php +return [ + 'confirm_timeout' => 10800, +]; +``` + +When using the [Confirmation middleware](#confirmation-middleware), the confirmation will be remembered for a set amount of seconds. By default, is 3 hours, which is enough for most scenarios. ## Attestation and Metadata statements support @@ -468,13 +591,13 @@ $this->app->extend(AttestationStatementSupport::class, function ($manager) { These are some details about this WebAuthn implementation: -1. Registration (attestation) is remembered by user, domain and IP. -2. Login (assertion) is remembered by domain and IP. -3. Cached challenge is always forgotten after resolution, independently of the result. -4. Cached challenge TTL is the same as the WebAuthn timeout (60 seconds default). -5. Included controllers include throttling for WebAuthn endpoints. -6. Users ID (handle) is a random UUID v4. -7. Credentials can be blacklisted (enabled/disabled). +* Registration (attestation) is exclusive to the domain, IP and user. +* Login (assertion) is exclusive to the domain, IP, and the user if specified +* Cached challenge is always forgotten after resolution, independently of the result. +* Cached challenge TTL is the same as the WebAuthn timeout (60 seconds default). +* Included controllers include throttling for WebAuthn endpoints. +* Users ID (handle) is a random UUID v4. +* Credentials can be blacklisted (enabled/disabled). If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. @@ -484,7 +607,7 @@ If you discover any security related issues, please email darkghosthunter@gmail. * **Does this work with any browser?** -[Yes](https://caniuse.com/#feat=webauthn). In the case of old browsers, you should have a fallback detection script. This can be asked with [the included Javascript helper](#5-frontend-integration) in a breeze: +[Yes](https://caniuse.com/#feat=webauthn). In the case of old browsers, you should have a fallback detection script. This can be asked with [the included Javascript helper](#5-use-the-javascript-helper-optional) in a breeze: ```javascript if (! Larapass.supportsWebAuthn()) { @@ -546,9 +669,11 @@ class MyCountChecker implements CounterChecker * **If a user loses his device, can he register a new device?** -Yes, just send him a signed email to register a new device with secure attestation and assertion routes. That's up to you. +Yes, just send him a signed email to register a new device with secure attestation and assertion routes. You can [use these recovery helpers](#6-set-up-account-recovery-optional). -> To blacklist a device, use `disableDevice()` in the user instance. That allows the user to re-enable it when he recovers the device. +* **What's the difference between disabling and deleting a credential?** + +Disabling a credential doesn't delete it, so it can be later enabled manually. When the credential is deleted, it goes away forever. * **How secure is this against passwords or 2FA?** @@ -556,15 +681,19 @@ Extremely secure since it works only on HTTPS, and no password or codes are exch * **Can I deactivate the password fallback? Can I enforce only WebAuthn authentication?** -Yes. Just be sure to disable the password column in the users table, the Password Broker, and have some logic to recover the account with new devices and invalidate old ones. The [`WebAuthnAuthentication`](src/WebAuthnAuthentication.php) trait helps with this. +Yes. Just be sure to [use the recovery helpers](#6-set-up-account-recovery-optional) if you want a quick fix. * **Does this includes a frontend Javascript?** -[Yes.](#5-frontend-integration) +[Yes.](#5-use-the-javascript-helper-optional) * **Does this encodes/decode the strings automatically in the frontend?** -Yes, the included [WebAuthn Helper](#5-frontend-integration) does it automatically for you. +Yes, the included [WebAuthn Helper](#5-use-the-javascript-helper-optional) does it automatically for you. + +* **Does this include a credential recovery routes?** + +[Yes.](#6-set-up-account-recovery-optional) ## License diff --git a/config/larapass.php b/config/larapass.php index 3f00499..1dad273 100644 --- a/config/larapass.php +++ b/config/larapass.php @@ -140,4 +140,17 @@ */ 'fallback' => true, + + /* + |-------------------------------------------------------------------------- + | Device Confirmation + |-------------------------------------------------------------------------- + | + | If you're using the "webauthn.confirm" middleware in your routes you may + | want to adjust the time the confirmation is remembered in the browser. + | This is measured in seconds, but it can be overridden in the route. + | + */ + + 'confirm_timeout' => 10800, // 3 hours ]; \ No newline at end of file diff --git a/database/migrations/2020_04_02_000000_create_web_authn_credentials_table.php b/database/migrations/2020_04_02_000000_create_web_authn_tables.php similarity index 82% rename from database/migrations/2020_04_02_000000_create_web_authn_credentials_table.php rename to database/migrations/2020_04_02_000000_create_web_authn_tables.php index 33c55ed..bc7214f 100644 --- a/database/migrations/2020_04_02_000000_create_web_authn_credentials_table.php +++ b/database/migrations/2020_04_02_000000_create_web_authn_tables.php @@ -1,11 +1,11 @@ primary(['id', 'user_id']); }); + + Schema::create('web_authn_recoveries', function (Blueprint $table) { + $table->string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); } /** @@ -49,5 +55,6 @@ public function up() public function down() { Schema::dropIfExists('web_authn_authentications'); + Schema::dropIfExists('web_authn_recoveries'); } } diff --git a/resources/lang/en/confirm.php b/resources/lang/en/confirm.php new file mode 100644 index 0000000..b2285ab --- /dev/null +++ b/resources/lang/en/confirm.php @@ -0,0 +1,6 @@ + 'Please confirm with your device before continuing', + 'button' => 'Confirm' +]; \ No newline at end of file diff --git a/resources/lang/en/recovery.php b/resources/lang/en/recovery.php new file mode 100644 index 0000000..c932952 --- /dev/null +++ b/resources/lang/en/recovery.php @@ -0,0 +1,22 @@ + 'Account recovery', + + 'description' => 'If you can\'t login with your device, you can register another by opening an email there.', + 'details' => 'Ensure you open the email on a device you fully own.', + + 'instructions' => 'Press the button to use this device for your account and follow your the instructions.', + 'unique' => 'Disable all others devices except this.', + + 'button' => [ + 'send' => 'Send account recovery', + 'register' => 'Register this device', + ], + + 'sent' => 'If the email is correct, you should receive an email with a recovery link shortly.', + 'attached' => 'A new device has been attached to your account to authenticate.', + 'user' => 'We can\'t find a user with that email address.', + 'token' => 'The token is invalid or has expired.', + 'throttled' => 'Please wait before retrying.', +]; \ No newline at end of file diff --git a/resources/views/confirm.blade.php b/resources/views/confirm.blade.php new file mode 100644 index 0000000..b6b80b4 --- /dev/null +++ b/resources/views/confirm.blade.php @@ -0,0 +1,32 @@ +@extends('larapass::layout') + +@section('title', __('Authenticator confirmation')) + +@section('body') +
+

{{ __('Please confirm with your device before continuing') }}

+
+
+ +
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/layout.blade.php b/resources/views/layout.blade.php new file mode 100644 index 0000000..af76652 --- /dev/null +++ b/resources/views/layout.blade.php @@ -0,0 +1,41 @@ + + + + + + + + @yield('title') + + + +
+
+
+
+
+ @yield('body') +
+
+
+
+
+@stack('scripts') + + \ No newline at end of file diff --git a/resources/views/lost.blade.php b/resources/views/lost.blade.php new file mode 100644 index 0000000..0a299c6 --- /dev/null +++ b/resources/views/lost.blade.php @@ -0,0 +1,33 @@ +@extends('larapass::layout') + +@section('title', trans('larapass::recovery.title')) + +@section('body') +
+ @csrf +

{{ trans('larapass::recovery.title') }}

+
+

{{ trans('larapass::recovery.description') }}

+ @if($errors->any()) +
+ +
+ @elseif(session('status')) +
+ {{ session('status') }} +
+ @endif +
+ + + {{ trans('larapass::recovery.details') }} +
+
+ +
+
+@endsection \ No newline at end of file diff --git a/resources/views/recover.blade.php b/resources/views/recover.blade.php new file mode 100644 index 0000000..4e1b74c --- /dev/null +++ b/resources/views/recover.blade.php @@ -0,0 +1,55 @@ +@extends('larapass::layout') + +@section('title', trans('larapass::recovery.title')) + +@section('body') +
+ + +

{{ trans('larapass::recovery.title') }}

+
+

{{ trans('larapass::recovery.instructions') }}

+ @if ($errors->any()) +
+ +
+ @endif +
+
+ + +
+
+
+ +
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/src/Auth/CredentialBroker.php b/src/Auth/CredentialBroker.php new file mode 100644 index 0000000..5a9c3b2 --- /dev/null +++ b/src/Auth/CredentialBroker.php @@ -0,0 +1,93 @@ +getUser($credentials); + + if (! $user instanceof WebAuthnAuthenticatable) { + return static::INVALID_USER; + } + + if ($this->tokens->recentlyCreatedToken($user)) { + return static::RESET_THROTTLED; + } + + $user->sendCredentialRecoveryNotification( + $this->tokens->create($user) + ); + + return static::RESET_LINK_SENT; + } + + /** + * Reset the password for the given token. + * + * @param array $credentials + * @param \Closure $callback + * @return mixed + */ + public function reset(array $credentials, Closure $callback) + { + $user = $this->validateReset($credentials); + + if (! $user instanceof CanResetPasswordContract || ! $user instanceof WebAuthnAuthenticatable) { + return $user; + } + + $callback($user); + + $this->tokens->delete($user); + + return static::PASSWORD_RESET; + } +} \ No newline at end of file diff --git a/src/Auth/EloquentWebAuthnProvider.php b/src/Auth/EloquentWebAuthnProvider.php index 101f38c..fc26288 100644 --- a/src/Auth/EloquentWebAuthnProvider.php +++ b/src/Auth/EloquentWebAuthnProvider.php @@ -55,9 +55,7 @@ public function retrieveByCredentials(array $credentials) return $this->model::getFromCredentialId($id); } - if ($this->fallback) { - return parent::retrieveByCredentials($credentials); - } + return parent::retrieveByCredentials($credentials); } /** @@ -95,6 +93,7 @@ public function validateCredentials(UserContract $user, array $credentials) return (bool)$this->validator->validate($credentials); } + // If the fallback is enabled, we will validate the credential password. if ($this->fallback) { return parent::validateCredentials($user, $credentials); } diff --git a/src/Contracts/WebAuthnAuthenticatable.php b/src/Contracts/WebAuthnAuthenticatable.php index fe3a926..b8a5ff7 100644 --- a/src/Contracts/WebAuthnAuthenticatable.php +++ b/src/Contracts/WebAuthnAuthenticatable.php @@ -8,7 +8,7 @@ interface WebAuthnAuthenticatable { /** - * Creates an user entity information for attestation (register). + * Creates an user entity information for attestation (registration). * * @return \Webauthn\PublicKeyCredentialUserEntity */ @@ -84,6 +84,14 @@ public function enableCredential($id) : void; */ public function disableCredential($id) : void; + /** + * Disables all credentials for the user. + * + * @param string|array|null $except + * @return void + */ + public function disableAllCredentials($except = null) : void; + /** * Returns all credentials descriptors of the user. * @@ -91,6 +99,14 @@ public function disableCredential($id) : void; */ public function allCredentialDescriptors() : array; + /** + * Sends a credential recovery email to the user. + * + * @param string $token + * @return void + */ + public function sendCredentialRecoveryNotification(string $token) : void; + /** * Returns an WebAuthnAuthenticatable user from a given Credential ID. * diff --git a/src/Eloquent/Casting/TrustPathCast.php b/src/Eloquent/Casting/TrustPathCast.php new file mode 100644 index 0000000..60a4d23 --- /dev/null +++ b/src/Eloquent/Casting/TrustPathCast.php @@ -0,0 +1,37 @@ +type, $this->transports->all(), $this->attestation_type, - TrustPathLoader::loadTrustPath($this->trust_path->all()), - Uuid::fromString($this->aaguid), + $this->trust_path, + $this->aaguid, $this->public_key, $this->user_handle, $this->counter diff --git a/src/Eloquent/WebAuthnCredential.php b/src/Eloquent/WebAuthnCredential.php index 5c60e9d..83f4094 100644 --- a/src/Eloquent/WebAuthnCredential.php +++ b/src/Eloquent/WebAuthnCredential.php @@ -2,8 +2,6 @@ namespace DarkGhostHunter\Larapass\Eloquent; -use Ramsey\Uuid\Uuid; -use Ramsey\Uuid\UuidInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletes; @@ -16,16 +14,19 @@ * * @property-read string $id * - * @property bool $is_excluded - * @property string $type - * @property \Illuminate\Support\Collection $transports - * @property string $attestation_type - * @property \Illuminate\Support\Collection $trust_path - * @property \Ramsey\Uuid\Uuid $aaguid - * @property string $public_key - * @property int $counter - * @property string $user_handle + * @property-read string $type + * @property-read null|string $name + * @property-read \Illuminate\Support\Collection $transports + * @property-read string $attestation_type + * @property-read \Webauthn\TrustPath\TrustPath $trust_path + * @property-read \Ramsey\Uuid\UuidInterface $aaguid + * @property-read string $public_key + * @property-read int $counter + * @property-read string $user_handle * @property-read null|\Illuminate\Support\Carbon $disabled_at + * + * @property-read string $prettyId + * * @method \Illuminate\Database\Eloquent\Builder|static enabled() */ class WebAuthnCredential extends Model implements PublicKeyCredentialSourceRepository @@ -55,12 +56,15 @@ class WebAuthnCredential extends Model implements PublicKeyCredentialSourceRepos protected $keyType = 'string'; /** - * The attributes that should be hidden for serialization. + * The attributes that should be visible in serialization. * * @var array */ - protected $hidden = [ - 'public_key', + protected $visible = [ + 'id', + 'name', + 'type', + 'transports', ]; /** @@ -70,17 +74,9 @@ class WebAuthnCredential extends Model implements PublicKeyCredentialSourceRepos */ protected $casts = [ 'transports' => 'collection', - 'trust_path' => 'collection', 'counter' => 'integer', - ]; - - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = [ - 'last_login_at', + 'trust_path' => Casting\TrustPathCast::class, + 'aaguid' => Casting\UuidCast::class, ]; /** @@ -90,6 +86,7 @@ class WebAuthnCredential extends Model implements PublicKeyCredentialSourceRepos */ protected $fillable = [ 'id', + 'name', 'type', 'transports', 'attestation_type', @@ -121,31 +118,13 @@ public function isDisabled() } /** - * The AAGUID is basically an UUID, so we will make it depending on how is encoded. + * Returns the credential ID encoded in BASE64. * - * @param string $value - * @return void + * @return false */ - public function setAaguidAttribute($value) + public function getPrettyIdAttribute() { - $this->attributes['aaguid'] = mb_strlen($value, '8bit') === 36 - ? Uuid::fromString($value) - : Uuid::fromBytes(base64_decode($value, true)); - } - - /** - * Returns the Aaguid as UUID. - * - * @param $value - * @return \Ramsey\Uuid\UuidInterface - */ - public function getAaguidAttribute($value) - { - if (! $value instanceof UuidInterface) { - Uuid::fromString($value); - } - - return $value; + return base64_decode($this->attributes['id']); } /** diff --git a/src/Facades/WebAuthn.php b/src/Facades/WebAuthn.php index 703ddcf..ec855ee 100644 --- a/src/Facades/WebAuthn.php +++ b/src/Facades/WebAuthn.php @@ -2,7 +2,9 @@ namespace DarkGhostHunter\Larapass\Facades; +use Closure; use Illuminate\Support\Facades\Facade; +use DarkGhostHunter\Larapass\Auth\CredentialBroker; use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAttestCreator; use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAttestValidator; use DarkGhostHunter\Larapass\WebAuthn\WebAuthnAssertValidator; @@ -10,6 +12,41 @@ class WebAuthn extends Facade { + /** + * Constant representing a successfully sent recovery. + * + * @var string + */ + public const RECOVERY_SENT = CredentialBroker::RESET_LINK_SENT; + + /** + * Constant representing a successfully reset recovery. + * + * @var string + */ + public const RECOVERY_ATTACHED = CredentialBroker::PASSWORD_RESET; + + /** + * Constant representing the user not found response. + * + * @var string + */ + public const INVALID_USER = CredentialBroker::INVALID_USER; + + /** + * Constant representing an invalid token. + * + * @var string + */ + public const INVALID_TOKEN = CredentialBroker::INVALID_TOKEN; + + /** + * Constant representing a throttled reset attempt. + * + * @var string + */ + public const RECOVERY_THROTTLED = CredentialBroker::RESET_THROTTLED; + /** * Creates a new attestation (registration) request. * @@ -68,4 +105,50 @@ public static function validateAssertion(array $data) { return (bool) static::$app[WebAuthnAssertValidator::class]->validate($data); } + + /** + * Sends an account recovery email to an user by the credentials. + * + * @param array $credentials + * @return string + */ + public static function sendRecoveryLink(array $credentials) + { + return static::$app[CredentialBroker::class]->sendResetLink($credentials); + } + + /** + * Recover the account for the given token. + * + * @param array $credentials + * @param \Closure $callback + * @return \Illuminate\Contracts\Auth\CanResetPassword|mixed|string + */ + public static function recover(array $credentials, Closure $callback) + { + return static::$app[CredentialBroker::class]->reset($credentials, $callback); + } + + /** + * Get the user for the given credentials. + * + * @param array $credentials + * @return null|\DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\CanResetPassword + */ + public static function getUser(array $credentials) + { + return static::$app[CredentialBroker::class]->getUser($credentials); + } + + /** + * Validate the given account recovery token. + * + * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable|\Illuminate\Contracts\Auth\CanResetPassword|null $user + * @param string $token + * @return bool + */ + public static function tokenExists($user, string $token) + { + return $user ? static::$app[CredentialBroker::class]->tokenExists($user, $token) : false; + } } \ No newline at end of file diff --git a/src/Http/AssertsWebAuthn.php b/src/Http/AuthenticatesWebAuthn.php similarity index 81% rename from src/Http/AssertsWebAuthn.php rename to src/Http/AuthenticatesWebAuthn.php index b870b4d..bb992a9 100644 --- a/src/Http/AssertsWebAuthn.php +++ b/src/Http/AuthenticatesWebAuthn.php @@ -6,8 +6,10 @@ use Illuminate\Support\Facades\Auth; use DarkGhostHunter\Larapass\Facades\WebAuthn; -trait AssertsWebAuthn +trait AuthenticatesWebAuthn { + use WebAuthnRules; + /** * Returns an WebAuthn Assertion challenge for the user (or userless). * @@ -73,7 +75,7 @@ protected function userProvider() * Log the user in. * * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response + * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse */ public function login(Request $request) { @@ -86,24 +88,6 @@ public function login(Request $request) return response()->noContent(422); } - /** - * The assertion rules to validate the incoming JSON payload. - * - * @return array|string[] - */ - protected function assertionRules() - { - return [ - 'id' => 'required|string', - 'rawId' => 'required|string', - 'response.authenticatorData' => 'required|string', - 'response.clientDataJSON' => 'required|string', - 'response.signature' => 'required|string', - 'response.userHandle' => 'required|string', - 'type' => 'required|string', - ]; - } - /** * Check if the Request has a "Remember" value present. * @@ -112,7 +96,7 @@ protected function assertionRules() */ protected function hasRemember(Request $request) { - return $request->filled('remember') || $request->header('WebAuthn-Remember', false); + return $request->filled('remember') || $request->header('WebAuthn-Remember'); } /** @@ -132,7 +116,7 @@ protected function attemptLogin(array $challenge, bool $remember = false) * * @param \Illuminate\Http\Request $request * @param mixed $user - * @return void|mixed + * @return void|\Illuminate\Http\JsonResponse */ protected function authenticated(Request $request, $user) { diff --git a/src/Http/ConfirmsWebAuthn.php b/src/Http/ConfirmsWebAuthn.php new file mode 100644 index 0000000..c8f1adb --- /dev/null +++ b/src/Http/ConfirmsWebAuthn.php @@ -0,0 +1,79 @@ +validate($this->assertionRules()); + + if (WebAuthn::validateAssertion($credential)) { + $this->resetAuthenticatorConfirmationTimeout($request); + + return response()->json([ + 'redirectTo' => redirect()->intended($this->redirectPath())->getTargetUrl() + ]); + } + + return response()->noContent(422); + } + + /** + * Reset the password confirmation timeout. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function resetAuthenticatorConfirmationTimeout(Request $request) + { + $request->session()->put('auth.webauthn.confirm', now()->timestamp); + } + + /** + * Get the post recovery redirect path. + * + * @return string + */ + public function redirectPath() + { + if (method_exists($this, 'redirectTo')) { + return $this->redirectTo(); + } + + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } +} \ No newline at end of file diff --git a/src/Http/Middleware/RequireWebAuthn.php b/src/Http/Middleware/RequireWebAuthn.php new file mode 100644 index 0000000..57959e2 --- /dev/null +++ b/src/Http/Middleware/RequireWebAuthn.php @@ -0,0 +1,94 @@ +responseFactory = $responseFactory; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + $this->remember = $config->get('larapass.confirm_timeout', 10800); + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string $redirectToRoute + * @return mixed + */ + public function handle($request, Closure $next, $redirectToRoute = 'webauthn.confirm.form') + { + if ($this->shouldConfirmAuthenticator()) { + if ($request->expectsJson()) { + return $this->responseFactory->json([ + 'message' => 'Authenticator assertion required.', + ], 423); + } + + return $this->responseFactory->redirectGuest( + $this->urlGenerator->route($redirectToRoute) + ); + } + + return $next($request); + } + + /** + * Determine if the confirmation timeout has expired. + * + * @return bool + */ + protected function shouldConfirmAuthenticator() + { + $confirmedAt = now()->timestamp - $this->session->get('auth.webauthn.confirm', 0); + + return $confirmedAt > $this->remember; + } +} \ No newline at end of file diff --git a/src/Http/RecoversWebAuthn.php b/src/Http/RecoversWebAuthn.php new file mode 100644 index 0000000..d788bf2 --- /dev/null +++ b/src/Http/RecoversWebAuthn.php @@ -0,0 +1,167 @@ +missing('token', 'email')) { + return redirect()->route('webauthn.lost.form'); + } + + return view('larapass::recover')->with( + ['token' => $request->query('token'), 'email' => $request->query('email')] + ); + } + + /** + * Returns the credential creation options to the user. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse|void + */ + public function options(Request $request) + { + $user = WebAuthn::getUser($request->validate($this->rules())); + + // We will proceed only if the broker can find the user and the token is valid. + // If the user doesn't exists or the token is invalid, we will bail out with a + // HTTP 401 code because the user doing the request is not authorized for it. + abort_unless(WebAuthn::tokenExists($user, $request->input('token')), 401); + + return response()->json(WebAuthn::generateAttestation($user)); + } + + /** + * Get the account recovery validation rules. + * + * @return array + */ + protected function rules() + { + return [ + 'token' => 'required', + 'email' => 'required|email', + ]; + } + + /** + * Recover the user account and log him in. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse + * @throws \Illuminate\Validation\ValidationException + */ + public function recover(Request $request) + { + $credentials = validator([ + 'email' => $request->header('email'), + 'token' => $request->header('token'), + ], $this->rules())->validate(); + + $response = WebAuthn::recover($credentials, function ($user) use ($request) { + $this->register($request, $user); + }); + + return $response === WebAuthn::RECOVERY_ATTACHED + ? $this->sendRecoveryResponse($request, $response) + : $this->sendRecoveryFailedResponse($request, $response); + } + + /** + * Registers a device for further WebAuthn authentication. + * + * @param \Illuminate\Http\Request $request + * @param \DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable $user + * @return void + */ + protected function register(Request $request, WebAuthnAuthenticatable $user) + { + $validCredential = WebAuthn::validateAttestation( + $request->validate($this->attestationRules()), $user + ); + + if ($validCredential) { + if ($request->filled('unique') || $request->header('WebAuthn-Unique')) { + $user->disableAllCredentials(); + } + + $user->addCredential($validCredential); + + event(new AttestationSuccessful($user, $validCredential)); + + $this->guard()->login($user); + } + } + + /** + * Get the response for a successful account recovery. + * + * @param \Illuminate\Http\Request $request + * @param string $response + * @return \Illuminate\Http\JsonResponse + */ + protected function sendRecoveryResponse(Request $request, $response) + { + return new JsonResponse([ + 'message' => trans($response), + 'redirectTo' => $this->redirectPath() + ], 200); + } + + /** + * Get the response for a failed account recovery. + * + * @param \Illuminate\Http\Request $request + * @param string $response + * @throws \Illuminate\Validation\ValidationException + */ + protected function sendRecoveryFailedResponse(Request $request, $response) + { + throw ValidationException::withMessages([ + 'email' => [trans($response)], + ]); + + } + + /** + * Returns the Authentication guard. + * + * @return \Illuminate\Contracts\Auth\StatefulGuard + */ + protected function guard() + { + return Auth::guard(); + } + + /** + * Get the post recovery redirect path. + * + * @return string + */ + public function redirectPath() + { + if (method_exists($this, 'redirectTo')) { + return $this->redirectTo(); + } + + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } +} \ No newline at end of file diff --git a/src/Http/AttestsWebAuthn.php b/src/Http/RegistersWebAuthn.php similarity index 78% rename from src/Http/AttestsWebAuthn.php rename to src/Http/RegistersWebAuthn.php index 4d0c73e..d39cd2a 100644 --- a/src/Http/AttestsWebAuthn.php +++ b/src/Http/RegistersWebAuthn.php @@ -7,8 +7,10 @@ use DarkGhostHunter\Larapass\Events\AttestationSuccessful; use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable; -trait AttestsWebAuthn +trait RegistersWebAuthn { + use WebAuthnRules; + /** * Returns a challenge to be verified by the user device. * @@ -47,22 +49,6 @@ public function register(Request $request, WebAuthnAuthenticatable $user) return response()->noContent(422); } - /** - * The attestation rules to validate the incoming JSON payload. - * - * @return array|string[] - */ - protected function attestationRules() - { - return [ - 'id' => 'required|string', - 'rawId' => 'required|string', - 'response.attestationObject' => 'required|string', - 'response.clientDataJSON' => 'required|string', - 'type' => 'required|string', - ]; - } - /** * The user has registered a credential. * diff --git a/src/Http/SendsWebAuthnRecoveryEmail.php b/src/Http/SendsWebAuthnRecoveryEmail.php new file mode 100644 index 0000000..d4cadc8 --- /dev/null +++ b/src/Http/SendsWebAuthnRecoveryEmail.php @@ -0,0 +1,86 @@ +validate($this->recoveryRules()); + + $response = WebAuthn::sendRecoveryLink($credentials); + + return $response === WebAuthn::RECOVERY_SENT + ? $this->sendRecoveryLinkResponse($request, $response) + : $this->sendRecoveryLinkFailedResponse($request, $response); + } + + /** + * The recovery credentials to retrieve through validation rules. + * + * @return array|string[] + */ + protected function recoveryRules() + { + return [ + 'email' => 'required|email', + ]; + } + + /** + * Get the response for a successful account recovery link. + * + * @param \Illuminate\Http\Request $request + * @param string $response + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse + */ + protected function sendRecoveryLinkResponse(Request $request, $response) + { + return $request->wantsJson() + ? new JsonResponse(['message' => trans($response)], 200) + : back()->with('status', trans($response)); + } + + /** + * Get the response for a failed account recovery link. + * + * @param \Illuminate\Http\Request $request + * @param string $response + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse + * @throws \Illuminate\Validation\ValidationException + */ + protected function sendRecoveryLinkFailedResponse(Request $request, $response) + { + if ($request->wantsJson()) { + throw ValidationException::withMessages([ + 'email' => [trans($response)], + ]); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => trans($response)]); + } +} \ No newline at end of file diff --git a/src/Http/WebAuthnRules.php b/src/Http/WebAuthnRules.php new file mode 100644 index 0000000..9f70c83 --- /dev/null +++ b/src/Http/WebAuthnRules.php @@ -0,0 +1,40 @@ + 'required|string', + 'rawId' => 'required|string', + 'response.attestationObject' => 'required|string', + 'response.clientDataJSON' => 'required|string', + 'type' => 'required|string', + ]; + } + + /** + * The assertion rules to validate the incoming JSON payload. + * + * @return array|string[] + */ + protected function assertionRules() + { + return [ + 'id' => 'required|string', + 'rawId' => 'required|string', + 'response.authenticatorData' => 'required|string', + 'response.clientDataJSON' => 'required|string', + 'response.signature' => 'required|string', + 'response.userHandle' => 'required|string', + 'type' => 'required|string', + ]; + } +} \ No newline at end of file diff --git a/src/LarapassServiceProvider.php b/src/LarapassServiceProvider.php index d49963d..fbbb89f 100644 --- a/src/LarapassServiceProvider.php +++ b/src/LarapassServiceProvider.php @@ -2,6 +2,8 @@ namespace DarkGhostHunter\Larapass; +use RuntimeException; +use Illuminate\Support\Str; use Psr\Log\LoggerInterface; use Webauthn\Counter\CounterChecker; use Illuminate\Support\ServiceProvider; @@ -15,8 +17,10 @@ use Webauthn\TokenBinding\TokenBindingHandler; use Webauthn\PublicKeyCredentialSourceRepository; use Cose\Algorithm\Manager as CoseAlgorithmManager; +use DarkGhostHunter\Larapass\Auth\CredentialBroker; use Webauthn\TokenBinding\IgnoreTokenBindingHandler; use Webauthn\AuthenticatorAssertionResponseValidator; +use Illuminate\Auth\Passwords\DatabaseTokenRepository; use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\MetadataService\MetadataStatementRepository; use Webauthn\AttestationStatement\AttestationObjectLoader; @@ -166,6 +170,30 @@ protected function bindWebAuthnBasePackage() $this->app->bind(AuthenticationExtensionsClientInputs::class, static function () { return new AuthenticationExtensionsClientInputs; }); + + $this->app->singleton(CredentialBroker::class, static function ($app) { + if (! $config = $app['config']['auth.passwords.webauthn']) { + throw new RuntimeException('No [webauthn] key broker is configured in [config/auth.php]'); + } + + $key = $app['config']['app.key']; + + if (Str::startsWith($key, 'base64:')) { + $key = base64_decode(substr($key, 7)); + } + + return new CredentialBroker( + new DatabaseTokenRepository( + $app['db']->connection($config['connection'] ?? null), + $app['hash'], + $config['table'], + $key, + $config['expire'], + $config['throttle'] ?? 0 + ), + $app['auth']->createUserProvider($config['provider'] ?? null) + ); + }); } /** @@ -175,6 +203,9 @@ protected function bindWebAuthnBasePackage() */ public function boot() { + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'larapass'); + $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'larapass'); + $this->app['auth']->provider('eloquent-webauthn', static function ($app, $config) { return new EloquentWebAuthnProvider( $app['config'], @@ -184,6 +215,8 @@ public function boot() ); }); + $this->app['router']->aliasMiddleware('webauthn.confirm', Http\Middleware\RequireWebAuthn::class); + if ($this->app->runningInConsole()) { $this->publishFiles(); } @@ -205,16 +238,18 @@ protected function publishFiles() ], 'controllers'); $this->publishes([ - __DIR__.'/../resources/js' => public_path('vendor/larapass/js'), + __DIR__ . '/../resources/js' => public_path('vendor/larapass/js'), ], 'public'); - if (! class_exists('CreateWebAuthnCredentialsTable')) { - $this->publishes([ - __DIR__ . - '/../database/migrations/2020_04_02_000000_create_web_authn_credentials_table.php' => database_path('migrations/' . - now()->format('Y_m_d_His') . - '_create_web_authn_credentials_table.php'), - ], 'migrations'); - } + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/larapass'), + ], 'views'); + + $this->publishes([ + __DIR__ . + '/../database/migrations/2020_04_02_000000_create_web_authn_tables.php' => database_path('migrations/' . + now()->format('Y_m_d_His') . + '_create_web_authn_tables.php'), + ], 'migrations'); } } \ No newline at end of file diff --git a/src/Notifications/AccountRecoveryNotification.php b/src/Notifications/AccountRecoveryNotification.php new file mode 100644 index 0000000..4266edf --- /dev/null +++ b/src/Notifications/AccountRecoveryNotification.php @@ -0,0 +1,105 @@ +token = $token; + } + + /** + * Get the notification's channels. + * + * @param mixed $notifiable + * @return array|string + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Build the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + if (static::$toMailCallback) { + return call_user_func(static::$toMailCallback, $notifiable, $this->token); + } + + if (static::$createUrlCallback) { + $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token); + } else { + $url = url(route('webauthn.recover.form', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ], false)); + } + + return (new MailMessage) + ->subject(Lang::get('Account Recovery Notification')) + ->line(Lang::get('You are receiving this email because we received an account recovery request for your account.')) + ->action(Lang::get('Recover Account'), $url) + ->line(Lang::get('This recovery link will expire in :count minutes.', [ + 'count' => config('auth.passwords.webauthn.expire') + ])) + ->line(Lang::get('If you did not request an account recovery, no further action is required.')); + } + + /** + * Set a callback that should be used when creating the reset password button URL. + * + * @param callable $callback + * @return void + */ + public static function createUrlUsing($callback) + { + static::$createUrlCallback = $callback; + } + + /** + * Set a callback that should be used when building the notification mail message. + * + * @param callable $callback + * @return void + */ + public static function toMailUsing($callback) + { + static::$toMailCallback = $callback; + } +} \ No newline at end of file diff --git a/src/WebAuthnAuthentication.php b/src/WebAuthnAuthentication.php index e3c073d..192af12 100644 --- a/src/WebAuthnAuthentication.php +++ b/src/WebAuthnAuthentication.php @@ -4,8 +4,8 @@ use Illuminate\Support\Str; use Webauthn\PublicKeyCredentialUserEntity as UserEntity; -use Webauthn\PublicKeyCredentialSource as CredentialSource; use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential; +use Webauthn\PublicKeyCredentialSource as CredentialSource; use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable; /** @@ -22,7 +22,7 @@ public function webAuthnCredentials() } /** - * Creates an user entity information for attestation (register). + * Creates an user entity information for attestation (registration). * * @return \Webauthn\PublicKeyCredentialUserEntity */ @@ -38,9 +38,8 @@ public function userEntity() : UserEntity */ public function userHandle() : string { - return $this->webAuthnCredentials()->firstOrNew([], [ - 'user_handle' => $this->generateUserHandle() - ])->user_handle; + return $this->webAuthnCredentials()->withTrashed()->value('user_handle') + ?? $this->generateUserHandle(); } /** @@ -103,6 +102,17 @@ public function removeCredential($id) : void $this->webAuthnCredentials()->whereKey($id)->forceDelete(); } + /** + * Removes all credentials previously registered. + * + * @param string|array|null $except + * @return void + */ + public function flushCredentials($except = null) : void + { + $this->webAuthnCredentials()->whereKeyNot($except)->forceDelete(); + } + /** * Checks if a given credential exists and is enabled. * @@ -137,14 +147,14 @@ public function disableCredential($id) : void } /** - * Removes all credentials previously registered. + * Disables all credentials for the user. * * @param string|array|null $except * @return void */ - public function flushCredentials($except = null) : void + public function disableAllCredentials($except = null) : void { - $this->webAuthnCredentials()->whereKeyNot($except)->forceDelete(); + $this->webAuthnCredentials()->whereKeyNot($except)->delete(); } /** @@ -162,6 +172,17 @@ public function allCredentialDescriptors() : array ->all(); } + /** + * Sends a credential recovery email to the user. + * + * @param string $token + * @return void + */ + public function sendCredentialRecoveryNotification(string $token) : void + { + $this->notify(new Notifications\AccountRecoveryNotification($token)); + } + /** * Returns an WebAuthnAuthenticatable user from a given Credential ID. * diff --git a/stubs/WebAuthnConfirmController.php b/stubs/WebAuthnConfirmController.php new file mode 100644 index 0000000..fd42993 --- /dev/null +++ b/stubs/WebAuthnConfirmController.php @@ -0,0 +1,41 @@ +middleware('auth'); + $this->middleware('throttle:10,1')->only('options', 'confirm'); + } +} \ No newline at end of file diff --git a/stubs/WebAuthnDeviceLostController.php b/stubs/WebAuthnDeviceLostController.php new file mode 100644 index 0000000..b576833 --- /dev/null +++ b/stubs/WebAuthnDeviceLostController.php @@ -0,0 +1,27 @@ +middleware('guest'); + } +} \ No newline at end of file diff --git a/stubs/WebAuthnLoginController.php b/stubs/WebAuthnLoginController.php index 307df5a..4c0d99b 100644 --- a/stubs/WebAuthnLoginController.php +++ b/stubs/WebAuthnLoginController.php @@ -3,11 +3,11 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use DarkGhostHunter\Larapass\Http\AssertsWebAuthn; +use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn; class WebAuthnLoginController extends Controller { - use AssertsWebAuthn; + use AuthenticatesWebAuthn; /* |-------------------------------------------------------------------------- diff --git a/stubs/WebAuthnRecoveryController.php b/stubs/WebAuthnRecoveryController.php new file mode 100644 index 0000000..53da887 --- /dev/null +++ b/stubs/WebAuthnRecoveryController.php @@ -0,0 +1,41 @@ +middleware('guest'); + $this->middleware('throttle:10,1')->only('options', 'recover'); + } +} \ No newline at end of file diff --git a/stubs/WebAuthnRegisterController.php b/stubs/WebAuthnRegisterController.php index 490c103..b546160 100644 --- a/stubs/WebAuthnRegisterController.php +++ b/stubs/WebAuthnRegisterController.php @@ -3,11 +3,11 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use DarkGhostHunter\Larapass\Http\AttestsWebAuthn; +use DarkGhostHunter\Larapass\Http\RegistersWebAuthn; class WebAuthnRegisterController extends Controller { - use AttestsWebAuthn; + use RegistersWebAuthn; /* |-------------------------------------------------------------------------- diff --git a/tests/Auth/EloquentWebAuthnProviderTest.php b/tests/Auth/EloquentWebAuthnProviderTest.php index dc18bb1..f943a2f 100644 --- a/tests/Auth/EloquentWebAuthnProviderTest.php +++ b/tests/Auth/EloquentWebAuthnProviderTest.php @@ -90,7 +90,7 @@ public function test_retrieves_user_using_classic_credentials() $this->assertTrue($user->is($retrieved)); } - public function test_fails_retrieving_user_using_classic_credentials_without_fallback() + public function test_retrieves_user_using_classic_credentials_without_fallback() { $this->app['config']->set('larapass.fallback', false); @@ -107,7 +107,7 @@ public function test_fails_retrieving_user_using_classic_credentials_without_fal 'email' => 'john.doe@mail.com', ]); - $this->assertNull($retrieved); + $this->assertTrue($retrieved->is($user)); } public function test_validates_user_using_password_fallback() diff --git a/tests/Eloquent/WebAuthnAuthenticationTest.php b/tests/Eloquent/WebAuthnAuthenticationTest.php index 1fcc4e9..8ec7e24 100644 --- a/tests/Eloquent/WebAuthnAuthenticationTest.php +++ b/tests/Eloquent/WebAuthnAuthenticationTest.php @@ -34,6 +34,51 @@ protected function setUp() : void parent::setUp(); } + public function test_hides_from_serialization() + { + DB::table('web_authn_credentials')->insert([ + 'id' => 'test_credential_id', + 'name' => 'foo', + 'user_id' => 1, + 'type' => 'public_key', + 'transports' => json_encode([]), + 'attestation_type' => 'none', + 'trust_path' => json_encode(['type' => EmptyTrustPath::class]), + 'aaguid' => Str::uuid(), + 'public_key' => 'public_key_bar', + 'counter' => 0, + 'user_handle' => Str::uuid()->toString(), + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'disabled_at' => null, + ]); + + $this->assertSame([ + 'id' => 'test_credential_id', + 'name' => 'foo', + 'type' => 'public_key', + 'transports' => [], + ], WebAuthnCredential::first()->toArray()); + } + + public function test_returns_pretty_id() + { + $model = WebAuthnCredential::make([ + 'id' => base64_encode('test_credential_id'), + ]); + + $this->assertSame('test_credential_id', $model->prettyId); + } + + public function test_can_fill_name() + { + $model = WebAuthnCredential::make([ + 'name' => 'foo', + ]); + + $this->assertSame('foo', $model->name); + } + public function test_sets_aaguid_as_uuid() { $uuid = '6028b017-b1d4-4c02-b4b3-afcdafc96bb2'; @@ -81,7 +126,7 @@ public function test_finds_one_by_credential_id() 'user_handle' => Str::uuid()->toString(), 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), - 'disabled_at' => null + 'disabled_at' => null, ]); $this->assertInstanceOf(PublicKeyCredentialSource::class, @@ -116,7 +161,7 @@ public function test_find_all_for_user_entity() 'user_handle' => 'test_id', 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), - 'disabled_at' => null + 'disabled_at' => null, ]); $this->assertCount(1, $model->findAllForUserEntity($entity)); @@ -140,7 +185,7 @@ public function test_only_updates_counter_from_credential_source() 'user_handle' => Str::uuid()->toString(), 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), - 'disabled_at' => null + 'disabled_at' => null, ]); $model = WebAuthnCredential::make(); @@ -170,8 +215,8 @@ public function test_only_updates_counter_from_credential_source() )); $this->assertDatabaseHas('web_authn_credentials', [ - 'id' => 'test_credential_id', - 'counter' => 10 + 'id' => 'test_credential_id', + 'counter' => 10, ]); } @@ -206,6 +251,5 @@ public function test_checks_if_credential_is_enabled_or_disabled() $this->assertTrue($credential->isEnabled()); $this->assertFalse($credential->isDisabled()); - } } \ No newline at end of file diff --git a/tests/Http/WebAuthnConfirmTest.php b/tests/Http/WebAuthnConfirmTest.php new file mode 100644 index 0000000..04f0a4c --- /dev/null +++ b/tests/Http/WebAuthnConfirmTest.php @@ -0,0 +1,206 @@ +afterApplicationCreated(function () { + $this->afterApplicationCreated([$this, 'cleanFiles']); + $this->loadLaravelMigrations(); + $this->loadMigrationsFrom([ + '--realpath' => true, + '--path' => [ + realpath(__DIR__ . '/../../database/migrations'), + ], + ]); + + $this->user = TestWebAuthnUser::make()->forceFill([ + 'name' => 'john', + 'email' => 'john.doe@mail.com', + 'password' => '$2y$10$FLIykVJWDsYSVMJyaFZZfe4tF5uBTnGsosJBL.ZfAAHsYgc27FSdi', + ]); + + $this->user->save(); + + DB::table('web_authn_credentials')->insert([ + 'id' => 'test_credential_foo', + 'user_id' => 1, + 'type' => 'public_key', + 'transports' => json_encode([]), + 'attestation_type' => 'none', + 'trust_path' => json_encode(['type' => EmptyTrustPath::class]), + 'aaguid' => '00000000-0000-0000-0000-000000000000', + 'public_key' => 'public_key', + 'counter' => 0, + 'user_handle' => 'test_user_handle', + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'disabled_at' => null, + ]); + + $this->app['config']->set('auth.providers.users.driver', 'eloquent-webauthn'); + $this->app['config']->set('auth.providers.users.model', TestWebAuthnUser::class); + $this->app['config']->set('auth.passwords.webauthn', [ + 'provider' => 'users', + 'table' => 'web_authn_recoveries', + 'expire' => 60, + 'throttle' => 60, + ]); + + require_once __DIR__ . '/../Stubs/Controller.php'; + + $this->app['router']->get('login', function () { + return 'please login'; + }) + ->name('login') + ->middleware('web'); + + $this->app['router']->get('webauthn/confirm', + 'Tests\Stubs\TestWebAuthnConfirmController@showConfirmForm') + ->name('webauthn.confirm.form')->middleware(['web']); + $this->app['router']->post('webauthn/confirm/options', + 'Tests\Stubs\TestWebAuthnConfirmController@options') + ->name('webauthn.confirm.options')->middleware(['web']); + $this->app['router']->post('webauthn/confirm', + 'Tests\Stubs\TestWebAuthnConfirmController@confirm') + ->name('webauthn.confirm')->middleware(['web']); + + $this->app['router']->get('intended', function () { + return 'ok'; + })->middleware('webauthn.confirm', 'web'); + }); + + parent::setUp(); + } + + public function test_asks_for_confirmation() + { + $this->actingAs($this->user) + ->get('intended') + ->assertRedirect('webauthn/confirm'); + + $this->actingAs($this->user) + ->followingRedirects() + ->get('intended') + ->assertViewIs('larapass::confirm') + ->assertOk(); + } + + public function test_receives_attestation_options() + { + $this->postJson('webauthn/confirm/options') + ->assertUnauthorized(); + + $this->actingAs($this->user) + ->postJson('webauthn/confirm/options') + ->assertJsonStructure([ + 'challenge', + 'allowCredentials' => [ + 0 => ['type', 'id'], + ], + 'timeout', + ]); + } + + public function test_confirmed_user_gets_intended_route() + { + $this->actingAs($this->user) + ->get('intended') + ->assertRedirect('webauthn/confirm'); + + $this->mock(WebAuthnAssertValidator::class) + ->shouldReceive('validate') + ->with($data = [ + 'id' => 'test_credential_foo', + 'rawId' => Base64Url::encode('test_credential_foo'), + 'type' => 'test_type', + 'response' => [ + 'authenticatorData' => 'test', + 'clientDataJSON' => 'test', + 'signature' => 'test', + 'userHandle' => 'test', + ], + ]) + ->andReturnUsing(function ($data) { + $credentials = WebAuthnCredential::find($data['id']); + + $credentials->setAttribute('counter', 1)->save(); + + return $credentials->toCredentialSource(); + }); + + $this + ->postJson('webauthn/confirm', $data) + ->assertExactJson([ + 'redirectTo' => 'http://localhost/intended' + ]); + + $this + ->get('intended', $data) + ->assertSee('ok'); + } + + public function test_returns_error_if_validation_fails() + { + $this->actingAs($this->user) + ->get('intended') + ->assertRedirect('webauthn/confirm'); + + $this->postJson('webauthn/confirm', [ + 'foo' => 'bar' + ]) + ->assertStatus(422); + } + + public function test_returns_error_if_attestation_fails() + { + $this->actingAs($this->user) + ->get('intended') + ->assertRedirect('webauthn/confirm'); + + $this->mock(WebAuthnAssertValidator::class) + ->shouldReceive('validate') + ->with($data = [ + 'id' => 'test_credential_foo', + 'rawId' => Base64Url::encode('test_credential_foo'), + 'type' => 'test_type', + 'response' => [ + 'authenticatorData' => 'test', + 'clientDataJSON' => 'test', + 'signature' => 'test', + 'userHandle' => 'test', + ], + ]) + ->andReturnFalse(); + + $this + ->postJson('webauthn/confirm', $data) + ->assertStatus(422); + } +} \ No newline at end of file diff --git a/tests/Http/WebAuthnDeviceLostTest.php b/tests/Http/WebAuthnDeviceLostTest.php new file mode 100644 index 0000000..134f3af --- /dev/null +++ b/tests/Http/WebAuthnDeviceLostTest.php @@ -0,0 +1,194 @@ +afterApplicationCreated(function () { + $this->afterApplicationCreated([$this, 'cleanFiles']); + $this->loadLaravelMigrations(); + $this->loadMigrationsFrom([ + '--realpath' => true, + '--path' => [ + realpath(__DIR__ . '/../../database/migrations'), + ], + ]); + + TestWebAuthnUser::make()->forceFill([ + 'name' => 'john', + 'email' => 'john.doe@mail.com', + 'password' => '$2y$10$FLIykVJWDsYSVMJyaFZZfe4tF5uBTnGsosJBL.ZfAAHsYgc27FSdi', + ])->save(); + + DB::table('web_authn_credentials')->insert([ + 'id' => 'test_credential_foo', + 'user_id' => 1, + 'type' => 'public_key', + 'transports' => json_encode([]), + 'attestation_type' => 'none', + 'trust_path' => json_encode(['type' => EmptyTrustPath::class]), + 'aaguid' => '00000000-0000-0000-0000-000000000000', + 'public_key' => 'public_key', + 'counter' => 0, + 'user_handle' => 'test_user_handle', + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'disabled_at' => null, + ]); + + $this->app['config']->set('auth.providers.users.driver', 'eloquent-webauthn'); + $this->app['config']->set('auth.providers.users.model', TestWebAuthnUser::class); + $this->app['config']->set('auth.passwords.webauthn' , [ + 'provider' => 'users', + 'table' => 'web_authn_recoveries', + 'expire' => 60, + 'throttle' => 60, + ]); + + require_once __DIR__ . '/../Stubs/Controller.php'; + require_once __DIR__ . '/../Stubs/TestWebAuthnDeviceLostController.php'; + require_once __DIR__ . '/../Stubs/TestWebAuthnRecoveryController.php'; + + $this->app['router'] + ->get( + 'webauthn/lost', + 'App\Http\Controllers\Auth\TestWebAuthnDeviceLostController@showDeviceLostForm') + ->name('webauthn.lost.form') + ->middleware('web'); + $this->app['router'] + ->post( + 'webauthn/lost', + 'App\Http\Controllers\Auth\TestWebAuthnDeviceLostController@sendRecoveryEmail') + ->name('webauthn.lost.send') + ->middleware('web'); + + $this->app['router'] + ->get( + 'webauthn/recover', + 'App\Http\Controllers\Auth\TestWebAuthnRecoveryController@showResetForm') + ->name('webauthn.recover.form') + ->middleware('web'); + }); + + parent::setUp(); + } + + public function test_shows_recovery_form() + { + $this->get('webauthn/lost') + ->assertViewIs('larapass::lost') + ->assertSee(trans('larapass::recovery.title')) + ->assertSee(trans('larapass::recovery.description')) + ->assertSee(trans('larapass::recovery.button.send')) + ->assertSee(route('webauthn.lost.send')); + } + + public function test_sends_recovery_email() + { + $notification = Notification::fake(); + + $this->post('webauthn/lost', [ + 'email' => 'john.doe@mail.com' + ], [ + 'HTTP_REFERER' => route('webauthn.lost.form') + ]) + ->assertSessionHas('status') + ->assertRedirect(route('webauthn.lost.form')); + + $notification->assertSentTo(TestWebAuthnUser::first(), AccountRecoveryNotification::class); + + $this->assertDatabaseHas('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com' + ]); + } + + public function test_error_if_email_invalid() + { + $notification = Notification::fake(); + + $this->post('webauthn/lost', [ + 'email' => 'invalid' + ], [ + 'HTTP_REFERER' => route('webauthn.lost.form') + ]) + ->assertRedirect(route('webauthn.lost.form')) + ->assertSessionHasErrors(['email']); + + $notification->assertNothingSent(); + + $this->assertDatabaseMissing('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com' + ]); + } + + public function test_error_if_user_email_doesnt_exists() + { + $notification = Notification::fake(); + + $this->post('webauthn/lost', [ + 'email' => 'foo@bar.com' + ], [ + 'HTTP_REFERER' => route('webauthn.lost.form') + ]) + ->assertRedirect(route('webauthn.lost.form')) + ->assertSessionHasErrors(['email']); + + $notification->assertNothingSent(); + + $this->assertDatabaseMissing('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com' + ]); + } + + public function test_throttled_on_resend() + { + $notification = Notification::fake(); + + Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30)); + + $this->post('webauthn/lost', [ + 'email' => 'john.doe@mail.com' + ], [ + 'HTTP_REFERER' => route('webauthn.lost.form') + ]) + ->assertSessionHas('status') + ->assertRedirect(route('webauthn.lost.form')); + + $notification->assertSentTo(TestWebAuthnUser::first(), AccountRecoveryNotification::class); + + $this->assertDatabaseHas('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com' + ]); + + $this->post('webauthn/lost', [ + 'email' => 'john.doe@mail.com' + ], [ + 'HTTP_REFERER' => route('webauthn.lost.form') + ]) + ->assertRedirect(route('webauthn.lost.form')) + ->assertSessionHasErrors(['email']); + } +} \ No newline at end of file diff --git a/tests/Http/WebAuthnLoginTest.php b/tests/Http/WebAuthnLoginTest.php index 4dfbca5..070e535 100644 --- a/tests/Http/WebAuthnLoginTest.php +++ b/tests/Http/WebAuthnLoginTest.php @@ -223,7 +223,7 @@ public function test_user_authenticates_with_webauthn() 'rawId' => Base64Url::encode('test_credential_id'), ]) ->andReturnUsing(function ($data) { - $credentials = WebAuthnCredential::whereKey($data['id'])->first(); + $credentials = WebAuthnCredential::find($data['id']); $credentials->setAttribute('counter', 1)->save(); diff --git a/tests/Http/WebAuthnRecoveryTest.php b/tests/Http/WebAuthnRecoveryTest.php new file mode 100644 index 0000000..89a0a73 --- /dev/null +++ b/tests/Http/WebAuthnRecoveryTest.php @@ -0,0 +1,446 @@ +afterApplicationCreated(function () { + $this->afterApplicationCreated([$this, 'cleanFiles']); + $this->loadLaravelMigrations(); + $this->loadMigrationsFrom([ + '--realpath' => true, + '--path' => [ + realpath(__DIR__ . '/../../database/migrations'), + ], + ]); + + TestWebAuthnUser::make()->forceFill([ + 'name' => 'john', + 'email' => 'john.doe@mail.com', + 'password' => '$2y$10$FLIykVJWDsYSVMJyaFZZfe4tF5uBTnGsosJBL.ZfAAHsYgc27FSdi', + ])->save(); + + DB::table('web_authn_credentials')->insert([ + 'id' => 'test_credential_foo', + 'user_id' => 1, + 'type' => 'public_key', + 'transports' => json_encode([]), + 'attestation_type' => 'none', + 'trust_path' => json_encode(['type' => EmptyTrustPath::class]), + 'aaguid' => '00000000-0000-0000-0000-000000000000', + 'public_key' => 'public_key', + 'counter' => 0, + 'user_handle' => 'test_user_handle', + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + 'disabled_at' => null, + ]); + + $this->app['config']->set('auth.providers.users.driver', 'eloquent-webauthn'); + $this->app['config']->set('auth.providers.users.model', TestWebAuthnUser::class); + $this->app['config']->set('auth.passwords.webauthn', [ + 'provider' => 'users', + 'table' => 'web_authn_recoveries', + 'expire' => 60, + 'throttle' => 60, + ]); + + require_once __DIR__ . '/../Stubs/Controller.php'; + require_once __DIR__ . '/../Stubs/TestWebAuthnDeviceLostController.php'; + require_once __DIR__ . '/../Stubs/TestWebAuthnRecoveryController.php'; + + $this->app['router'] + ->get( + 'webauthn/lost', + 'App\Http\Controllers\Auth\TestWebAuthnDeviceLostController@showDeviceLostForm') + ->name('webauthn.lost.form') + ->middleware('web'); + + $this->app['router'] + ->get( + 'webauthn/recover', + 'App\Http\Controllers\Auth\TestWebAuthnRecoveryController@showResetForm') + ->name('webauthn.recover.form') + ->middleware('web'); + $this->app['router'] + ->post( + 'webauthn/recover/options', + 'App\Http\Controllers\Auth\TestWebAuthnRecoveryController@options') + ->name('webauthn.recover.options') + ->middleware('web'); + $this->app['router'] + ->post( + 'webauthn/recover/register', + 'App\Http\Controllers\Auth\TestWebAuthnRecoveryController@recover') + ->name('webauthn.recover') + ->middleware('web'); + }); + + parent::setUp(); + } + + public function test_shows_form() + { + $this->get('webauthn/recover?email=john.doe@mail.com&token=test_token') + ->assertViewIs('larapass::recover') + ->assertSee(trans('larapass::recovery.instructions')) + ->assertSee(trans('larapass::recovery.unique')) + ->assertOk(); + } + + public function test_redirects_when_no_email_or_token_is_present() + { + $this->get('webauthn/recover') + ->assertRedirect(route('webauthn.lost.form')); + + $this->get('webauthn/recover?email=foo@bar.com') + ->assertRedirect(route('webauthn.lost.form')); + + $this->get('webauthn/recover?token=test_token') + ->assertRedirect(route('webauthn.lost.form')); + } + + public function test_requests_attestation_for_new_device() + { + Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30)); + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => $now->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/options', [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + ])->assertJsonStructure([ + 'rp', + 'pubKeyCredParams', + 'challenge', + 'attestation', + 'user', + 'authenticatorSelection', + 'timeout', + ])->assertJsonFragment([ + 'user' => [ + 'name' => 'john.doe@mail.com', + 'id' => base64_encode('test_user_handle'), + 'displayName' => 'john', + ], + ]); + + $this->assertDatabaseHas('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com', + ]); + } + + public function test_fails_if_no_recovery_is_set() + { + $this->post('webauthn/recover/options', [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + ])->assertStatus(401); + } + + public function test_fails_when_token_doesnt_exists() + { + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/options', [ + 'email' => 'john.doe@mail.com', + 'token' => 'foo_bar', + ])->assertStatus(401); + } + + public function test_fails_when_token_expired() + { + Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30)); + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => $now->clone()->subHour()->subSecond()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/options', [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + ])->assertStatus(401); + } + + public function test_fails_when_no_user_exists() + { + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/options', [ + 'email' => 'mike.doe@mail.com', + 'token' => 'test_token', + ])->assertStatus(401); + } + + public function test_register_new_device() + { + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->mock(WebAuthnAttestValidator::class) + ->shouldReceive('validate') + ->with($data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ], Mockery::type(TestWebAuthnUser::class)) + ->andReturn(new PublicKeyCredentialSource( + 'test_id', + 'test_type', + [], + 'none', + new EmptyTrustPath(), + Uuid::uuid4(), + 'test_public_key', + 'test_user_handle', + 0 + )); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + ]) + ->assertOk() + ->assertJson([ + 'redirectTo' => '/home', + ]); + + $this->assertDatabaseMissing('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com', + ]); + + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_id', + ]); + } + + public function test_register_new_credential_and_disables_the_rest() + { + Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30)); + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->mock(WebAuthnAttestValidator::class) + ->shouldReceive('validate') + ->with($data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ], Mockery::type(TestWebAuthnUser::class)) + ->andReturn(new PublicKeyCredentialSource( + 'test_id', + 'test_type', + [], + 'none', + new EmptyTrustPath(), + Uuid::uuid4(), + 'test_public_key', + 'test_user_handle', + 0 + )); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + 'WebAuthn-Unique' => 'on', + ]) + ->assertOk() + ->assertJson([ + 'redirectTo' => '/home', + ]); + + $this->assertDatabaseMissing('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com', + ]); + + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_credential_foo', + 'disabled_at' => $now->toDateTimeString(), + ]); + + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_id', + ]); + } + + public function test_register_fails_if_no_email_or_token_sent() + { + $data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ]; + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/register', $data, [ + 'WebAuthn-Unique' => 'on', + ])->assertStatus(422); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'WebAuthn-Unique' => 'on', + ])->assertStatus(422); + + $this->postJson('webauthn/recover/register', $data, [ + 'token' => 'test_token', + 'WebAuthn-Unique' => 'on', + ])->assertStatus(422); + } + + public function test_register_fails_if_token_invalid() + { + $data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ]; + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'token' => 'invalid_token', + 'WebAuthn-Unique' => 'on', + ])->assertStatus(422); + } + + public function test_register_fails_when_token_expired() + { + Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30)); + + $data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ]; + + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => $now->clone()->subHour()->subSecond()->toDateTimeString(), + ]); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + 'WebAuthn-Unique' => 'on', + ])->assertStatus(422); + } + + public function test_attestation_fails_and_recovery_is_deleted_anyway() + { + DB::table('web_authn_recoveries')->insert([ + 'email' => 'john.doe@mail.com', + 'token' => '$2y$10$hgGTVVTRLsSYSlAHpyydBu6m4ZuRheBqTTUfRE/aG89DaqEyo.HPu', + 'created_at' => now()->toDateTimeString(), + ]); + + $this->mock(WebAuthnAttestValidator::class) + ->shouldReceive('validate') + ->with($data = [ + 'id' => 'test_id', + 'rawId' => Base64Url::encode('test_user_handle'), + 'response' => [ + 'attestationObject' => 'test_attestationObject', + 'clientDataJSON' => 'test_clientDataJSON', + ], + 'type' => 'test_type', + ], Mockery::type(TestWebAuthnUser::class)) + ->andReturnFalse(); + + $this->postJson('webauthn/recover/register', $data, [ + 'email' => 'john.doe@mail.com', + 'token' => 'test_token', + 'WebAuthn-Unique' => 'on', + ]) + ->assertOk() + ->assertJson([ + 'redirectTo' => '/home', + ]); + + $this->assertDatabaseMissing('web_authn_recoveries', [ + 'email' => 'john.doe@mail.com', + ]); + + $this->assertDatabaseMissing('web_authn_credentials', [ + 'id' => 'test_id', + ]); + } +} \ No newline at end of file diff --git a/tests/Notifications/AccountRecoveryNotificationTest.php b/tests/Notifications/AccountRecoveryNotificationTest.php new file mode 100644 index 0000000..08d3716 --- /dev/null +++ b/tests/Notifications/AccountRecoveryNotificationTest.php @@ -0,0 +1,91 @@ +app['config']->set('auth.passwords.webauthn', [ + 'provider' => 'users', + 'table' => 'web_authn_recoveries', + 'expire' => 15, + 'throttle' => 60, + ]); + + $this->app['router']->get('route', function () {})->name('webauthn.recover.form'); + + $user = TestWebAuthnUser::make()->forceFill([ + 'email' => 'test@test.com' + ]); + + $mail = (new AccountRecoveryNotification('test_token'))->toMail($user)->render(); + + $this->assertStringContainsString( + 'assertStringContainsString( + 'Recover Account', + $mail + ); + + $this->assertStringContainsString( + 'You are receiving this email because we received an account recovery request for your account', + $mail + ); + + $this->assertStringContainsString( + 'This recovery link will expire in 15 minutes.', + $mail + ); + + $this->assertStringContainsString( + 'If you did not request an account recovery, no further action is required.', + $mail + ); + } + + public function test_uses_to_mail_callback() + { + AccountRecoveryNotification::toMailUsing(function ($user, $token) { + return $user->email . '|' . $token; + }); + + $user = TestWebAuthnUser::make()->forceFill([ + 'email' => 'test@test.com' + ]); + + $this->assertSame('test@test.com|test_token', + (new AccountRecoveryNotification('test_token'))->toMail($user) + ); + } + + public function test_uses_to_token_callback() + { + AccountRecoveryNotification::createUrlUsing(function ($user, $token) { + return $user->email . '|' . $token; + }); + + $user = TestWebAuthnUser::make()->forceFill([ + 'email' => 'test@test.com' + ]); + + $mail = (new AccountRecoveryNotification('test_token'))->toMail($user)->render(); + + $this->assertStringContainsString('middleware('auth'); + } + +} \ No newline at end of file diff --git a/tests/Stubs/TestWebAuthnDeviceLostController.php b/tests/Stubs/TestWebAuthnDeviceLostController.php new file mode 100644 index 0000000..2884de2 --- /dev/null +++ b/tests/Stubs/TestWebAuthnDeviceLostController.php @@ -0,0 +1,27 @@ +middleware('guest'); + } +} \ No newline at end of file diff --git a/tests/Stubs/TestWebAuthnLoginController.php b/tests/Stubs/TestWebAuthnLoginController.php index 27aaf8b..69ec54c 100644 --- a/tests/Stubs/TestWebAuthnLoginController.php +++ b/tests/Stubs/TestWebAuthnLoginController.php @@ -3,11 +3,11 @@ namespace Tests\Stubs; use Illuminate\Routing\Controller; -use DarkGhostHunter\Larapass\Http\AssertsWebAuthn; +use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn; class TestWebAuthnLoginController extends Controller { - use AssertsWebAuthn; + use AuthenticatesWebAuthn; /* |-------------------------------------------------------------------------- diff --git a/tests/Stubs/TestWebAuthnRecoveryController.php b/tests/Stubs/TestWebAuthnRecoveryController.php new file mode 100644 index 0000000..29354e5 --- /dev/null +++ b/tests/Stubs/TestWebAuthnRecoveryController.php @@ -0,0 +1,39 @@ +middleware('throttle:10,1')->only('options', 'recover'); + } +} \ No newline at end of file diff --git a/tests/Stubs/TestWebAuthnRegisterController.php b/tests/Stubs/TestWebAuthnRegisterController.php index 3f8c1c4..a3cad71 100644 --- a/tests/Stubs/TestWebAuthnRegisterController.php +++ b/tests/Stubs/TestWebAuthnRegisterController.php @@ -3,11 +3,11 @@ namespace Tests\Stubs; use Illuminate\Routing\Controller; -use DarkGhostHunter\Larapass\Http\AttestsWebAuthn; +use DarkGhostHunter\Larapass\Http\RegistersWebAuthn; class TestWebAuthnRegisterController extends Controller { - use AttestsWebAuthn; + use RegistersWebAuthn; /* |-------------------------------------------------------------------------- diff --git a/tests/Stubs/TestWebAuthnUser.php b/tests/Stubs/TestWebAuthnUser.php index ee5d45b..2ad1f0a 100644 --- a/tests/Stubs/TestWebAuthnUser.php +++ b/tests/Stubs/TestWebAuthnUser.php @@ -3,6 +3,7 @@ namespace Tests\Stubs; use Illuminate\Foundation\Auth\User; +use Illuminate\Notifications\Notifiable; use DarkGhostHunter\Larapass\WebAuthnAuthentication; use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable; use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential as WebAuthModel; @@ -12,7 +13,8 @@ */ class TestWebAuthnUser extends User implements WebAuthnAuthenticatable { - use WebAuthnAuthentication; + use WebAuthnAuthentication, + Notifiable; protected $table = 'users'; diff --git a/tests/WebAuthnAuthenticationTest.php b/tests/WebAuthnAuthenticationTest.php index af87c60..3f5c9f5 100644 --- a/tests/WebAuthnAuthenticationTest.php +++ b/tests/WebAuthnAuthenticationTest.php @@ -111,6 +111,17 @@ public function test_returns_user_entity_with_handle_used_previously() $this->assertSame($this->user->userEntity()->getId(), $this->user->userEntity()->getId()); } + public function test_returns_user_entity_with_handle_used_in_disabled_credential() + { + $entity = $this->user->userEntity()->getId(); + + DB::table('web_authn_credentials') + ->where('test_credential_bar') + ->update(['disabled_at' => now()]); + + $this->assertSame($entity, $this->user->userEntity()->getId()); + } + public function test_returns_all_credentials_as_excluded() { $this->assertCount(2, $this->user->attestationExcludedCredentials()); @@ -184,6 +195,10 @@ public function test_enables_and_disables_credentials() 'disabled_at' => $now->toDateTimeString(), ]); + $this->user->webAuthnCredentials()->update([ + 'disabled_at' => null + ]); + $this->user->disableCredential(['test_credential_foo', 'test_credential_bar']); $this->assertCount(2, DB::table('web_authn_credentials')->whereNotNull('disabled_at')->get()); @@ -193,10 +208,41 @@ public function test_enables_and_disables_credentials() 'disabled_at' => null, ]); + $this->user->disableAllCredentials(); + $this->user->enableCredential(['test_credential_foo', 'test_credential_bar']); $this->assertCount(2, DB::table('web_authn_credentials')->whereNull('disabled_at')->get()); } + public function test_disables_all_credentials() + { + $this->user->disableAllCredentials(); + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_credential_foo', + ]); + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_credential_bar', + ]); + $this->assertDatabaseMissing('web_authn_credentials', [ + 'disabled_at' => null, + ]); + } + + public function test_disables_all_credentials_except_some() + { + Date::setTestNow($now = Date::create(2020, 04, 01, 16, 30)); + + $this->user->disableAllCredentials('test_credential_bar'); + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_credential_foo', + 'disabled_at' => $now->toDateTimeString(), + ]); + $this->assertDatabaseHas('web_authn_credentials', [ + 'id' => 'test_credential_bar', + 'disabled_at' => null, + ]); + } + public function test_deletes_credentials() { $this->user->removeCredential('test_credential_foo');