From 36ae69064eede6829d14e458226bbed8cb50d5f9 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Wed, 17 Mar 2021 21:01:05 -0300 Subject: [PATCH 01/22] Added more links to 2FA apps. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e683df4..3bf3ecd 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The contract is used to identify the model using Two Factor Authentication, whil To enable Two Factor Authentication successfully, the User must sync the Shared Secret between its Authenticator app and the application. -> Some free Authenticator Apps are [FreeOTP](https://freeotp.github.io/), [Authy](https://authy.com/), [andOTP](https://github.com/andOTP/andOTP), [Google Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en), and [Microsoft Authenticator](https://www.microsoft.com/en-us/account/authenticator), to name a few. +> Some free Authenticator Apps are [FreeOTP](https://freeotp.github.io/), [Authy](https://authy.com/), [andOTP](https://github.com/andOTP/andOTP), [Google](https://apps.apple.com/app/google-authenticator/id388497605) [Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en), and [Microsoft Authenticator](https://www.microsoft.com/en-us/account/authenticator), to name a few. To start, generate the needed data using the `createTwoFactorAuth()` method. Once you do, you can show the Shared Secret to the User as a string or QR Code (encoded as SVG) in your view. From 0c984d5c8fc03e5f08bbc98ef0c62382e30b638c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 29 Apr 2021 21:11:44 +0000 Subject: [PATCH 02/22] Upgrade to GitHub-native Dependabot --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7995edd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: daily + time: "09:00" + open-pull-requests-limit: 10 From f2abfbfbfa6580e5487178af0ae93f8161a16343 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Fri, 3 Sep 2021 11:40:31 -0400 Subject: [PATCH 03/22] Minor optimization --- src/Listeners/ChecksTwoFactorCode.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Listeners/ChecksTwoFactorCode.php b/src/Listeners/ChecksTwoFactorCode.php index 80b163c..6fc0219 100644 --- a/src/Listeners/ChecksTwoFactorCode.php +++ b/src/Listeners/ChecksTwoFactorCode.php @@ -18,14 +18,8 @@ protected function shouldUseTwoFactorAuth($user = null) return false; } - $shouldUse = $user->hasTwoFactorEnabled(); - - // If safe devices is active, then it should be used if the current is not. - if ($this->isSafeDevicesEnabled()) { - return $shouldUse && ! $user->isSafeDevice($this->request); - } - - return $shouldUse; + return $user->hasTwoFactorEnabled() + && (! $this->isSafeDevicesEnabled() || ! $user->isSafeDevice($this->request)); } /** From a7854817be9b1f0aa09353752a1d2a71e315ac49 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sat, 4 Sep 2021 14:40:37 -0400 Subject: [PATCH 04/22] First 4.0 commit. Tests pending. --- README.md | 239 +++++++----------- UPGRADE.md | 18 ++ composer.json | 11 +- config/laraguard.php | 28 +- .../TwoFactorAuthenticationFactory.php | 78 +++--- ...reate_two_factor_authentications_table.php | 10 +- ...grade_two_factor_authentications_table.php | 85 +++++++ resources/views/auth.blade.php | 41 --- src/Contracts/TwoFactorAuthenticatable.php | 21 +- src/Contracts/TwoFactorListener.php | 25 -- src/Contracts/TwoFactorTotp.php | 3 +- src/Eloquent/HandlesCodes.php | 82 +++--- src/Eloquent/HandlesRecoveryCodes.php | 18 +- src/Eloquent/HandlesSafeDevices.php | 8 +- src/Eloquent/SerializesSharedSecret.php | 23 +- src/Eloquent/TwoFactorAuthentication.php | 63 +++-- src/Exceptions/InvalidCodeException.php | 45 ++++ src/Http/Controllers/Confirms2FACode.php | 20 +- src/Http/Middleware/ConfirmTwoFactorCode.php | 42 +-- src/Laraguard.php | 133 ++++++++++ src/LaraguardServiceProvider.php | 72 ++---- src/Listeners/ChecksTwoFactorCode.php | 75 ------ src/Listeners/EnforceTwoFactorAuth.php | 127 ---------- src/Rules/TotpCodeRule.php | 28 +- src/TwoFactorAuthentication.php | 146 ++++++----- 25 files changed, 675 insertions(+), 766 deletions(-) create mode 100644 UPGRADE.md create mode 100644 database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php delete mode 100644 resources/views/auth.blade.php delete mode 100644 src/Contracts/TwoFactorListener.php create mode 100644 src/Exceptions/InvalidCodeException.php create mode 100644 src/Laraguard.php delete mode 100644 src/Listeners/ChecksTwoFactorCode.php delete mode 100644 src/Listeners/EnforceTwoFactorAuth.php diff --git a/README.md b/README.md index 3bf3ecd..123fee4 100644 --- a/README.md +++ b/README.md @@ -10,46 +10,17 @@ ![](https://github.com/DarkGhostHunter/Laraguard/workflows/PHP%20Composer/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/DarkGhostHunter/Laraguard/badge.svg?branch=master)](https://coveralls.io/github/DarkGhostHunter/Laraguard?branch=master) -Two Factor Authentication via TOTP for all your users out-of-the-box. +Two-Factor Authentication via TOTP for all your users out-of-the-box. This package _silently_ enables authentication using 6 digits codes, without Internet or external providers. ## Requirements -* Laravel 8.x -* PHP 7.4 or PHP 8.0 +* [Laravel 8.39 or later](https://github.com/laravel/framework/blob/8.x/CHANGELOG-8.x.md#v8390-2021-04-27) +* PHP 8.0 > For older versions support, consider helping by sponsoring or donating. -## Table of Contents - -* [Installation](#installation) - + [How this works](#how-this-works) -* [Usage](#usage) - + [Enabling Two Factor Authentication](#enabling-two-factor-authentication) - + [Recovery Codes](#recovery-codes) - + [Logging in](#logging-in) - + [Deactivation](#deactivation) -* [Events](#events) -* [Middleware](#middleware) -* [Validation](#validation) -* [Translations](#translations) -* [Protecting the Login](#protecting-the-login) -* [Configuration](#configuration) - + [Listener](#listener) - + [Eloquent Model](#eloquent-model) - + [Input name](#input-name) - + [Cache Store](#cache-store) - + [Recovery](#recovery) - + [Safe devices](#safe-devices) - + [Confirmation Middleware](#confirmation-middleware) - + [Secret length](#secret-length) - + [TOTP configuration](#totp-configuration) - + [QR Code Configuration](#qr-code-configuration) - + [Custom view](#custom-view) -* [Security](#security) -* [License](#license) - ## Installation Fire up Composer and require this package in your project. @@ -60,9 +31,9 @@ That's it. ### How this works -This package adds a **Contract** to detect in a **per-user basis** if, after the credentials are deemed valid, should use Two Factor Authentication as a second layer of authentication. +This package adds a **Contract** to detect in a **per-user basis** if, after the credentials are deemed valid, should use Two-Factor Authentication as a second layer of authentication. -It includes a custom **view** and a **listener** to handle the Two Factor authentication itself during login attempts. +It includes a custom **view** and a **callback** to handle the Two-Factor authentication itself during login attempts. This package was made to be the less invasive possible, but you can go full manual if you want. @@ -73,9 +44,9 @@ First, create the `two_factor_authentications` table by publishing the migration php artisan vendor:publish --provider="DarkGhostHunter\Laraguard\LaraguardServiceProvider" --tag="migrations" php artisan migrate -This will create a table to handle the Two Factor Authentication information for each model you want to attach to 2FA. +This will create a table to handle the Two-Factor Authentication information for each model you want to attach to 2FA. -Add the `TwoFactorAuthenticatable` _contract_ and the `TwoFactorAuthentication` trait to the User model, or any other model you want to make Two Factor Authentication available. +Add the `TwoFactorAuthenticatable` _contract_ and the `TwoFactorAuthentication` trait to the User model, or any other model you want to make Two-Factor Authentication available. ```php Some free Authenticator Apps are [FreeOTP](https://freeotp.github.io/), [Authy](https://authy.com/), [andOTP](https://github.com/andOTP/andOTP), [Google](https://apps.apple.com/app/google-authenticator/id388497605) [Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en), and [Microsoft Authenticator](https://www.microsoft.com/en-us/account/authenticator), to name a few. +> Some free Authenticator Apps are [iOS Authenticator](https://www.apple.com/ios/ios-15-preview/features/#:~:text=Built-in%20authenticator), [FreeOTP](https://freeotp.github.io/), [Authy](https://authy.com/), [andOTP](https://github.com/andOTP/andOTP), [Google](https://apps.apple.com/app/google-authenticator/id388497605) [Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en), and [Microsoft Authenticator](https://www.microsoft.com/en-us/account/authenticator), to name a few. To start, generate the needed data using the `createTwoFactorAuth()` method. Once you do, you can show the Shared Secret to the User as a string or QR Code (encoded as SVG) in your view. @@ -117,7 +88,7 @@ public function prepareTwoFactor(Request $request) } ``` -> When you use `createTwoFactorAuth()` on someone with Two Factor Authentication already enabled, the previous data becomes permanently invalid. This ensures a User **never** has two Shared Secrets enabled at any given time. +> When you use `createTwoFactorAuth()` on someone with Two-Factor Authentication already enabled, the previous data becomes permanently invalid. This ensures a User **never** has two Shared Secrets enabled at any given time. Then, the User must confirm the Shared Secret with a Code generated by their Authenticator app. The `confirmTwoFactorAuth()` method will automatically enable it if the code is valid. @@ -136,7 +107,7 @@ If the User doesn't issue the correct Code, the method will return `false`. You ### Recovery Codes -Recovery Codes are automatically generated each time the Two Factor Authentication is enabled. By default, a Collection of ten one-use 8-characters codes are created. +Recovery Codes are automatically generated each time the Two-Factor Authentication is enabled. By default, a Collection of ten one-use 8-characters codes are created. You can show them using `getRecoveryCodes()`. @@ -151,7 +122,7 @@ public function confirmTwoFactor(Request $request) } ``` -You're free on how to show these codes to the User, but **ensure** you show them one time after a successfully enabling Two Factor Authentication, and ask him to print them somewhere. +You're free on how to show these codes to the User, but **ensure** you show them one time after a successfully enabling Two-Factor Authentication, and ask him to print them somewhere. > These Recovery Codes are handled automatically when the User validates one. If it's a recovery code, the package will use and mark it as invalid. @@ -164,31 +135,72 @@ public function showRecoveryCodes(Request $request) } ``` -> If the User depletes his recovery codes without disabling Two Factor Authentication, or Recovery Codes are deactivated, **he may be locked out forever without his Authenticator app**. Ensure you have countermeasures in these cases. +> If the User depletes his recovery codes without disabling Two-Factor Authentication, or Recovery Codes are deactivated, **he may be locked out forever without his Authenticator app**. Ensure you have countermeasures in these cases. ### Logging in -This package hooks into the `Attempting` and `Validated` events to check the User's Two Factor Authentication configuration preemptively. +To login, the user must issue a TOTP code along their credentials. Simply use `attemptWhen()` with Laraguard, which will automatically do the checks for you. By default, it checks for the `2fa_code` input name, but you can issue your own as parameter. -1. If the User has set up Two Factor Authentication, it will be prompted for a 2FA Code, otherwise authentication will proceed as normal. -2. If the Login attempt contains a `2fa_code` with the 2FA Code inside the Request, it will be used to check if its valid and proceed as normal. +```php +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use DarkGhostHunter\Laraguard\Laraguard; -This is done transparently without intervening your application with guards, routes, controllers or middleware. +public function login(Request $request) +{ + // ... + + $credentials = $request->only('email', 'password'); + + if (Auth::attemptWhen($credentials, Laraguard::hasCode(), $request->filled('remember'))) { + return redirect()->home(); + } + + return back()->withErrors(['email' => 'Bad credentials']) +} +``` + +Behind the scenes, once the User is retrieved and validated from your guard of choice, it makes an additional check for a valid TOTP code. If it's invalid, it will return false and no authentication will happen. + +#### Separating the TOTP requirement + +In some occasions you will want to tell the user the authentication failed because of an invalid TOTP code, instead of just denying the login altogether. -Additionally, **ensure you [protect your login route](#protecting-the-login)**. +You can use the `hasCodeOrFails()` method that does the same, but throws a validation exception, which is handled gracefully by the framework. It even accepts a custom message in case of failure, otherwise a default translation will be used. -> If you're using a custom Authentication Guard that doesn't fire events, this package won't work, like the `TokenGuard` and the `RequestGuard`. +```php +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use DarkGhostHunter\Laraguard\Laraguard; + +public function login(Request $request) +{ + // ... + + $attempt = Auth::attemptWhen( + $request->only('email', 'password'), + Laraguard::hasCodeOrFails(), + $request->filled('remember') + ); + + if ($attempt) { + return redirect()->home(); + } + + return back()->withErrors(['email', 'Authentication failed!']); +} +``` ### Deactivation -You can deactivate Two Factor Authentication for a given User using the `disableTwoFactorAuth()` method. This will automatically invalidate the authentication data, allowing the User to log in with just his credentials. +You can deactivate Two-Factor Authentication for a given User using the `disableTwoFactorAuth()` method. This will automatically invalidate the authentication data, allowing the User to log in with just his credentials. ```php public function disableTwoFactorAuth(Request $request) { $request->user()->disableTwoFactorAuth(); - return 'Two Factor Authentication has been disabled!'; + return 'Two-Factor Authentication has been disabled!'; } ``` @@ -196,22 +208,22 @@ public function disableTwoFactorAuth(Request $request) The following events are fired in addition to the default Authentication events. -* `TwoFactorEnabled`: An User has enabled Two Factor Authentication. +* `TwoFactorEnabled`: An User has enabled Two-Factor Authentication. * `TwoFactorRecoveryCodesDepleted`: An User has used his last Recovery Code. * `TwoFactorRecoveryCodesGenerated`: An User has generated a new set of Recovery Codes. -* `TwoFactorDisabled`: An User has disabled Two Factor Authentication. +* `TwoFactorDisabled`: An User has disabled Two-Factor Authentication. -> You can use `TwoFactorRecoveryCodesDepleted` to tell the User to create more Recovery Codes. +> You can use `TwoFactorRecoveryCodesDepleted` to tell the User to create more Recovery Codes or mail them some more. ## Middleware Laraguard comes with two middleware for your routes: `2fa.require` and `2fa.confirm`. -> To avoid unexpected results, these middleware only act on your users models with `TwoFactorAuthenticatable`. If a user model doesn't implements it, the middleware bypass any 2FA logic. +> To avoid unexpected results, middleware only act on your users models with `TwoFactorAuthenticatable`. If a user model doesn't implement it, the middleware bypass any 2FA logic. ### Require 2FA -If you need to ensure the User has Two Factor Authentication enabled before entering a given route, you can use the `2fa.require` middleware. +If you need to ensure the User has Two-Factor Authentication enabled before entering a given route, you can use the `2fa.require` middleware. This middleware doesn't asks for codes, only checks if is enabled. ```php Route::get('system/settings') @@ -219,28 +231,29 @@ Route::get('system/settings') ->middleware('2fa.require'); ``` -This middleware works much like the `verified` middleware: if the User has not enabled Two Factor Authentication, it will be redirected to a route name containing the warning, which is `2fa.notice` by default. +This middleware works much like Laravel's `verified` middleware: if the User has not enabled Two-Factor Authentication, it will be redirected to a route name containing the warning, which is `2fa.notice` by default. You can implement the view easily with the one included in this package: ```php +use Illuminate\Support\Facades\Route; + Route::view('2fa-required', 'laraguard::notice')->name('2fa.notice'); ``` -Alternatively, you can use a custom controller action to also include a link to where he can enable Two Factor Authentication. +Alternatively, you can just redirect the user to where he can enable the configuration. ```php -public function notice() -{ - return view('laraguard::notice', [ - 'url' => url('account/settings') - ]); -} +use Illuminate\Support\Facades\Route + +Route::get('system/settings') + ->uses('SystemSettingsController@show') + ->middleware('2fa.require:account.settings.2fa'); ``` ### Confirm 2FA -Much like the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), you can also ask the user to confirm an action using `2fa.confirm`. +Much like the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), you can also ask the user to confirm an action using `2fa.confirm`, if it has Two-Factor Authentication enabled. ```php Route::get('api/token') @@ -248,6 +261,14 @@ Route::get('api/token') ->middleware('2fa.confirm'); ``` +Since a user without 2FA enabled won't be asked for a code, you can mix with middleware with `2fa.require` to enforce it. + +```php +Route::get('api/token') + ->uses('ApiTokenController@show') + ->middleware('2fa.require', '2fa.confirm'); +``` + Laraguard automatically uses the [`Confirm2FACodeController`](src/Http/Controllers/Confirm2FACodeController.php) to handle the form view and the code confirmation for you. Alternatively, [you can point your own controller actions](#confirmation-middleware) to handle the form view and confirmation. Better yet, you can start with the [`Confirms2FACode`](src/Http/Controllers/Confirms2FACode.php) trait to avoid reinventing the wheel. @@ -267,7 +288,7 @@ public function checkTotp(Request $request) } ``` -This rule will succeed if the user is authenticated, is has Two Factor Authentication enabled, and the code is correct. +This rule will succeed if the user is authenticated, it has Two-Factor Authentication enabled, and the code is correct. ## Translations @@ -288,47 +309,17 @@ To add your own in your language, publish the translation files. These will be l php artisan vendor:publish --provider="DarkGhostHunter\Laraguard\LaraguardServiceProvider" --tag="translations" -## Protecting the Login - -Two Factor Authentication can be victim of brute-force attacks. The attacker will need between 16.000~34.000 requests each second to get the correct code, or less depending on the lifetime of the code. - -Since the listener throws a response before the default Login throttler increments its failed tries, its recommended to use a try-catch in the `attemptLogin()` method to keep the throttler working. - -```php -/** - * Attempt to log the user into the application. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ -protected function attemptLogin(Request $request) -{ - try { - return $this->guard()->attempt( - $this->credentials($request), $request->filled('remember') - ); - } catch (HttpResponseException $exception) { - $this->incrementLoginAttempts($request); - throw $exception; - } -} -``` - -To show the form, the Listener uses `HttpResponseException` to forcefully exit the authentication logic. This exception catch allows to throw the response after the login attempts are incremented. - ## Configuration To further configure the package, publish the configuration files and assets: php artisan vendor:publish --provider="DarkGhostHunter\Laraguard\LaraguardServiceProvider" -You will receive the authentication view in `resources/views/vendor/laraguard/auth.blade.php`, and the `config/laraguard.php` config file with the following contents: +You will receive the `config/laraguard.php` config file with the following contents: ```php return [ - 'listener' => \DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth::class, 'model' => \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication::class, - 'input' => '2fa_code', 'cache' => [ 'store' => null, 'prefix' => '2fa.code' @@ -363,20 +354,6 @@ return [ ]; ``` -### Listener - -```php -return [ - 'listener' => \DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth::class, -]; -``` - -This package works out-of-the-box by hooking up the `ForcesTwoFactorAuth` listener to the `Attempting` and `Validated` events, which is in charge of checking if the user login needs a 2FA code or not. - -This may work wonders, but if you want tighter control on how and when prompt for Two Factor Authentication, you can use another listener, or disable it. For example, to create your own 2FA Guard or greatly modify the Login Controller. - -> If you change it for your own Listener, ensure it implements the `TwoFactorAuthListener` contract. - ### Eloquent Model ```php @@ -385,24 +362,12 @@ return [ ]; ``` -This is the model where the data for Two Factor Authentication is saved, like the shared secret and recovery codes, and associated to the User model. +This is the model where the data for Two-Factor Authentication is saved, like the shared secret and recovery codes, and associated to the User model. You can change this model for your own if you wish. > If you change it for your own Model, ensure it implements the `TwoFactorTotp` contract. -### Input name - -```php -return [ - 'input' => '2fa_code', -]; -``` - -By default, the input name that must contain the Two Factor Authentication Code is called `2fa_code`, which is a good default value to avoid collisions with other inputs names. - -This allows to seamlessly intercept the log in attempt and proceed with Two Factor Authentication or bypass it. Change it if it collides with other login form inputs. - ### Cache Store ```php @@ -430,7 +395,7 @@ return [ ]; ``` -You can disable the generation and checking of Recovery Codes. If you do, ensure Users can authenticate by other means, like sending an email with a link to a signed URL that logs him in and disables Two Factor Authentication, or SMS. +You can disable the generation and checking of Recovery Codes. If you do, ensure Users can authenticate by other means, like sending an email with a link to a signed URL that logs him in and disables Two-Factor Authentication, or SMS. The number and length of codes generated is configurable. 10 Codes of 8 random characters are enough for most authentication scenarios. @@ -446,13 +411,13 @@ return [ ]; ``` -Enabling this option will allow the application to "remember" a device using a cookie, allowing it to bypass Two Factor Authentication once a code is verified in that device. When the User logs in again in that device, it won't be prompted for a 2FA Code again. +Enabling this option will allow the application to "remember" a device using a cookie, allowing it to bypass Two-Factor Authentication once a code is verified in that device. When the User logs in again in that device, it won't be prompted for a 2FA Code again. There is a limit of devices that can be saved. New devices will displace the oldest devices registered. Devices are considered no longer "safe" until a set amount of days. -You can change the maximum number of devices saved and the amount of days of validity once they're registered. More devices and more expiration days will make the Two Factor Authentication less secure. +You can change the maximum number of devices saved and the amount of days of validity once they're registered. More devices and more expiration days will make the Two-Factor Authentication less secure. -> When re-enabling Two Factor Authentication, the list of devices is automatically invalidated. +> When re-enabling Two-Factor Authentication, the list of devices is automatically invalidated. ### Confirmation Middleware @@ -527,21 +492,7 @@ return [ This controls the size and margin used to create the QR Code, which are created as SVG. -### Custom view - - resources/views/vendor/laraguard/auth.blade.php - -You can override the view, which handles the Two Factor Code verification for the User. It receives this data: - -* `$action`: The full URL where the form should send the login credentials. -* `$credentials`: An `array` containing the User credentials used for the login. -* `$user`: The User instance trying to authenticate. -* `$error`: If the Two Factor Code is invalid. -* `$remember`: If the "remember" checkbox has been filled. - -The way it works is very simple: it will hold the User credentials in a hidden input while it asks for the Two Factor Code. The User will send everything again along with the Code, the application will ensure its correct, and complete the log in. - -This view and its form is bypassed if the User doesn't uses Two Factor Authentication, making the login transparent and non-invasive. +## [Upgrading from 3.0](UPGRADE.md) ## Security diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..218b263 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,18 @@ +# Upgrading + +## Upgrade from 3.0 + +If you're upgrading from Laraguard 3.0, you will need to migrate. + +Laraguard 4.0 encrypts the Shared Secret and Recovery Codes. This adds an extra layer of protection in case the database records are leaked to the wild, as recommended by the [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238). + +To upgrade, ensure you have installed `doctrine/dbal` so the migration can run, as it needs to change a column type. + + composer require doctrine/dbal + +Then, publish the upgrading migration and run it: + + php artisan vendor:publish --provider="DarkGhostHunter\Laraguard\LaraguardServiceProvider" --tag="upgrade" + php artisan migrate + +The migration will automatically encrypt all shared secrets, while also reverting the decryption on rolling back migrations. diff --git a/composer.json b/composer.json index c3b1d41..ab55e77 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,16 @@ } ], "require": { - "php": "^7.4||^8.0", + "php": "^8.0", "ext-json": "*", "bacon/bacon-qr-code": "^2.0", "paragonie/constant_time_encoding": "^2.4", - "illuminate/support": "^8.0", - "illuminate/http": "^8.20", - "illuminate/auth": "^8.0" + "illuminate/config": "^8.39", + "illuminate/validation": "^8.39", + "illuminate/database": "^8.39", + "illuminate/support": "^8.39", + "illuminate/http": "^8.39", + "illuminate/auth": "^8.39" }, "require-dev": { "orchestra/testbench": "^6.0", diff --git a/config/laraguard.php b/config/laraguard.php index 6cb632e..1c63fe2 100644 --- a/config/laraguard.php +++ b/config/laraguard.php @@ -1,20 +1,6 @@ \DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth::class, - /* |-------------------------------------------------------------------------- | TwoFactorAuthentication Model @@ -28,19 +14,6 @@ 'model' => \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication::class, - /* - |-------------------------------------------------------------------------- - | Input name - |-------------------------------------------------------------------------- - | - | When using the Listener, it will automatically check the Request for the - | input name containing the Two Factor Code. A safe default is set here, - | but you can override the value if it collides with other form input. - | - */ - - 'input' => '2fa_code', - /* |-------------------------------------------------------------------------- | Cache Store @@ -86,6 +59,7 @@ */ 'safe_devices' => [ + 'cookie' => '2fa_remember', 'enabled' => false, 'max_devices' => 3, 'expiration_days' => 14, diff --git a/database/factories/TwoFactorAuthenticationFactory.php b/database/factories/TwoFactorAuthenticationFactory.php index d2a735b..ee4079a 100644 --- a/database/factories/TwoFactorAuthenticationFactory.php +++ b/database/factories/TwoFactorAuthenticationFactory.php @@ -2,12 +2,12 @@ namespace Database\Factories\DarkGhostHunter\Laraguard\Eloquent; -use Faker\Generator as Faker; +use DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Collection; -use DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication; -class TwoFactorAuthenticationFactory extends Factory { +class TwoFactorAuthenticationFactory extends Factory +{ /** * The name of the factory's corresponding model. * @@ -20,7 +20,7 @@ class TwoFactorAuthenticationFactory extends Factory { * * @return array */ - public function definition() + public function definition(): array { $config = config('laraguard'); @@ -43,60 +43,54 @@ public function definition() /** * Returns a model with unused recovery codes. * - * @return TwoFactorAuthenticationFactory + * @return \Database\Factories\DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthenticationFactory */ - public function withRecovery() + public function withRecovery(): static { - return $this->state(function(array $attributes) { - [$enabled, $amount, $length] = array_values(config('laraguard.recovery')); + [$enabled, $amount, $length] = array_values(config('laraguard.recovery')); - return [ - 'recovery_codes' => TwoFactorAuthentication::generateRecoveryCodes($amount, $length), - 'recovery_codes_generated_at' => $this->faker->dateTimeBetween('-1 years'), - ]; - }); + return $this->state([ + 'recovery_codes' => TwoFactorAuthentication::generateRecoveryCodes($amount, $length), + 'recovery_codes_generated_at' => $this->faker->dateTimeBetween('-1 years'), + ]); } /** * Returns an authentication with a list of safe devices. * - * @return TwoFactorAuthenticationFactory + * @return \Database\Factories\DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthenticationFactory */ - public function withSafeDevices() + public function withSafeDevices(): static { - return $this->state(function (array $attributes) { - $max = config('laraguard.safe_devices.max_devices'); - - return [ - 'safe_devices' => Collection::times($max, function ($step) use ($max) { - - $expiration_days = config('laraguard.safe_devices.expiration_days'); - - $added_at = $max !== $step - ? now() - : $this->faker->dateTimeBetween(now()->subDays($expiration_days * 2), now()->subDays($expiration_days)); - - return [ - '2fa_remember' => TwoFactorAuthentication::generateDefaultTwoFactorRemember(), - 'ip' => $this->faker->ipv4, - 'added_at' => $added_at, - ]; - }), - ]; - }); + $max = config('laraguard.safe_devices.max_devices'); + + return $this->state([ + 'safe_devices' => Collection::times($max, function ($step) use ($max) { + $expiration_days = config('laraguard.safe_devices.expiration_days'); + + $added_at = $max !== $step + ? now() + : $this->faker->dateTimeBetween(now()->subDays($expiration_days * 2), + now()->subDays($expiration_days)); + + return [ + '2fa_remember' => TwoFactorAuthentication::generateDefaultTwoFactorRemember(), + 'ip' => $this->faker->ipv4, + 'added_at' => $added_at, + ]; + }), + ]); } /** * Returns an enabled authentication. * - * @return TwoFactorAuthenticationFactory + * @return \Database\Factories\DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthenticationFactory */ - public function enabled() + public function enabled(): static { - return $this->state(function (array $attributes) { - return [ - 'enabled_at' => null - ]; - }); + return $this->state([ + 'enabled_at' => null, + ]); } } diff --git a/database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php b/database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php index 369187e..49a39fa 100644 --- a/database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php +++ b/database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php @@ -11,19 +11,19 @@ class CreateTwoFactorAuthenticationsTable extends Migration * * @return void */ - public function up() + public function up(): void { Schema::create('two_factor_authentications', function (Blueprint $table) { - $table->bigIncrements('id'); + $table->id(); $table->morphs('authenticatable', '2fa_auth_type_auth_id_index'); - $table->string('shared_secret'); + $table->text('shared_secret'); $table->timestampTz('enabled_at')->nullable(); $table->string('label'); $table->unsignedTinyInteger('digits')->default(6); $table->unsignedTinyInteger('seconds')->default(30); $table->unsignedTinyInteger('window')->default(0); $table->string('algorithm', 16)->default('sha1'); - $table->json('recovery_codes')->nullable(); + $table->text('recovery_codes')->nullable(); $table->timestampTz('recovery_codes_generated_at')->nullable(); $table->json('safe_devices')->nullable(); $table->timestampsTz(); @@ -35,7 +35,7 @@ public function up() * * @return void */ - public function down() + public function down(): void { Schema::dropIfExists('two_factor_authentications'); } diff --git a/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php b/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php new file mode 100644 index 0000000..0c55501 --- /dev/null +++ b/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php @@ -0,0 +1,85 @@ +text('shared_secret')->change(); + $table->text('recovery_codes')->nullable()->change(); + }); + + // We need to encrypt all shared secrets so these can be used with Laraguard v4.0. + $this->chunkRows(true); + } + + /** + * Returns a chunk of authentications to encrypt/decrypt them. + * + * @param bool $encrypt + * + * @return void + */ + protected function chunkRows(bool $encrypt): void + { + $call = $encrypt ? 'encryptString' : 'decryptString'; + $encrypter = Crypt::getFacadeRoot(); + $query = DB::table('two_factor_authentications'); + + $query->clone()->select('id', 'shared_secret', 'recovery_codes') + ->chunk( + 1000, + static function (Collection $chunk) use ($encrypter, $query, $call): void { + DB::beginTransaction(); + foreach ($chunk as $item) { + $query->clone()->where('id', $item)->update([ + 'shared_secret' => $encrypter->$call($item->shared_secret), + 'recovery_codes' => $encrypter->$call($item->recovery_codes), + ]); + } + DB::commit(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + // Before changing the shared secret column, we will need to decrypt the shared secret. + $this->chunkRows(false); + + Schema::table('two_factor_authentications', function (Blueprint $table) { + $table->string('shared_secret')->change(); + $table->json('recovery_codes')->nullable()->change(); + }); + } +} diff --git a/resources/views/auth.blade.php b/resources/views/auth.blade.php deleted file mode 100644 index 4ffe140..0000000 --- a/resources/views/auth.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -@extends('laraguard::layout') - -@section('card-body') -
- @csrf - @foreach((array)$credentials as $name => $value) - - @endforeach - @if($remember) - - @endif -

- {{ trans('laraguard::messages.continue') }} -

-
-
- - @if($error) -
- {{ trans('laraguard::validation.totp_code') }} -
- @endif -
-
-
-
- - -
-
-
-
- -
-
-
-@endsection diff --git a/src/Contracts/TwoFactorAuthenticatable.php b/src/Contracts/TwoFactorAuthenticatable.php index 1ebcc89..fbbc168 100644 --- a/src/Contracts/TwoFactorAuthenticatable.php +++ b/src/Contracts/TwoFactorAuthenticatable.php @@ -2,41 +2,42 @@ namespace DarkGhostHunter\Laraguard\Contracts; +use DateTimeInterface; use Illuminate\Http\Request; use Illuminate\Support\Collection; interface TwoFactorAuthenticatable { /** - * Determines if the User has Two Factor Authentication enabled or not. + * Determines if the User has Two-Factor Authentication enabled or not. * * @return bool */ public function hasTwoFactorEnabled() : bool; /** - * Enables Two Factor Authentication for the given user. + * Enables Two-Factor Authentication for the given user. * * @return void */ public function enableTwoFactorAuth() : void; /** - * Disables Two Factor Authentication for the given user. + * Disables Two-Factor Authentication for the given user. * * @return void */ public function disableTwoFactorAuth() : void; /** - * Recreates the Two Factor Authentication from the ground up, and returns a new Shared Secret. + * Recreates the Two-Factor Authentication from the ground up, and returns a new Shared Secret. * * @return \DarkGhostHunter\Laraguard\Contracts\TwoFactorTotp */ public function createTwoFactorAuth() : TwoFactorTotp; /** - * Confirms the Shared Secret and fully enables the Two Factor Authentication. + * Confirms the Shared Secret and fully enables the Two-Factor Authentication. * * @param string $code * @return bool @@ -46,19 +47,19 @@ public function confirmTwoFactorAuth(string $code) : bool; /** * Validates the TOTP Code or Recovery Code. * - * @param string $code + * @param string|null $code * @return bool */ public function validateTwoFactorCode(?string $code = null) : bool; /** - * Makes a Two Factor Code for a given time, and period offset. + * Makes a Two-Factor Code for a given time, and period offset. * - * @param int|string|\Illuminate\Support\Carbon|\Datetime $at + * @param \DateTimeInterface|int|string $at * @param int $offset * @return string */ - public function makeTwoFactorCode($at = 'now', int $offset = 0) : string; + public function makeTwoFactorCode(DateTimeInterface|int|string $at = 'now', int $offset = 0) : string; /** * Return the current set of Recovery Codes. @@ -75,7 +76,7 @@ public function getRecoveryCodes() : Collection; public function generateRecoveryCodes() : Collection; /** - * Return all the Safe Devices that bypass Two Factor Authentication. + * Return all the Safe Devices that bypass Two-Factor Authentication. * * @return \Illuminate\Support\Collection */ diff --git a/src/Contracts/TwoFactorListener.php b/src/Contracts/TwoFactorListener.php deleted file mode 100644 index dc87cd7..0000000 --- a/src/Contracts/TwoFactorListener.php +++ /dev/null @@ -1,25 +0,0 @@ - $store, 'prefix' => $this->prefix] = config('laraguard.cache'); @@ -37,11 +42,12 @@ protected function initializeHandlesCodes() /** * Returns the Cache Store to use. * - * @param string $store + * @param string|null $store + * * @return \Illuminate\Contracts\Cache\Repository * @throws \Exception */ - protected function useCacheStore(string $store = null) + protected function useCacheStore(string $store = null): Repository { return cache()->store($store); } @@ -50,11 +56,12 @@ protected function useCacheStore(string $store = null) * Validates a given code, optionally for a given timestamp and future window. * * @param string $code - * @param int|string|\Illuminate\Support\Carbon|\Datetime $at - * @param int $window + * @param string $at + * @param int|null $window + * * @return bool */ - public function validateCode(string $code, $at = 'now', int $window = null) : bool + public function validateCode(string $code, $at = 'now', int $window = null): bool { if ($this->codeHasBeenUsed($code)) { return false; @@ -77,9 +84,10 @@ public function validateCode(string $code, $at = 'now', int $window = null) : bo * * @param int|string|\Illuminate\Support\Carbon|\Datetime $at * @param int $offset + * * @return string */ - public function makeCode($at = 'now', int $offset = 0) : string + public function makeCode($at = 'now', int $offset = 0): string { return $this->generateCode( $this->getTimestampFromPeriod($at, $offset) @@ -90,9 +98,10 @@ public function makeCode($at = 'now', int $offset = 0) : string * Generates a valid Code for a given timestamp. * * @param int $timestamp + * * @return string */ - protected function generateCode(int $timestamp) + protected function generateCode(int $timestamp): string { $hmac = hash_hmac( $this->algorithm, @@ -110,29 +119,31 @@ protected function generateCode(int $timestamp) (ord($hmac[$offset + 3]) & 0xFF) ) % (10 ** $this->digits); - return str_pad((string)$number, $this->digits, '0', STR_PAD_LEFT); + return str_pad((string) $number, $this->digits, '0', STR_PAD_LEFT); } /** * Return the periods elapsed from the given Timestamp and seconds. * * @param int $timestamp + * * @return int */ - protected function getPeriodsFromTimestamp(int $timestamp) + protected function getPeriodsFromTimestamp(int $timestamp): int { - return (int)(floor($timestamp / $this->seconds)); + return (int) (floor($timestamp / $this->seconds)); } /** * Creates a 64-bit raw binary string from a timestamp. * * @param int $timestamp + * * @return string */ - protected function timestampToBinary(int $timestamp) + protected function timestampToBinary(int $timestamp): string { - return pack('N*', 0) . pack('N*', $timestamp); + return pack('N*', 0).pack('N*', $timestamp); } /** @@ -140,74 +151,79 @@ protected function timestampToBinary(int $timestamp) * * @return string */ - protected function getBinarySecret() + protected function getBinarySecret(): string { - return Base32::decodeUpper($this->attributes['shared_secret']); + return Base32::decodeUpper($this->shared_secret); } /** * Get the timestamp from a given elapsed "periods" of seconds. * - * @param int|string|\Datetime|\Illuminate\Support\Carbon $at + * @param \DatetimeInterface|int|string $at * @param int $period + * * @return int */ - protected function getTimestampFromPeriod($at, int $period = 0) + protected function getTimestampFromPeriod(DatetimeInterface|int|string $at = 'now', int $period = 0): int { $periods = ($this->parseTimestamp($at) / $this->seconds) + $period; - return (int)$periods * $this->seconds; + return (int) $periods * $this->seconds; } /** * Normalizes the Timestamp from a string, integer or object. * - * @param int|string|\Datetime|\Illuminate\Support\Carbon $at + * @param \DateTimeInterface|int|string $at + * * @return int */ - protected function parseTimestamp($at) : int + protected function parseTimestamp(DatetimeInterface|int|string $at = 'now'): int { - if ($at instanceof DateTime) { - return $at->getTimestamp(); + if (is_string($at)) { + $at = date_create($at); } - if (is_string($at)) { - return Carbon::parse($at)->getTimestamp(); + if (is_int($at)) { + $at = now()->addSeconds($at); } - return $at; + return $at->getTimestamp(); } /** * Returns the cache key string to save the codes into the cache. * * @param string $code + * * @return string */ - protected function cacheKey(string $code) + protected function cacheKey(string $code): string { - return "{$this->prefix}|{$this->getKey()}|$code"; + return implode('|', [$this->prefix, $this->getKey(), $code]); } /** * Checks if the code has been used. * * @param string $code + * * @return bool */ - protected function codeHasBeenUsed(string $code) + protected function codeHasBeenUsed(string $code): bool { return $this->cache->has($this->cacheKey($code)); } /** - * Sets the Code has used so it can't be used again. + * Sets the Code has used, so it can't be used again. * * @param string $code - * @param int|string|\Datetime|\Illuminate\Support\Carbon $at + * @param \DateTimeInterface|int|string $at + * * @return bool */ - protected function setCodeHasUsed(string $code, $at) + protected function setCodeHasUsed(string $code, DateTimeInterface|int|string $at = 'now'): bool { // We will safely set the cache key for the whole lifetime plus window just to be safe. return $this->cache->set($this->cacheKey($code), true, diff --git a/src/Eloquent/HandlesRecoveryCodes.php b/src/Eloquent/HandlesRecoveryCodes.php index 811433a..6799c5e 100644 --- a/src/Eloquent/HandlesRecoveryCodes.php +++ b/src/Eloquent/HandlesRecoveryCodes.php @@ -12,9 +12,9 @@ trait HandlesRecoveryCodes * * @return bool */ - public function containsUnusedRecoveryCodes() + public function containsUnusedRecoveryCodes(): bool { - return $this->recovery_codes && $this->recovery_codes->contains('used_at', null); + return (bool) $this->recovery_codes?->contains('used_at'); } /** @@ -23,14 +23,12 @@ public function containsUnusedRecoveryCodes() * @param string $code * @return int|null */ - protected function getUnusedRecoveryCodeIndex(string $code) + protected function getUnusedRecoveryCodeIndex(string $code): ?int { - $key = optional($this->recovery_codes)->search([ + return $this->recovery_codes?->search([ 'code' => $code, 'used_at' => null, ]); - - return $key !== false ? $key : null; } /** @@ -39,13 +37,13 @@ protected function getUnusedRecoveryCodeIndex(string $code) * @param string $code * @return bool */ - public function setRecoveryCodeAsUsed(string $code) + public function setRecoveryCodeAsUsed(string $code): bool { if (null === $index = $this->getUnusedRecoveryCodeIndex($code)) { return false; } - $this->recovery_codes = $this->recovery_codes->put($index, [ + $this->recovery_codes->put($index, [ 'code' => $code, 'used_at' => now(), ]); @@ -60,9 +58,9 @@ public function setRecoveryCodeAsUsed(string $code) * @param int $length * @return \Illuminate\Support\Collection */ - public static function generateRecoveryCodes(int $amount, int $length) + public static function generateRecoveryCodes(int $amount, int $length): Collection { - return Collection::times($amount, function () use ($length) { + return Collection::times($amount, static function () use ($length): array { return [ 'code' => strtoupper(Str::random($length)), 'used_at' => null, diff --git a/src/Eloquent/HandlesSafeDevices.php b/src/Eloquent/HandlesSafeDevices.php index c7943ac..396b2ca 100644 --- a/src/Eloquent/HandlesSafeDevices.php +++ b/src/Eloquent/HandlesSafeDevices.php @@ -13,9 +13,9 @@ trait HandlesSafeDevices * @param null|string $token * @return null|\Illuminate\Support\Carbon */ - public function getSafeDeviceTimestamp(string $token = null) + public function getSafeDeviceTimestamp(string $token = null): ?Carbon { - if ($token && $device = collect($this->safe_devices)->firstWhere('2fa_remember', $token)) { + if ($token && $device = $this->safe_devices?->firstWhere('2fa_remember', $token)) { return Carbon::createFromTimestamp($device['added_at']); } @@ -23,11 +23,11 @@ public function getSafeDeviceTimestamp(string $token = null) } /** - * Generates a Device token to bypass Two Factor Authentication. + * Generates a Device token to bypass Two-Factor Authentication. * * @return string */ - public static function generateDefaultTwoFactorRemember() + public static function generateDefaultTwoFactorRemember(): string { return Str::random(100); } diff --git a/src/Eloquent/SerializesSharedSecret.php b/src/Eloquent/SerializesSharedSecret.php index df1fdc5..4d074de 100644 --- a/src/Eloquent/SerializesSharedSecret.php +++ b/src/Eloquent/SerializesSharedSecret.php @@ -6,18 +6,27 @@ use BaconQrCode\Renderer\ImageRenderer; use BaconQrCode\Renderer\Image\SvgImageBackEnd; use BaconQrCode\Renderer\RendererStyle\RendererStyle; +use function config; +use function http_build_query; +use function strtoupper; +use function rawurlencode; +use function array_values; +use function trim; +use function chunk_split; trait SerializesSharedSecret { /** - * Returns the Shared Secret as an URI. + * Returns the Shared Secret as a URI. * * @return string */ public function toUri() : string { + $issuer = config('laraguard.issuer', config('app.name')); + $query = http_build_query([ - 'issuer' => $issuer = config('laraguard.issuer') ?? config('app.name'), + 'issuer' => $issuer, 'label' => $this->attributes['label'], 'secret' => $this->shared_secret, 'algorithm' => strtoupper($this->attributes['algorithm']), @@ -46,7 +55,7 @@ public function toQr() : string * * @return string */ - public function __toString() + public function __toString(): string { return $this->toString(); } @@ -74,7 +83,7 @@ public function toGroupedString() : string /** * @inheritDoc */ - public function render() + public function render(): string { return $this->toQr(); } @@ -82,15 +91,15 @@ public function render() /** * @inheritDoc */ - public function toJson($options = 0) + public function toJson($options = 0): string { - return json_encode($this->toUri(), $options); + return json_encode($this->toUri(), JSON_THROW_ON_ERROR | $options); } /** * @inheritDoc */ - public function jsonSerialize() + public function jsonSerialize(): mixed { return $this->toUri(); } diff --git a/src/Eloquent/TwoFactorAuthentication.php b/src/Eloquent/TwoFactorAuthentication.php index 71ff8f6..6277e44 100644 --- a/src/Eloquent/TwoFactorAuthentication.php +++ b/src/Eloquent/TwoFactorAuthentication.php @@ -2,10 +2,11 @@ namespace DarkGhostHunter\Laraguard\Eloquent; +use DarkGhostHunter\Laraguard\Contracts\TwoFactorTotp; use Illuminate\Database\Eloquent\Factories\HasFactory; -use ParagonIE\ConstantTime\Base32; use Illuminate\Database\Eloquent\Model; -use DarkGhostHunter\Laraguard\Contracts\TwoFactorTotp; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use ParagonIE\ConstantTime\Base32; /** * @mixin \Illuminate\Database\Eloquent\Builder @@ -43,63 +44,57 @@ class TwoFactorAuthentication extends Model implements TwoFactorTotp * @var array */ protected $casts = [ - 'authenticatable_id' => 'int', - 'digits' => 'int', - 'seconds' => 'int', - 'window' => 'int', - 'recovery_codes' => 'collection', - 'safe_devices' => 'collection', - ]; - - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = [ - 'enabled_at', - 'recovery_codes_generated_at', + 'shared_secret' => 'encrypted', + 'authenticatable_id' => 'int', + 'digits' => 'int', + 'seconds' => 'int', + 'window' => 'int', + 'recovery_codes' => 'encrypted:collection', + 'safe_devices' => 'collection', + 'enabled_at' => 'datetime', + 'recovery_codes_generated_at' => 'datetime', ]; /** - * The model that uses Two Factor Authentication. + * The model that uses Two-Factor Authentication. * * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ - public function authenticatable() + public function authenticatable(): MorphTo { return $this->morphTo('authenticatable'); } - /** * Sets the Algorithm to lowercase. * * @param $value + * + * @return void */ - protected function setAlgorithmAttribute($value) + protected function setAlgorithmAttribute($value): void { $this->attributes['algorithm'] = strtolower($value); } /** - * Returns if the Two Factor Authentication has been enabled. + * Returns if the Two-Factor Authentication has been enabled. * * @return bool */ - public function isEnabled() + public function isEnabled(): bool { return $this->enabled_at !== null; } /** - * Returns if the Two Factor Authentication is not been enabled. + * Returns if the Two-Factor Authentication is not been enabled. * * @return bool */ - public function isDisabled() + public function isDisabled(): bool { - return ! $this->isEnabled(); + return !$this->isEnabled(); } /** @@ -107,16 +102,16 @@ public function isDisabled() * * @return $this */ - public function flushAuth() + public function flushAuth(): static { - $this->attributes['recovery_codes'] = null; - $this->attributes['recovery_codes_generated_at'] = null; - $this->attributes['safe_devices'] = null; - $this->attributes['enabled_at'] = null; + $this->recovery_codes_generated_at = null; + $this->safe_devices = null; + $this->enabled_at = null; $this->attributes = array_merge($this->attributes, config('laraguard.totp')); - $this->attributes['shared_secret'] = static::generateRandomSecret(); + $this->shared_secret = static::generateRandomSecret(); + $this->recovery_codes = null; return $this; } @@ -126,7 +121,7 @@ public function flushAuth() * * @return string */ - public static function generateRandomSecret() + public static function generateRandomSecret(): string { return Base32::encodeUpper( random_bytes(config('laraguard.secret_length')) diff --git a/src/Exceptions/InvalidCodeException.php b/src/Exceptions/InvalidCodeException.php new file mode 100644 index 0000000..a838139 --- /dev/null +++ b/src/Exceptions/InvalidCodeException.php @@ -0,0 +1,45 @@ +withMessage(trans('laraguard:validation.totp_code')); + } + + /** + * Sets a custom validation message. + * + * @param string $message + * + * @return $this + */ + public function withMessage(string $message): static + { + $this->validator->errors()->add(config('laraguard.input', '2fa_code'), $message); + + return $this; + } +} diff --git a/src/Http/Controllers/Confirms2FACode.php b/src/Http/Controllers/Confirms2FACode.php index 82ee9f3..ad30264 100644 --- a/src/Http/Controllers/Confirms2FACode.php +++ b/src/Http/Controllers/Confirms2FACode.php @@ -2,16 +2,20 @@ namespace DarkGhostHunter\Laraguard\Http\Controllers; +use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; trait Confirms2FACode { /** * Display the TOTP code confirmation view. * - * @return \Illuminate\View\View + * @return \Illuminate\Contracts\View\View */ - public function showConfirmForm() + public function showConfirmForm(): View { return view('laraguard::confirm'); } @@ -22,7 +26,7 @@ public function showConfirmForm() * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse|\Illuminate\Http\Response */ - public function confirm(Request $request) + public function confirm(Request $request): JsonResponse|Response|RedirectResponse { $request->validate($this->rules(), $this->validationErrorMessages()); @@ -39,7 +43,7 @@ public function confirm(Request $request) * @param \Illuminate\Http\Request $request * @return void */ - protected function resetTotpConfirmationTimeout(Request $request) + protected function resetTotpConfirmationTimeout(Request $request): void { $request->session()->put('2fa.totp_confirmed_at', now()->timestamp); } @@ -49,7 +53,7 @@ protected function resetTotpConfirmationTimeout(Request $request) * * @return array */ - protected function rules() + protected function rules(): array { return [ config('laraguard.input') => 'required|totp_code', @@ -61,7 +65,7 @@ protected function rules() * * @return array */ - protected function validationErrorMessages() + protected function validationErrorMessages(): array { return []; } @@ -72,7 +76,7 @@ protected function validationErrorMessages() * @return string * @see \Illuminate\Foundation\Auth\RedirectsUsers */ - public function redirectPath() + public function redirectPath(): string { if (method_exists($this, 'redirectTo')) { return $this->redirectTo(); @@ -80,4 +84,4 @@ public function redirectPath() return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; } -} \ No newline at end of file +} diff --git a/src/Http/Middleware/ConfirmTwoFactorCode.php b/src/Http/Middleware/ConfirmTwoFactorCode.php index a3d7182..c7b702a 100644 --- a/src/Http/Middleware/ConfirmTwoFactorCode.php +++ b/src/Http/Middleware/ConfirmTwoFactorCode.php @@ -7,42 +7,20 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Routing\ResponseFactory; use DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable; +use Illuminate\Http\Request; class ConfirmTwoFactorCode { - /** - * The response factory instance. - * - * @var \Illuminate\Contracts\Routing\ResponseFactory - */ - protected $response; - - /** - * The URL generator instance. - * - * @var \Illuminate\Contracts\Routing\UrlGenerator - */ - protected $url; - - /** - * Current user authenticated. - * - * @var \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable - */ - protected $user; - /** * Create a new middleware instance. * * @param \Illuminate\Contracts\Routing\ResponseFactory $response * @param \Illuminate\Contracts\Routing\UrlGenerator $url - * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param \Illuminate\Contracts\Auth\Authenticatable|null $user */ - public function __construct(ResponseFactory $response, UrlGenerator $url, Authenticatable $user = null) + public function __construct(protected ResponseFactory $response, protected UrlGenerator $url, protected ?Authenticatable $user = null) { - $this->response = $response; - $this->url = $url; - $this->user = $user; + // } /** @@ -53,9 +31,9 @@ public function __construct(ResponseFactory $response, UrlGenerator $url, Authen * @param string $redirectToRoute * @return mixed */ - public function handle($request, Closure $next, $redirectToRoute = '2fa.confirm') + public function handle($request, Closure $next, string $redirectToRoute = '2fa.confirm'): mixed { - if ($this->userHasNotEnabledTwoFactorAuth() || $this->codeWasValidated($request)) { + if ($this->userHasNotEnabledTwoFactorAuth() || $this->codeAlreadyValidated($request)) { return $next($request); } @@ -65,11 +43,11 @@ public function handle($request, Closure $next, $redirectToRoute = '2fa.confirm' } /** - * Check if the user is using Two Factor Authentication. + * Check if the user is using Two-Factor Authentication. * * @return bool */ - protected function userHasNotEnabledTwoFactorAuth() + protected function userHasNotEnabledTwoFactorAuth(): bool { return ! ($this->user instanceof TwoFactorAuthenticatable && $this->user->hasTwoFactorEnabled()); } @@ -80,10 +58,10 @@ protected function userHasNotEnabledTwoFactorAuth() * @param \Illuminate\Http\Request $request * @return bool */ - protected function codeWasValidated($request) + protected function codeAlreadyValidated(Request $request): bool { $confirmedAt = now()->timestamp - $request->session()->get('2fa.totp_confirmed_at', 0); return $confirmedAt < config('laraguard.confirm.timeout', 10800); } -} \ No newline at end of file +} diff --git a/src/Laraguard.php b/src/Laraguard.php new file mode 100644 index 0000000..66ed78b --- /dev/null +++ b/src/Laraguard.php @@ -0,0 +1,133 @@ + $input])->validate($user); + }; + } + + /** + * Check if the user uses TOTP and has a valid code. + * + * @param string $input + * @param string|null $message + * + * @return \Closure + */ + public static function hasCodeOrFails(string $input = '2fa_code', string $message = null): Closure + { + return static function ($user) use ($input, $message): bool { + if (app(__CLASS__, ['input' => $input])->validate($user)) { + return true; + } + + throw ValidationException::withMessages([ + config('laraguard.input') => $message ?? trans('laraguard::validation.totp_code'), + ]); + }; + } + + /** + * Creates a new Laraguard instance. + * + * @param \Illuminate\Contracts\Config\Repository $config + * @param \Illuminate\Http\Request $request + * @param string $input + */ + public function __construct(protected Repository $config, protected Request $request, protected string $input) + { + } + + /** + * Check if the user uses TOTP and has a valid code. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * + * @return bool + */ + public function validate(Authenticatable $user): bool + { + if ($user instanceof TwoFactorAuthenticatable && $user->hasTwoFactorEnabled()) { + // If safe devices are enabled, and this is a safe device, bypass. + if ($this->isSafeDevicesEnabled() && $user->isSafeDevice($this->request)) { + return true; + } + + // If the code is valid, save the device if it's enabled. + if ($this->requestHasCode() && $user->validateTwoFactorCode($this->getCode())) { + if ($this->isSafeDevicesEnabled() && $this->wantsToAddDevice()) { + $user->addSafeDevice($this->request); + } + + return true; + } + } + + return true; + } + + /** + * Checks if the app config has Safe Devices enabled. + * + * @return bool + */ + protected function isSafeDevicesEnabled(): bool + { + return $this->config->get('laraguard.safe_devices.enabled', false); + } + + /** + * Checks if the Request has a Two-Factor Code and is valid. + * + * @return bool + */ + protected function requestHasCode(): bool + { + return !validator($this->request->only($this->input), [ + $this->input => 'required|numeric', + ])->fails(); + } + + /** + * Returns the code from the request input. + * + * @return int + */ + protected function getCode(): int + { + return $this->request->input($this->input); + } + + /** + * Checks if the user wants to add this device as "safe". + * + * @return bool + */ + protected function wantsToAddDevice(): bool + { + return $this->request->filled('safe_device'); + } +} diff --git a/src/LaraguardServiceProvider.php b/src/LaraguardServiceProvider.php index 5585ef8..ee9beb5 100644 --- a/src/LaraguardServiceProvider.php +++ b/src/LaraguardServiceProvider.php @@ -2,13 +2,10 @@ namespace DarkGhostHunter\Laraguard; -use Illuminate\Routing\Router; -use Illuminate\Auth\Events\Validated; -use Illuminate\Auth\Events\Attempting; -use Illuminate\Support\ServiceProvider; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Validation\Factory; +use Illuminate\Routing\Router; +use Illuminate\Support\ServiceProvider; class LaraguardServiceProvider extends ServiceProvider { @@ -18,13 +15,14 @@ class LaraguardServiceProvider extends ServiceProvider * @var string */ protected const MIGRATION_FILE = __DIR__ . '/../database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php'; + protected const UPGRADE_FILE = __DIR__ . '/..database/migrations/0000_00_00_000000_upgrade_two_factor_authentications_table.php'; /** * Register the application services. * * @return void */ - public function register() + public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/laraguard.php', 'laraguard'); } @@ -34,16 +32,14 @@ public function register() * * @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Routing\Router $router - * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher * @param \Illuminate\Contracts\Validation\Factory $validator * @return void */ - public function boot(Repository $config, Router $router, Dispatcher $dispatcher, Factory $validator) + public function boot(Repository $config, Router $router, Factory $validator): void { $this->loadViewsFrom(__DIR__ . '/../resources/views', 'laraguard'); $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'laraguard'); - $this->registerListener($config, $dispatcher); $this->registerMiddleware($router); $this->registerRules($validator); $this->registerRoutes($config, $router); @@ -53,34 +49,13 @@ public function boot(Repository $config, Router $router, Dispatcher $dispatcher, } } - /** - * Register a listeners to tackle authentication. - * - * @param \Illuminate\Contracts\Config\Repository $config - * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @return void - */ - protected function registerListener(Repository $config, Dispatcher $dispatcher) - { - if (! $listener = $config['laraguard.listener']) { - return; - } - - $this->app->singleton(Contracts\TwoFactorListener::class, function ($app) use ($listener) { - return new $listener($app['config'], $app['request']); - }); - - $dispatcher->listen(Attempting::class, Contracts\TwoFactorListener::class . '@saveCredentials'); - $dispatcher->listen(Validated::class, Contracts\TwoFactorListener::class . '@checkTwoFactor'); - } - /** * Register the middleware. * * @param \Illuminate\Routing\Router $router * @return void */ - protected function registerMiddleware(Router $router) + protected function registerMiddleware(Router $router): void { $router->aliasMiddleware('2fa.require', Http\Middleware\RequireTwoFactorEnabled::class); $router->aliasMiddleware('2fa.confirm', Http\Middleware\ConfirmTwoFactorCode::class); @@ -92,7 +67,7 @@ protected function registerMiddleware(Router $router) * @param \Illuminate\Contracts\Validation\Factory $validator * @return void */ - protected function registerRules(Factory $validator) + protected function registerRules(Factory $validator): void { $validator->extendImplicit('totp_code', Rules\TotpCodeRule::class, trans('laraguard::validation.totp_code')); } @@ -104,17 +79,14 @@ protected function registerRules(Factory $validator) * @param \Illuminate\Routing\Router $router * @return void */ - protected function registerRoutes(Repository $config, Router $router) + protected function registerRoutes(Repository $config, Router $router): void { if ($view = $config->get('laraguard.confirm.view')) { - $router->get('2fa/confirm', $view) - ->middleware('web') - ->name('2fa.confirm'); + $router->get('2fa/confirm', $view)->middleware('web')->name('2fa.confirm'); } if ($action = $config->get('laraguard.confirm.action')) { - $router->post('2fa/confirm', $action) - ->middleware('web'); + $router->post('2fa/confirm', $action)->middleware('web'); } } @@ -123,7 +95,7 @@ protected function registerRoutes(Repository $config, Router $router) * * @return void */ - protected function publishFiles() + protected function publishFiles(): void { $this->publishes([ __DIR__ . '/../config/laraguard.php' => config_path('laraguard.php'), @@ -137,16 +109,16 @@ protected function publishFiles() __DIR__ . '/../resources/lang' => resource_path('lang/vendor/laraguard'), ], 'translations'); - // We will allow the publishing for the Two Factor Authentication migration that - // holds the TOTP data, only if it wasn't published before, avoiding multiple - // copies for the same migration, which can throw errors when re-migrating. - if (! class_exists('CreateTwoFactorAuthenticationsTable')) { - $this->publishes([ - self::MIGRATION_FILE => - database_path('migrations/' . - now()->format('Y_m_d_His') . - '_create_two_factor_authentications_table.php'), - ], 'migrations'); - } + $this->publishes([ + self::MIGRATION_FILE => database_path('migrations/' + . now()->format('Y_m_d_His') + . '_create_two_factor_authentications_table.php'), + ], 'migrations'); + + $this->publishes([ + self::UPGRADE_FILE => database_path('migrations/' + . now()->format('Y_m_d_His') + . '_upgrade_two_factor_authentications_table.php'), + ], 'upgrade'); } } diff --git a/src/Listeners/ChecksTwoFactorCode.php b/src/Listeners/ChecksTwoFactorCode.php deleted file mode 100644 index 6fc0219..0000000 --- a/src/Listeners/ChecksTwoFactorCode.php +++ /dev/null @@ -1,75 +0,0 @@ -hasTwoFactorEnabled() - && (! $this->isSafeDevicesEnabled() || ! $user->isSafeDevice($this->request)); - } - - /** - * Checks if the app config has Safe Devices enabled. - * - * @return bool - */ - protected function isSafeDevicesEnabled() - { - return $this->config['laraguard.safe_devices.enabled']; - } - - - /** - * Checks if the user wants to add this device as "safe". - * - * @return bool - */ - protected function wantsAddSafeDevice() - { - return $this->request->filled('safe_device'); - } - - /** - * Returns if the Request has the Two Factor Code. - * - * @return bool - */ - protected function hasCode() - { - return $this->request->filled($this->input); - } - - /** - * Checks if the Request has a Two Factor Code and is correct and valid. - * - * @param \DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable $user - * @return bool - */ - protected function hasValidCode(TwoFactorAuthenticatable $user) - { - return $this->hasCorrectCode() && $user->validateTwoFactorCode($this->request->input($this->input)); - } - - /** - * Checks if the Request has a Two Factor Code and is correct. - * - * @return bool - */ - protected function hasCorrectCode() { - return ! validator($this->request->only($this->input), [$this->input => 'alphanum'])->fails(); - } -} diff --git a/src/Listeners/EnforceTwoFactorAuth.php b/src/Listeners/EnforceTwoFactorAuth.php deleted file mode 100644 index b5271b8..0000000 --- a/src/Listeners/EnforceTwoFactorAuth.php +++ /dev/null @@ -1,127 +0,0 @@ -config = $config; - $this->request = $request; - $this->input = $config['laraguard.input']; - } - - /** - * Saves the credentials temporarily into the class instance. - * - * @param \Illuminate\Auth\Events\Attempting $event - * @return void - */ - public function saveCredentials(Attempting $event) - { - $this->credentials = (array) $event->credentials; - $this->remember = (bool) $event->remember; - } - - /** - * Checks if the user should use Two Factor Auth. - * - * @param \Illuminate\Auth\Events\Validated $event - * @return void - */ - public function checkTwoFactor(Validated $event) - { - if ($this->shouldUseTwoFactorAuth($event->user)) { - // If the request doesn't have any code, just throw a response. - if (! $this->hasCode()) { - $this->throwResponse($event->user); - } - - // If the user has set an invalid code, throw him a response. - if (! $this->hasValidCode($event->user)) { - $this->throwResponse($event->user, true); - } - - // The code is valid so we will need to check if the device should - // be registered as safe. For that, we will check if the config - // allows it, and there is a checkbox filled to opt-in this. - if ($this->isSafeDevicesEnabled() && $this->wantsAddSafeDevice()) { - $event->user->addSafeDevice($this->request); - } - } - } - - /** - * Creates a response containing the Two Factor Authentication view. - * - * @param \DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable $user - * @param bool $error - * @return void - * @throws \Illuminate\Http\Exceptions\HttpResponseException - */ - protected function throwResponse(TwoFactorAuthenticatable $user, bool $error = false) - { - $view = view('laraguard::auth', [ - 'action' => $this->request->fullUrl(), - 'credentials' => $this->credentials, - 'user' => $user, - 'error' => $error, - 'remember' => $this->remember, - 'input' => $this->input - ]); - - response($view, $error ? 422 : 403, [ - 'Cache-Control' => 'no-cache, must-revalidate', - ])->throwResponse(); - } -} diff --git a/src/Rules/TotpCodeRule.php b/src/Rules/TotpCodeRule.php index 48e821f..5258d7d 100644 --- a/src/Rules/TotpCodeRule.php +++ b/src/Rules/TotpCodeRule.php @@ -8,46 +8,32 @@ class TotpCodeRule { - /** - * The auth user. - * - * @var \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable - */ - protected $user; - - /** - * Translator instance. - * - * @var \Illuminate\Contracts\Translation\Translator - */ - protected $translator; - /** * Create a new "totp code" rule instance. * * @param \Illuminate\Contracts\Translation\Translator $translator * @param \Illuminate\Contracts\Auth\Authenticatable|null $user */ - public function __construct(Translator $translator, Authenticatable $user = null) + public function __construct(protected Translator $translator, + protected ?Authenticatable $user = null) { - $this->user = $user; - $this->translator = $translator; + // } /** - * Validate that an attribute is a valid Two Factor Authentication TOTP code. + * Validate that an attribute is a valid Two-Factor Authentication TOTP code. * * @param string $attribute * @param mixed $value * @return bool */ - public function validate($attribute, $value) + public function validate(string $attribute, mixed $value): bool { - if (is_string($value) && $this->user instanceof TwoFactorAuthenticatable) { + if (is_numeric($value) && $this->user instanceof TwoFactorAuthenticatable) { return $this->user->validateTwoFactorCode($value); } return false; } -} \ No newline at end of file +} diff --git a/src/TwoFactorAuthentication.php b/src/TwoFactorAuthentication.php index 37adf31..36ce0da 100644 --- a/src/TwoFactorAuthentication.php +++ b/src/TwoFactorAuthentication.php @@ -2,8 +2,15 @@ namespace DarkGhostHunter\Laraguard; +use DateTimeInterface; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use function collect; +use function config; +use function cookie; +use function event; +use function now; /** * @property-read \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication $twoFactorAuth @@ -15,39 +22,38 @@ trait TwoFactorAuthentication * * @return void */ - public function initializeTwoFactorAuthentication() + public function initializeTwoFactorAuthentication(): void { - // For security, we will hide the Two Factor Authentication data from the parent model. + // For security, we will hide the Two-Factor Authentication data from the parent model. $this->makeHidden('twoFactorAuth'); } /** - * This connects the current Model to the Two Factor Authentication model. + * This connects the current Model to the Two-Factor Authentication model. * - * @return \Illuminate\Database\Eloquent\Relations\MorphOne|\DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication + * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ - public function twoFactorAuth() + public function twoFactorAuth(): MorphOne { - return $this->morphOne(config('laraguard.model'), 'authenticatable') - ->withDefault(config('laraguard.totp')); + return $this->morphOne(config('laraguard.model'), 'authenticatable')->withDefault(config('laraguard.totp')); } /** - * Determines if the User has Two Factor Authentication enabled. + * Determines if the User has Two-Factor Authentication enabled. * * @return bool */ - public function hasTwoFactorEnabled() : bool + public function hasTwoFactorEnabled(): bool { return $this->twoFactorAuth->isEnabled(); } /** - * Enables Two Factor Authentication for the given user. + * Enables Two-Factor Authentication for the given user. * * @return void */ - public function enableTwoFactorAuth() : void + public function enableTwoFactorAuth(): void { $this->twoFactorAuth->enabled_at = now(); @@ -61,28 +67,27 @@ public function enableTwoFactorAuth() : void } /** - * Disables Two Factor Authentication for the given user. + * Disables Two-Factor Authentication for the given user. * * @return void */ - public function disableTwoFactorAuth() : void + public function disableTwoFactorAuth(): void { - $this->twoFactorAuth->flushAuth()->save(); + $this->twoFactorAuth->delete(); event(new Events\TwoFactorDisabled($this)); } /** - * Creates a new Two Factor Auth mechanisms from scratch, and returns a new Shared Secret. + * Creates a new Two-Factor Auth mechanisms from scratch, and returns a new Shared Secret. * * @return \DarkGhostHunter\Laraguard\Contracts\TwoFactorTotp */ - public function createTwoFactorAuth() : Contracts\TwoFactorTotp + public function createTwoFactorAuth(): Contracts\TwoFactorTotp { - $this->twoFactorAuth - ->flushAuth() - ->setAttribute('label', $this->twoFactorLabel()) - ->save(); + $this->twoFactorAuth->flushAuth()->update([ + 'label' => $this->twoFactorLabel() + ]); return $this->twoFactorAuth; } @@ -92,19 +97,21 @@ public function createTwoFactorAuth() : Contracts\TwoFactorTotp * * @return string */ - protected function twoFactorLabel() + protected function twoFactorLabel(): string { return $this->getAttribute('email'); } /** - * Confirms the Shared Secret and fully enables the Two Factor Authentication. + * Confirms the Shared Secret and fully enables the Two-Factor Authentication. * * @param string $code + * * @return bool */ - public function confirmTwoFactorAuth(string $code) : bool + public function confirmTwoFactorAuth(string $code): bool { + // If the Two-Factor is already enabled, there is no need to re-confirm the code. if ($this->hasTwoFactorEnabled()) { return true; } @@ -121,9 +128,10 @@ public function confirmTwoFactorAuth(string $code) : bool * Verifies the Code against the Shared Secret. * * @param $code + * * @return bool */ - protected function validateCode($code) + protected function validateCode($code): bool { return $this->twoFactorAuth->validateCode($code); } @@ -131,12 +139,13 @@ protected function validateCode($code) /** * Validates the TOTP Code or Recovery Code. * - * @param string $code + * @param string|null $code + * * @return bool */ - public function validateTwoFactorCode(?string $code = null) : bool + public function validateTwoFactorCode(?string $code = null): bool { - if (! $code || ! $this->hasTwoFactorEnabled()) { + if (!$code || !$this->hasTwoFactorEnabled()) { return false; } @@ -144,13 +153,14 @@ public function validateTwoFactorCode(?string $code = null) : bool } /** - * Makes a Two Factor Code for a given time, and period offset. + * Makes a Two-Factor Code for a given time, and period offset. * - * @param int|string|\Illuminate\Support\Carbon|\Datetime $at + * @param \DateTimeInterface|int|string $at * @param int $offset + * * @return string */ - public function makeTwoFactorCode($at = 'now', int $offset = 0) : string + public function makeTwoFactorCode(DateTimeInterface|int|string $at = 'now', int $offset = 0): string { return $this->twoFactorAuth->makeCode($at, $offset); } @@ -160,7 +170,7 @@ public function makeTwoFactorCode($at = 'now', int $offset = 0) : string * * @return bool */ - protected function hasRecoveryCodes() : bool + protected function hasRecoveryCodes(): bool { return $this->twoFactorAuth->containsUnusedRecoveryCodes(); } @@ -170,7 +180,7 @@ protected function hasRecoveryCodes() : bool * * @return \Illuminate\Support\Collection */ - public function getRecoveryCodes() : Collection + public function getRecoveryCodes(): Collection { return $this->twoFactorAuth->recovery_codes ?? collect(); } @@ -180,11 +190,13 @@ public function getRecoveryCodes() : Collection * * @return \Illuminate\Support\Collection */ - public function generateRecoveryCodes() : Collection + public function generateRecoveryCodes(): Collection { - [$enabled, $amount, $length] = array_values(config('laraguard.recovery')); + [$model, $amount, $length] = config()->get([ + 'laraguard.model', 'laraguard.recovery.amount', 'laraguard.recovery.length' + ]); - $this->twoFactorAuth->recovery_codes = config('laraguard.model')::generateRecoveryCodes($amount, $length); + $this->twoFactorAuth->recovery_codes = $model::generateRecoveryCodes($amount, $length); $this->twoFactorAuth->recovery_codes_generated_at = now(); $this->twoFactorAuth->save(); @@ -197,17 +209,18 @@ public function generateRecoveryCodes() : Collection * Uses a one-time Recovery Code if there is one available. * * @param string $code + * * @return mixed */ - protected function useRecoveryCode(string $code) : bool + protected function useRecoveryCode(string $code): bool { - if (! config('laraguard.recovery.enabled') || ! $this->twoFactorAuth->setRecoveryCodeAsUsed($code)) { + if (!config('laraguard.recovery.enabled') || !$this->twoFactorAuth->setRecoveryCodeAsUsed($code)) { return false; } $this->twoFactorAuth->save(); - if (! $this->hasRecoveryCodes()) { + if (!$this->hasRecoveryCodes()) { event(new Events\TwoFactorRecoveryCodesDepleted($this)); } @@ -218,35 +231,35 @@ protected function useRecoveryCode(string $code) : bool * Adds a "safe" Device from the Request. * * @param \Illuminate\Http\Request $request + * * @return string */ - public function addSafeDevice(Request $request) : string + public function addSafeDevice(Request $request): string { - $devices = collect($this->twoFactorAuth->safe_devices)->push([ - '2fa_remember' => $token = $this->generateTwoFactorRemember(), - 'ip' => $request->ip(), - 'added_at' => now()->timestamp, - ])->sortByDesc('added_at'); - - if ($devices->count() > $max = config('laraguard.safe_devices.max_devices')) { - $devices = $devices->slice(0, $max)->values(); - } + [$name, $expiration] = config(['laraguard.safe_devices.cookie', 'laraguard.safe_devices.expiration_days']); - $this->twoFactorAuth->safe_devices = $devices; + $this->twoFactorAuth->safe_devices = $this->safeDevices() + ->sortBy('added_at') + ->slice(0, max(1, config('laraguard.safe_devices.max_devices', 3)) - 1) + ->push([ + '2fa_remember' => $token = $this->generateTwoFactorRemember(), + 'ip' => $request->ip(), + 'added_at' => now()->timestamp, + ]); $this->twoFactorAuth->save(); - cookie()->queue('2fa_remember', $token, config('laraguard.safe_devices.expiration_days', 0) * 1440); + cookie()->queue($name, $token, $expiration * 1440); return $token; } /** - * Generates a Device token to bypass Two Factor Authentication. + * Generates a Device token to bypass Two-Factor Authentication. * * @return string */ - protected function generateTwoFactorRemember() + protected function generateTwoFactorRemember(): string { return config('laraguard.model')::generateDefaultTwoFactorRemember(); } @@ -256,17 +269,17 @@ protected function generateTwoFactorRemember() * * @return bool */ - public function flushSafeDevices() : bool + public function flushSafeDevices(): bool { return $this->twoFactorAuth->setAttribute('safe_devices', null)->save(); } /** - * Return all the Safe Devices that bypass Two Factor Authentication. + * Return all the Safe Devices that bypass Two-Factor Authentication. * * @return \Illuminate\Support\Collection */ - public function safeDevices() : Collection + public function safeDevices(): Collection { return $this->twoFactorAuth->safe_devices ?? collect(); } @@ -274,14 +287,13 @@ public function safeDevices() : Collection /** * Determines if the Request has been made through a previously used "safe" device. * - * @param null|\Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request + * * @return bool */ - public function isSafeDevice(Request $request) : bool + public function isSafeDevice(Request $request): bool { - $timestamp = $this->twoFactorAuth->getSafeDeviceTimestamp( - $this->getTwoFactorRememberFromRequest($request) - ); + $timestamp = $this->twoFactorAuth->getSafeDeviceTimestamp($this->getTwoFactorRememberFromRequest($request)); if ($timestamp) { return $timestamp->addDays(config('laraguard.safe_devices.expiration_days'))->isFuture(); @@ -294,21 +306,23 @@ public function isSafeDevice(Request $request) : bool * Returns the Two Factor Remember Token of the request. * * @param \Illuminate\Http\Request $request - * @return null|array|string + * + * @return string|null */ - protected function getTwoFactorRememberFromRequest(Request $request) + protected function getTwoFactorRememberFromRequest(Request $request): ?string { - return $request->cookie('2fa_remember'); + return $request->cookie(config('laraguard.safe_devices.cookie', '2fa_remember')); } /** * Determines if the Request has been made through a not-previously-known device. * - * @param null|\Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request + * * @return bool */ - public function isNotSafeDevice(Request $request) : bool + public function isNotSafeDevice(Request $request): bool { - return ! $this->isSafeDevice($request); + return !$this->isSafeDevice($request); } } From 2dd5983b324509eee6b0c937ecd7af15011225e6 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 01:50:49 -0300 Subject: [PATCH 05/22] Fixes. Tests. Done. --- composer.json | 5 +- ...grade_two_factor_authentications_table.php | 10 +- src/Contracts/TwoFactorTotp.php | 21 +- src/Eloquent/HandlesCodes.php | 36 +- src/Eloquent/HandlesRecoveryCodes.php | 13 +- src/Http/Controllers/Confirms2FACode.php | 2 +- src/Http/Middleware/ConfirmTwoFactorCode.php | 12 +- .../Middleware/RequireTwoFactorEnabled.php | 25 +- src/Laraguard.php | 4 +- src/Rules/TotpCodeRule.php | 14 +- src/TwoFactorAuthentication.php | 41 +- tests/CreatesTwoFactorUser.php | 5 +- .../Eloquent/TwoFactorAuthenticationTest.php | 41 +- tests/Eloquent/UpgradeTest.php | 101 ++++ tests/Events/EventsTest.php | 8 +- .../ConfirmTwoFactorEnabledTest.php | 47 +- .../RequireTwoFactorEnabledTest.php | 12 +- tests/LaraguardTest.php | 217 ++++++++ tests/Listeners/ForcesTwoFactorAuthTest.php | 497 ------------------ tests/Listeners/ListenerNotRegisteredTest.php | 44 -- tests/RegistersLoginRoute.php | 2 +- tests/Rules/TotpRuleTest.php | 20 +- tests/RunsPublishableMigrations.php | 4 +- tests/Stubs/UserStub.php | 2 + tests/Stubs/UserTwoFactorStub.php | 12 +- tests/TwoFactorAuthenticationTest.php | 57 +- 26 files changed, 510 insertions(+), 742 deletions(-) create mode 100644 tests/Eloquent/UpgradeTest.php create mode 100644 tests/LaraguardTest.php delete mode 100644 tests/Listeners/ForcesTwoFactorAuthTest.php delete mode 100644 tests/Listeners/ListenerNotRegisteredTest.php diff --git a/composer.json b/composer.json index ab55e77..5313428 100644 --- a/composer.json +++ b/composer.json @@ -33,9 +33,10 @@ "illuminate/auth": "^8.39" }, "require-dev": { - "orchestra/testbench": "^6.0", + "doctrine/dbal": "^3.1", + "mockery/mockery": "^1.4", "orchestra/canvas": "^6.0", - "mockery/mockery":"^1.4", + "orchestra/testbench": "^6.0", "phpunit/phpunit": "^9.3" }, "autoload": { diff --git a/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php b/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php index 0c55501..bbe66ab 100644 --- a/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php +++ b/database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php @@ -29,7 +29,7 @@ public function __construct() */ public function up(): void { - Schema::table('two_factor_authentications', function (Blueprint $table) { + Schema::table('two_factor_authentications', static function (Blueprint $table): void { $table->text('shared_secret')->change(); $table->text('recovery_codes')->nullable()->change(); }); @@ -52,14 +52,14 @@ protected function chunkRows(bool $encrypt): void $query = DB::table('two_factor_authentications'); $query->clone()->select('id', 'shared_secret', 'recovery_codes') - ->chunk( + ->chunkById( 1000, static function (Collection $chunk) use ($encrypter, $query, $call): void { DB::beginTransaction(); foreach ($chunk as $item) { - $query->clone()->where('id', $item)->update([ + $query->clone()->where('id', $item->id)->update([ 'shared_secret' => $encrypter->$call($item->shared_secret), - 'recovery_codes' => $encrypter->$call($item->recovery_codes), + 'recovery_codes' => $item->recovery_codes ? $encrypter->$call($item->recovery_codes) : null, ]); } DB::commit(); @@ -77,7 +77,7 @@ public function down(): void // Before changing the shared secret column, we will need to decrypt the shared secret. $this->chunkRows(false); - Schema::table('two_factor_authentications', function (Blueprint $table) { + Schema::table('two_factor_authentications', static function (Blueprint $table): void { $table->string('shared_secret')->change(); $table->json('recovery_codes')->nullable()->change(); }); diff --git a/src/Contracts/TwoFactorTotp.php b/src/Contracts/TwoFactorTotp.php index 641cc24..5e1060b 100644 --- a/src/Contracts/TwoFactorTotp.php +++ b/src/Contracts/TwoFactorTotp.php @@ -2,6 +2,7 @@ namespace DarkGhostHunter\Laraguard\Contracts; +use DateTimeInterface; use Illuminate\Contracts\Support\Renderable; use Stringable; @@ -11,39 +12,41 @@ interface TwoFactorTotp extends Renderable, Stringable * Validates a given code, optionally for a given timestamp and future window. * * @param string $code - * @param int|string|\Illuminate\Support\Carbon|\Datetime $at - * @param int $window + * @param \DateTimeInterface|int|string $at + * @param int|null $window + * * @return bool */ - public function validateCode(string $code, $at = 'now', int $window = null) : bool; + public function validateCode(string $code, DateTimeInterface|int|string $at = 'now', int $window = null): bool; /** * Creates a Code for a given timestamp, optionally by a given period offset. * - * @param string $at + * @param \DateTimeInterface|int|string $at * @param int $offset + * * @return string */ - public function makeCode($at = 'now', int $offset = 0) : string; + public function makeCode(DateTimeInterface|int|string $at = 'now', int $offset = 0): string; /** * Returns the Shared Secret as a QR Code. * * @return string */ - public function toQr() : string; + public function toQr(): string; /** * Returns the Shared Secret as a string. * * @return string */ - public function toString() : string; + public function toString(): string; /** - * Returns the Shared Secret as an URI. + * Returns the Shared Secret as a URI. * * @return string */ - public function toUri() : string; + public function toUri(): string; } diff --git a/src/Eloquent/HandlesCodes.php b/src/Eloquent/HandlesCodes.php index 44be615..963c0c6 100644 --- a/src/Eloquent/HandlesCodes.php +++ b/src/Eloquent/HandlesCodes.php @@ -2,14 +2,22 @@ namespace DarkGhostHunter\Laraguard\Eloquent; -use DateTime; use DateTimeInterface; use Illuminate\Contracts\Cache\Repository; use Illuminate\Support\Carbon; use ParagonIE\ConstantTime\Base32; -use function config; use function cache; +use function config; +use function floor; +use function hash_hmac; +use function implode; +use function now; +use function ord; +use function pack; +use function str_pad; +use function strlen; + trait HandlesCodes { @@ -56,12 +64,12 @@ protected function useCacheStore(string $store = null): Repository * Validates a given code, optionally for a given timestamp and future window. * * @param string $code - * @param string $at + * @param \DateTimeInterface|int|string $at * @param int|null $window * * @return bool */ - public function validateCode(string $code, $at = 'now', int $window = null): bool + public function validateCode(string $code, DateTimeInterface|int|string $at = 'now', int $window = null): bool { if ($this->codeHasBeenUsed($code)) { return false; @@ -82,12 +90,12 @@ public function validateCode(string $code, $at = 'now', int $window = null): boo /** * Creates a Code for a given timestamp, optionally by a given period offset. * - * @param int|string|\Illuminate\Support\Carbon|\Datetime $at + * @param \DateTimeInterface|int|string $at * @param int $offset * * @return string */ - public function makeCode($at = 'now', int $offset = 0): string + public function makeCode(DateTimeInterface|int|string $at = 'now', int $offset = 0): string { return $this->generateCode( $this->getTimestampFromPeriod($at, $offset) @@ -159,12 +167,12 @@ protected function getBinarySecret(): string /** * Get the timestamp from a given elapsed "periods" of seconds. * - * @param \DatetimeInterface|int|string $at + * @param \DateTimeInterface|int|string|null $at * @param int $period * * @return int */ - protected function getTimestampFromPeriod(DatetimeInterface|int|string $at = 'now', int $period = 0): int + protected function getTimestampFromPeriod(DatetimeInterface|int|string|null $at, int $period = 0): int { $periods = ($this->parseTimestamp($at) / $this->seconds) + $period; @@ -178,17 +186,13 @@ protected function getTimestampFromPeriod(DatetimeInterface|int|string $at = 'no * * @return int */ - protected function parseTimestamp(DatetimeInterface|int|string $at = 'now'): int + protected function parseTimestamp(DatetimeInterface|int|string $at): int { - if (is_string($at)) { - $at = date_create($at); - } - - if (is_int($at)) { - $at = now()->addSeconds($at); + if (!is_int($at)) { + $at = Carbon::parse($at)->getTimestamp(); } - return $at->getTimestamp(); + return $at; } /** diff --git a/src/Eloquent/HandlesRecoveryCodes.php b/src/Eloquent/HandlesRecoveryCodes.php index 6799c5e..da417c9 100644 --- a/src/Eloquent/HandlesRecoveryCodes.php +++ b/src/Eloquent/HandlesRecoveryCodes.php @@ -14,21 +14,22 @@ trait HandlesRecoveryCodes */ public function containsUnusedRecoveryCodes(): bool { - return (bool) $this->recovery_codes?->contains('used_at'); + return (bool) $this->recovery_codes?->contains('used_at', '==', null); } /** * Returns the key of the not-used Recovery Code. * * @param string $code - * @return int|null + * + * @return int|bool|null */ - protected function getUnusedRecoveryCodeIndex(string $code): ?int + protected function getUnusedRecoveryCodeIndex(string $code): int|null|bool { return $this->recovery_codes?->search([ 'code' => $code, 'used_at' => null, - ]); + ], true); } /** @@ -39,11 +40,11 @@ protected function getUnusedRecoveryCodeIndex(string $code): ?int */ public function setRecoveryCodeAsUsed(string $code): bool { - if (null === $index = $this->getUnusedRecoveryCodeIndex($code)) { + if (! is_int($index = $this->getUnusedRecoveryCodeIndex($code))) { return false; } - $this->recovery_codes->put($index, [ + $this->recovery_codes = $this->recovery_codes->put($index, [ 'code' => $code, 'used_at' => now(), ]); diff --git a/src/Http/Controllers/Confirms2FACode.php b/src/Http/Controllers/Confirms2FACode.php index ad30264..2aa9c93 100644 --- a/src/Http/Controllers/Confirms2FACode.php +++ b/src/Http/Controllers/Confirms2FACode.php @@ -56,7 +56,7 @@ protected function resetTotpConfirmationTimeout(Request $request): void protected function rules(): array { return [ - config('laraguard.input') => 'required|totp_code', + '2fa_code' => 'required|totp_code', ]; } diff --git a/src/Http/Middleware/ConfirmTwoFactorCode.php b/src/Http/Middleware/ConfirmTwoFactorCode.php index c7b702a..9823979 100644 --- a/src/Http/Middleware/ConfirmTwoFactorCode.php +++ b/src/Http/Middleware/ConfirmTwoFactorCode.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Routing\ResponseFactory; use DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable; use Illuminate\Http\Request; +use Illuminate\Http\Response; class ConfirmTwoFactorCode { @@ -18,7 +19,10 @@ class ConfirmTwoFactorCode * @param \Illuminate\Contracts\Routing\UrlGenerator $url * @param \Illuminate\Contracts\Auth\Authenticatable|null $user */ - public function __construct(protected ResponseFactory $response, protected UrlGenerator $url, protected ?Authenticatable $user = null) + public function __construct( + protected ResponseFactory $response, + protected UrlGenerator $url, + protected ?Authenticatable $user = null) { // } @@ -31,9 +35,9 @@ public function __construct(protected ResponseFactory $response, protected UrlGe * @param string $redirectToRoute * @return mixed */ - public function handle($request, Closure $next, string $redirectToRoute = '2fa.confirm'): mixed + public function handle($request, Closure $next, string $redirectToRoute = '2fa.confirm') { - if ($this->userHasNotEnabledTwoFactorAuth() || $this->codeAlreadyValidated($request)) { + if ($this->userHasNotEnabledTwoFactorAuth() || $this->codeWasValidated($request)) { return $next($request); } @@ -58,7 +62,7 @@ protected function userHasNotEnabledTwoFactorAuth(): bool * @param \Illuminate\Http\Request $request * @return bool */ - protected function codeAlreadyValidated(Request $request): bool + protected function codeWasValidated(Request $request): bool { $confirmedAt = now()->timestamp - $request->session()->get('2fa.totp_confirmed_at', 0); diff --git a/src/Http/Middleware/RequireTwoFactorEnabled.php b/src/Http/Middleware/RequireTwoFactorEnabled.php index 8fd388d..c9e9a7a 100644 --- a/src/Http/Middleware/RequireTwoFactorEnabled.php +++ b/src/Http/Middleware/RequireTwoFactorEnabled.php @@ -9,30 +9,15 @@ class RequireTwoFactorEnabled { - /** - * Current User authenticated. - * - * @var \Illuminate\Contracts\Auth\Authenticatable|\DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable|null - */ - protected $user; - - /** - * Response Factory. - * - * @var \Illuminate\Contracts\Routing\ResponseFactory - */ - protected $response; - /** * Create a new middleware instance. * * @param \Illuminate\Contracts\Auth\Authenticatable|null $user * @param \Illuminate\Contracts\Routing\ResponseFactory $response */ - public function __construct(ResponseFactory $response, Authenticatable $user = null) + public function __construct(protected ResponseFactory $response, protected ?Authenticatable $user = null) { - $this->response = $response; - $this->user = $user; + // } /** @@ -43,7 +28,7 @@ public function __construct(ResponseFactory $response, Authenticatable $user = n * @param string $redirectToRoute * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse|mixed */ - public function handle($request, Closure $next, $redirectToRoute = '2fa.notice') + public function handle($request, Closure $next, string $redirectToRoute = '2fa.notice') { if ($this->hasTwoFactorAuthDisabled()) { return $request->expectsJson() @@ -55,11 +40,11 @@ public function handle($request, Closure $next, $redirectToRoute = '2fa.notice') } /** - * Check if the user has Two Factor Authentication enabled. + * Check if the user has Two-Factor Authentication enabled. * * @return bool */ - protected function hasTwoFactorAuthDisabled() + protected function hasTwoFactorAuthDisabled(): bool { return $this->user instanceof TwoFactorAuthenticatable && ! $this->user->hasTwoFactorEnabled(); } diff --git a/src/Laraguard.php b/src/Laraguard.php index 66ed78b..5be2b57 100644 --- a/src/Laraguard.php +++ b/src/Laraguard.php @@ -45,7 +45,7 @@ public static function hasCodeOrFails(string $input = '2fa_code', string $messag } throw ValidationException::withMessages([ - config('laraguard.input') => $message ?? trans('laraguard::validation.totp_code'), + $input => $message ?? trans('laraguard::validation.totp_code'), ]); }; } @@ -86,7 +86,7 @@ public function validate(Authenticatable $user): bool } } - return true; + return false; } /** diff --git a/src/Rules/TotpCodeRule.php b/src/Rules/TotpCodeRule.php index 5258d7d..2117e1c 100644 --- a/src/Rules/TotpCodeRule.php +++ b/src/Rules/TotpCodeRule.php @@ -14,8 +14,7 @@ class TotpCodeRule * @param \Illuminate\Contracts\Translation\Translator $translator * @param \Illuminate\Contracts\Auth\Authenticatable|null $user */ - public function __construct(protected Translator $translator, - protected ?Authenticatable $user = null) + public function __construct(protected Translator $translator, protected ?Authenticatable $user = null) { // } @@ -27,13 +26,10 @@ public function __construct(protected Translator $translator, * @param mixed $value * @return bool */ - public function validate(string $attribute, mixed $value): bool + public function validate($attribute, $value): bool { - if (is_numeric($value) && $this->user instanceof TwoFactorAuthenticatable) { - return $this->user->validateTwoFactorCode($value); - } - - return false; + return is_string($value) + && $this->user instanceof TwoFactorAuthenticatable + && $this->user->validateTwoFactorCode($value); } - } diff --git a/src/TwoFactorAuthentication.php b/src/TwoFactorAuthentication.php index 36ce0da..6c0e6b6 100644 --- a/src/TwoFactorAuthentication.php +++ b/src/TwoFactorAuthentication.php @@ -73,7 +73,7 @@ public function enableTwoFactorAuth(): void */ public function disableTwoFactorAuth(): void { - $this->twoFactorAuth->delete(); + $this->twoFactorAuth->flushAuth()->delete(); event(new Events\TwoFactorDisabled($this)); } @@ -85,9 +85,9 @@ public function disableTwoFactorAuth(): void */ public function createTwoFactorAuth(): Contracts\TwoFactorTotp { - $this->twoFactorAuth->flushAuth()->update([ + $this->twoFactorAuth->flushAuth()->forceFill([ 'label' => $this->twoFactorLabel() - ]); + ])->save(); return $this->twoFactorAuth; } @@ -127,11 +127,11 @@ public function confirmTwoFactorAuth(string $code): bool /** * Verifies the Code against the Shared Secret. * - * @param $code + * @param string|int $code * * @return bool */ - protected function validateCode($code): bool + protected function validateCode(string|int $code): bool { return $this->twoFactorAuth->validateCode($code); } @@ -145,11 +145,9 @@ protected function validateCode($code): bool */ public function validateTwoFactorCode(?string $code = null): bool { - if (!$code || !$this->hasTwoFactorEnabled()) { - return false; - } - - return $this->useRecoveryCode($code) || $this->validateCode($code); + return null !== $code + && $this->hasTwoFactorEnabled() + && ($this->validateCode($code) || $this->useRecoveryCode($code)); } /** @@ -192,9 +190,9 @@ public function getRecoveryCodes(): Collection */ public function generateRecoveryCodes(): Collection { - [$model, $amount, $length] = config()->get([ - 'laraguard.model', 'laraguard.recovery.amount', 'laraguard.recovery.length' - ]); + [$model, $amount, $length] = array_values(config()->get([ + 'laraguard.model', 'laraguard.recovery.codes', 'laraguard.recovery.length' + ])); $this->twoFactorAuth->recovery_codes = $model::generateRecoveryCodes($amount, $length); $this->twoFactorAuth->recovery_codes_generated_at = now(); @@ -214,7 +212,7 @@ public function generateRecoveryCodes(): Collection */ protected function useRecoveryCode(string $code): bool { - if (!config('laraguard.recovery.enabled') || !$this->twoFactorAuth->setRecoveryCodeAsUsed($code)) { + if (!$this->twoFactorAuth->setRecoveryCodeAsUsed($code)) { return false; } @@ -228,7 +226,7 @@ protected function useRecoveryCode(string $code): bool } /** - * Adds a "safe" Device from the Request. + * Adds a "safe" Device from the Request, and returns the token used. * * @param \Illuminate\Http\Request $request * @@ -236,16 +234,19 @@ protected function useRecoveryCode(string $code): bool */ public function addSafeDevice(Request $request): string { - [$name, $expiration] = config(['laraguard.safe_devices.cookie', 'laraguard.safe_devices.expiration_days']); + [$name, $expiration] = array_values(config()->get([ + 'laraguard.safe_devices.cookie', 'laraguard.safe_devices.expiration_days' + ])); $this->twoFactorAuth->safe_devices = $this->safeDevices() - ->sortBy('added_at') - ->slice(0, max(1, config('laraguard.safe_devices.max_devices', 3)) - 1) ->push([ '2fa_remember' => $token = $this->generateTwoFactorRemember(), 'ip' => $request->ip(), - 'added_at' => now()->timestamp, - ]); + 'added_at' => $this->freshTimestamp()->getTimestamp(), + ]) + ->sortByDesc('added_at') // Ensure the last is the first, so we can slice it. + ->slice(0, config('laraguard.safe_devices.max_devices', 3)) + ->values(); $this->twoFactorAuth->save(); diff --git a/tests/CreatesTwoFactorUser.php b/tests/CreatesTwoFactorUser.php index 6c62c38..d7acb87 100644 --- a/tests/CreatesTwoFactorUser.php +++ b/tests/CreatesTwoFactorUser.php @@ -2,6 +2,7 @@ namespace Tests; +use Tests\Stubs\UserStub; use Tests\Stubs\UserTwoFactorStub; use DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication; @@ -10,12 +11,12 @@ trait CreatesTwoFactorUser /** @var \Tests\Stubs\UserTwoFactorStub */ protected $user; - protected function createTwoFactorUser() + protected function createTwoFactorUser(): void { $this->user = UserTwoFactorStub::create([ 'name' => 'foo', 'email' => 'foo@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', + 'password' => UserStub::PASSWORD_SECRET, ]); $this->user->twoFactorAuth()->save( diff --git a/tests/Eloquent/TwoFactorAuthenticationTest.php b/tests/Eloquent/TwoFactorAuthenticationTest.php index 8f53704..19239ec 100644 --- a/tests/Eloquent/TwoFactorAuthenticationTest.php +++ b/tests/Eloquent/TwoFactorAuthenticationTest.php @@ -6,6 +6,7 @@ use Tests\RegistersPackage; use Orchestra\Testbench\TestCase; use ParagonIE\ConstantTime\Base32; +use Tests\Stubs\UserStub; use Tests\Stubs\UserTwoFactorStub; use Tests\RunsPublishableMigrations; use Illuminate\Support\Facades\Cache; @@ -28,12 +29,12 @@ protected function setUp() : void parent::setUp(); } - public function test_returns_authenticatable() + public function test_returns_authenticatable(): void { $user = UserTwoFactorStub::create([ 'name' => 'foo', 'email' => 'foo@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', + 'password' => UserStub::PASSWORD_SECRET, ]); $user->twoFactorAuth()->save( @@ -45,7 +46,7 @@ public function test_returns_authenticatable() } - public function test_lowercases_algorithm() + public function test_lowercases_algorithm(): void { $tfa = TwoFactorAuthentication::factory() ->withRecovery()->withSafeDevices() @@ -56,7 +57,7 @@ public function test_lowercases_algorithm() $this->assertEquals('abcde2', $tfa->algorithm); } - public function test_is_enabled_and_is_disabled() + public function test_is_enabled_and_is_disabled(): void { $tfa = new TwoFactorAuthentication(); @@ -69,7 +70,7 @@ public function test_is_enabled_and_is_disabled() $this->assertFalse($tfa->isDisabled()); } - public function test_flushes_authentication() + public function test_flushes_authentication(): void { $tfa = TwoFactorAuthentication::factory() ->withRecovery()->withSafeDevices() @@ -102,14 +103,14 @@ public function test_flushes_authentication() $this->assertNull($tfa->safe_devices); } - public function test_generates_random_secret() + public function test_generates_random_secret(): void { $secret = TwoFactorAuthentication::generateRandomSecret(); $this->assertEquals(config('laraguard.secret_length'), strlen(Base32::decodeUpper($secret))); } - public function test_makes_code() + public function test_makes_code(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => $secret = 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -138,7 +139,7 @@ public function test_makes_code() $this->assertEquals('976814', $tfa->makeCode('4th february 2020')); } - public function test_makes_code_for_timestamp() + public function test_makes_code_for_timestamp(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => $secret = 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -148,7 +149,7 @@ public function test_makes_code_for_timestamp() $this->assertTrue($tfa->validateCode('566278', 1581300000)); } - public function test_validate_code() + public function test_validate_code(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => $secret = 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -167,7 +168,7 @@ public function test_validate_code() $this->assertFalse($tfa->validateCode($code)); } - public function test_validate_code_with_window() + public function test_validate_code_with_window(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => $secret = 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -196,7 +197,7 @@ public function test_validate_code_with_window() $this->assertFalse($tfa->validateCode($code)); } - public function test_contains_unused_recovery_codes() + public function test_contains_unused_recovery_codes(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make(); @@ -220,7 +221,7 @@ public function test_contains_unused_recovery_codes() $this->assertFalse($tfa->containsUnusedRecoveryCodes()); } - public function test_generates_recovery_codes() + public function test_generates_recovery_codes(): void { $codes = TwoFactorAuthentication::generateRecoveryCodes(13, 7); @@ -232,12 +233,12 @@ public function test_generates_recovery_codes() }); } - public function test_generates_random_safe_device_remember_token() + public function test_generates_random_safe_device_remember_token(): void { $this->assertEquals(100, strlen(TwoFactorAuthentication::generateDefaultTwoFactorRemember())); } - public function test_serializes_to_string() + public function test_serializes_to_string(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => $secret = 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -248,7 +249,7 @@ public function test_serializes_to_string() $this->assertEquals($secret, (string)$tfa); } - public function test_serializes_to_grouped_string() + public function test_serializes_to_grouped_string(): void { $tfa = TwoFactorAuthentication::factory()->withRecovery()->withSafeDevices()->make([ 'shared_secret' => 'KS72XBTN5PEBGX2IWBMVW44LXHPAQ7L3', @@ -257,7 +258,7 @@ public function test_serializes_to_grouped_string() $this->assertEquals('KS72 XBTN 5PEB GX2I WBMV W44L XHPA Q7L3', $tfa->toGroupedString()); } - public function test_serializes_to_uri() + public function test_serializes_to_uri(): void { config(['laraguard.issuer' => 'quz']); @@ -273,7 +274,7 @@ public function test_serializes_to_uri() $this->assertEquals($uri, $tfa->toUri()); } - public function test_serializes_to_qr_and_renders_to_qr() + public function test_serializes_to_qr_and_renders_to_qr(): void { config(['laraguard.issuer' => 'quz']); @@ -288,7 +289,7 @@ public function test_serializes_to_qr_and_renders_to_qr() $this->assertStringEqualsFile(__DIR__ . '/../Stubs/QrStub.svg', $tfa->render()); } - public function test_serializes_to_qr_and_renders_to_qr_with_custom_values() + public function test_serializes_to_qr_and_renders_to_qr_with_custom_values(): void { config(['laraguard.issuer' => 'quz']); config(['laraguard.qr_code' => [ @@ -307,7 +308,7 @@ public function test_serializes_to_qr_and_renders_to_qr_with_custom_values() $this->assertStringEqualsFile(__DIR__ . '/../Stubs/CustomQrStub.svg', $tfa->render()); } - public function test_serializes_uri_to_json() + public function test_serializes_uri_to_json(): void { config(['laraguard.issuer' => 'quz']); @@ -325,7 +326,7 @@ public function test_serializes_uri_to_json() $this->assertEquals($uri, json_encode($tfa)); } - public function test_changes_issuer() + public function test_changes_issuer(): void { config(['laraguard.issuer' => 'foo bar']); diff --git a/tests/Eloquent/UpgradeTest.php b/tests/Eloquent/UpgradeTest.php new file mode 100644 index 0000000..88caace --- /dev/null +++ b/tests/Eloquent/UpgradeTest.php @@ -0,0 +1,101 @@ +id(); + $table->string('shared_secret'); + $table->string('recovery_codes')->nullable(); + $table->timestampsTz(); + }); + + DB::table('two_factor_authentications')->insert([ + 'shared_secret' => $secret = Str::random(300), + 'recovery_codes' => $codes = Collection::make(['foo' => 'bar']), + ]); + + DB::table('two_factor_authentications')->insert([ + 'shared_secret' => $secret, + 'recovery_codes' => null, + ]); + + require __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; + + (new \UpgradeTwoFactorAuthenticationsTable)->up(); + + static::assertSame($secret, TwoFactorAuthentication::find(1)->shared_secret); + static::assertSame($codes->all(), TwoFactorAuthentication::find(1)->recovery_codes->all()); + static::assertNull(TwoFactorAuthentication::find(2)->recovery_codes); + + static::assertSame( + 'text', + DB::connection()->getDoctrineColumn('two_factor_authentications', 'shared_secret')->getType()->getName() + ); + + static::assertSame( + 'text', + DB::connection()->getDoctrineColumn('two_factor_authentications', 'recovery_codes')->getType()->getName() + ); + } + + public function test_rollbacks_migration(): void + { + Schema::create('two_factor_authentications', function (Blueprint $table) { + $table->id(); + $table->text('shared_secret'); + $table->text('recovery_codes')->nullable(); + $table->timestampsTz(); + }); + + DB::table('two_factor_authentications')->insert([ + 'shared_secret' => Crypt::encryptString($secret = Str::random(300)), + 'recovery_codes' => Crypt::encryptString($codes = Collection::make(['foo' => 'bar'])), + ]); + + DB::table('two_factor_authentications')->insert([ + 'shared_secret' => Crypt::encryptString($secret), + 'recovery_codes' => null, + ]); + + require __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; + + (new \UpgradeTwoFactorAuthenticationsTable)->down(); + + static::assertSame($secret, DB::table('two_factor_authentications')->where('id', 1)->first()->shared_secret); + static::assertSame($codes->toJson(), DB::table('two_factor_authentications')->where('id', 1)->first()->recovery_codes); + static::assertNull(DB::table('two_factor_authentications')->where('id', 2)->first()->recovery_codes); + + static::assertSame( + 'string', + DB::connection()->getDoctrineColumn('two_factor_authentications', 'shared_secret')->getType()->getName() + ); + + static::assertSame( + 'json', + DB::connection()->getDoctrineColumn('two_factor_authentications', 'recovery_codes')->getType()->getName() + ); + } +} diff --git a/tests/Events/EventsTest.php b/tests/Events/EventsTest.php index ad61f93..9a81d95 100644 --- a/tests/Events/EventsTest.php +++ b/tests/Events/EventsTest.php @@ -31,7 +31,7 @@ protected function setUp() : void parent::setUp(); } - public function test_fires_two_factor_enabled_event() + public function test_fires_two_factor_enabled_event(): void { $event = Event::fake(); @@ -44,7 +44,7 @@ public function test_fires_two_factor_enabled_event() }); } - public function test_fires_two_factor_disabled_event() + public function test_fires_two_factor_disabled_event(): void { $event = Event::fake(); @@ -55,7 +55,7 @@ public function test_fires_two_factor_disabled_event() }); } - public function test_fires_two_factor_recovery_codes_depleted() + public function test_fires_two_factor_recovery_codes_depleted(): void { $event = Event::fake(); @@ -77,7 +77,7 @@ public function test_fires_two_factor_recovery_codes_depleted() }); } - public function test_fires_two_factor_recovery_codes_generated() + public function test_fires_two_factor_recovery_codes_generated(): void { $event = Event::fake(); diff --git a/tests/Http/Middleware/ConfirmTwoFactorEnabledTest.php b/tests/Http/Middleware/ConfirmTwoFactorEnabledTest.php index 2c6a909..10bc405 100644 --- a/tests/Http/Middleware/ConfirmTwoFactorEnabledTest.php +++ b/tests/Http/Middleware/ConfirmTwoFactorEnabledTest.php @@ -2,13 +2,13 @@ namespace Tests\Http\Middleware; -use Tests\Stubs\UserStub; -use Tests\RegistersPackage; -use Tests\CreatesTwoFactorUser; +use Illuminate\Foundation\Testing\DatabaseMigrations; +use Illuminate\Support\Facades\Date; use Orchestra\Testbench\TestCase; +use Tests\CreatesTwoFactorUser; +use Tests\RegistersPackage; use Tests\RunsPublishableMigrations; -use Illuminate\Support\Facades\Date; -use Illuminate\Foundation\Testing\DatabaseMigrations; +use Tests\Stubs\UserStub; class ConfirmTwoFactorEnabledTest extends TestCase { @@ -17,7 +17,7 @@ class ConfirmTwoFactorEnabledTest extends TestCase use RunsPublishableMigrations; use CreatesTwoFactorUser; - protected function setUp() : void + protected function setUp(): void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); $this->afterApplicationCreated([$this, 'runPublishableMigration']); @@ -32,19 +32,19 @@ protected function setUp() : void parent::setUp(); } - public function test_continues_if_user_is_not_2fa_instance() + public function test_continues_if_user_is_not_2fa_instance(): void { $this->actingAs(UserStub::create([ 'name' => 'test', 'email' => 'bar@test.com', - 'password' => '$2y$10$K0WnjWfbVBYcCvoSAh0yRurrgXgWVgQE2JHBJ.zdQdGHXgJofgGKC', + 'password' => UserStub::PASSWORD_SECRET, ])); $this->followingRedirects()->get('intended')->assertSee('ok'); $this->getJson('intended')->assertSee('ok'); } - public function test_continues_if_user_is_2fa_but_not_activated() + public function test_continues_if_user_is_2fa_but_not_activated(): void { $this->actingAs(tap($this->user)->disableTwoFactorAuth()); @@ -52,7 +52,7 @@ public function test_continues_if_user_is_2fa_but_not_activated() $this->getJson('intended')->assertSee('ok'); } - public function test_asks_for_confirmation() + public function test_asks_for_confirmation(): void { $this->actingAs($this->user); @@ -61,7 +61,7 @@ public function test_asks_for_confirmation() $this->getJson('intended')->assertJson(['message' => trans('laraguard::messages.required')]); } - public function test_redirects_to_intended_when_code_valid() + public function test_redirects_to_intended_when_code_valid(): void { $this->actingAs($this->user); @@ -72,7 +72,7 @@ public function test_redirects_to_intended_when_code_valid() $this->followingRedirects() ->post('2fa/confirm', [ - config('laraguard.input') => $this->user->makeTwoFactorCode() + '2fa_code' => $this->user->makeTwoFactorCode(), ]) ->assertSessionHas('2fa.totp_confirmed_at') ->assertSee('ok'); @@ -82,7 +82,7 @@ public function test_redirects_to_intended_when_code_valid() ->assertSee('ok'); } - public function test_returns_ok_on_json_response() + public function test_returns_ok_on_json_response(): void { $this->actingAs($this->user); @@ -92,13 +92,13 @@ public function test_returns_ok_on_json_response() ->assertStatus(403); $this->postJson('2fa/confirm', [ - config('laraguard.input') => $this->user->makeTwoFactorCode() - ]) + '2fa_code' => $this->user->makeTwoFactorCode(), + ]) ->assertSessionHas('2fa.totp_confirmed_at') ->assertNoContent(); } - public function test_returns_validation_error_when_code_invalid() + public function test_returns_validation_error_when_code_invalid(): void { $this->actingAs($this->user); @@ -107,12 +107,12 @@ public function test_returns_validation_error_when_code_invalid() ->assertViewIs('laraguard::confirm'); $this->post('2fa/confirm', [ - config('laraguard.input') => 'invalid' - ]) + '2fa_code' => 'invalid', + ]) ->assertSessionHasErrors(); } - public function test_bypasses_check_if_below_timeout() + public function test_bypasses_check_if_below_timeout(): void { Date::setTestNow($now = Date::create(2020, 04, 01, 20, 20)); @@ -131,7 +131,7 @@ public function test_bypasses_check_if_below_timeout() ->assertViewIs('laraguard::confirm'); } - public function test_throttles_totp() + public function test_throttles_totp(): void { Date::setTestNow($now = Date::create(2020, 04, 01, 20, 20)); @@ -143,17 +143,16 @@ public function test_throttles_totp() for ($i = 0; $i < 60; $i++) { $this->post('2fa/confirm', [ - config('laraguard.input') => 'invalid' + '2fa_code' => 'invalid', ])->assertSessionHasErrors(); } $this->post('2fa/confirm', [ - config('laraguard.input') => 'invalid' + '2fa_code' => 'invalid', ])->assertStatus(429); - } - public function test_routes_to_alternate_named_route() + public function test_routes_to_alternate_named_route(): void { $this->app['router']->get('intended_to_foo', function () { return 'ok'; diff --git a/tests/Http/Middleware/RequireTwoFactorEnabledTest.php b/tests/Http/Middleware/RequireTwoFactorEnabledTest.php index e6fff8b..a323e90 100644 --- a/tests/Http/Middleware/RequireTwoFactorEnabledTest.php +++ b/tests/Http/Middleware/RequireTwoFactorEnabledTest.php @@ -40,19 +40,19 @@ protected function setUp() : void parent::setUp(); } - public function test_guest_cant_access() + public function test_guest_cant_access(): void { $this->get('test')->assertRedirect('login'); $this->getJson('test')->assertJson(['message' => 'Unauthenticated.'])->assertStatus(401); } - public function test_user_no_2fa_can_access() + public function test_user_no_2fa_can_access(): void { $this->actingAs(UserStub::create([ 'name' => 'test', 'email' => 'bar@test.com', - 'password' => '$2y$10$K0WnjWfbVBYcCvoSAh0yRurrgXgWVgQE2JHBJ.zdQdGHXgJofgGKC', + 'password' => UserStub::PASSWORD_SECRET, ])); $this->get('test')->assertSee('ok'); @@ -60,7 +60,7 @@ public function test_user_no_2fa_can_access() $this->getJson('test')->assertSee('ok')->assertOk(); } - public function test_user_2fa_not_enabled_cant_acesss() + public function test_user_2fa_not_enabled_cant_access(): void { $this->actingAs(tap($this->user)->disableTwoFactorAuth()); @@ -71,7 +71,7 @@ public function test_user_2fa_not_enabled_cant_acesss() ->assertForbidden(); } - public function test_user_2fa_enabled_access() + public function test_user_2fa_enabled_access(): void { $this->actingAs($this->user); @@ -80,7 +80,7 @@ public function test_user_2fa_enabled_access() $this->getJson('test')->assertSee('ok'); } - public function test_redirects_to_custom_notice() + public function test_redirects_to_custom_notice(): void { $this->actingAs(tap($this->user)->disableTwoFactorAuth()); diff --git a/tests/LaraguardTest.php b/tests/LaraguardTest.php new file mode 100644 index 0000000..430eab9 --- /dev/null +++ b/tests/LaraguardTest.php @@ -0,0 +1,217 @@ +afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); + $this->afterApplicationCreated([$this, 'registerLoginRoute']); + $this->afterApplicationCreated([$this, 'createTwoFactorUser']); + $this->afterApplicationCreated(function (): void { + app('config')->set('auth.providers.users.model', UserTwoFactorStub::class); + $this->travelTo(today()); + }); + parent::setUp(); + } + + public function test_authenticates_with_when(): void + { + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => $this->user->makeTwoFactorCode() + ])); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); + } + + public function test_authenticates_with_when_with_no_exceptions(): void + { + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => $this->user->makeTwoFactorCode() + ])); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCodeOrFails())); + } + + public function test_authenticates_with_different_input_name(): void + { + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + 'foo_bar' => $this->user->makeTwoFactorCode() + ])); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode('foo_bar'))); + } + + public function test_doesnt_authenticates_with_invalid_code(): void + { + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => 'invalid' + ])); + + static::assertFalse(Auth::attemptWhen($credentials, Laraguard::hasCode())); + } + + public function test_non_two_factor_user_doesnt_authenticate(): void + { + $user = UserStub::create([ + 'name' => 'bar', + 'email' => 'bar@test.com', + 'password' => UserStub::PASSWORD_SECRET, + ]); + + $credentials = [ + 'email' => $user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => $this->user->makeTwoFactorCode() + ])); + + static::assertFalse(Auth::attemptWhen($credentials, Laraguard::hasCode())); + } + + public function test_validation_exception_when_code_invalid(): void + { + $this->expectException(ValidationException::class); + + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => 'invalid' + ])); + + try { + Auth::attemptWhen($credentials, Laraguard::hasCodeOrFails()); + } catch (ValidationException $exception) { + static::assertSame(['2fa_code' => ['The Code is invalid or has expired.']], $exception->errors()); + throw $exception; + } + } + + public function test_validation_exception_with_message_when_code_invalid(): void + { + $this->expectException(ValidationException::class); + + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => 'invalid' + ])); + + try { + Auth::attemptWhen($credentials, Laraguard::hasCodeOrFails(message: 'foo')); + } catch (ValidationException $exception) { + static::assertSame(['2fa_code' => ['foo']], $exception->errors()); + throw $exception; + } + } + + public function test_saves_safe_device(): void + { + config()->set('laraguard.safe_devices.enabled', true); + + Cookie::partialMock()->shouldReceive('queue') + ->with('2fa_remember', Mockery::type('string'), 14 * 1440) + ->once(); + + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => $this->user->makeTwoFactorCode(), + 'safe_device' => 'on', + ])); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); + static::assertCount(1, $this->user->fresh()->safeDevices()); + } + + public function test_doesnt_adds_safe_device_when_input_not_filled(): void + { + config()->set('laraguard.safe_devices.enabled', true); + + Cookie::partialMock()->shouldNotReceive('queue'); + + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', Request::create('test', 'POST', [ + '2fa_code' => $this->user->makeTwoFactorCode(), + ])); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); + + static::assertEmpty($this->user->fresh()->safeDevices()); + } + + public function test_doesnt_bypasses_totp_if_safe_devices(): void + { + config()->set('laraguard.safe_devices.enabled', true); + + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->instance('request', $request = Request::create('test', 'POST')); + + $token = $this->user->addSafeDevice($request); + + $request->cookies->set('2fa_remember', $token); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); + } +} diff --git a/tests/Listeners/ForcesTwoFactorAuthTest.php b/tests/Listeners/ForcesTwoFactorAuthTest.php deleted file mode 100644 index c0cee61..0000000 --- a/tests/Listeners/ForcesTwoFactorAuthTest.php +++ /dev/null @@ -1,497 +0,0 @@ -afterApplicationCreated([$this, 'loadLaravelMigrations']); - $this->afterApplicationCreated([$this, 'runPublishableMigration']); - $this->afterApplicationCreated([$this, 'createTwoFactorUser']); - $this->afterApplicationCreated([$this, 'registerLoginRoute']); - parent::setUp(); - } - - protected function getEnvironmentSetUp($app) - { - $app['config']->set('auth.providers.users.model', UserTwoFactorStub::class); - } - - public function test_form_contains_action_credentials_remember_and_user() - { - $view = view('laraguard::auth')->with([ - 'action' => 'qux', - 'user' => $this->user, - 'credentials' => ['foo' => 'bar'], - 'remember' => 'on', - 'error' => true, - 'input' => 'bar' - ])->render(); - - $this->assertStringContainsString('action="qux"', $view); - $this->assertStringContainsString('', $view); - $this->assertStringContainsString('', $view); - $this->assertStringNotContainsString('assertStringContainsString(trans('The Code is invalid or has expired.'), $view); - } - - public function test_form_doesnt_contains_credentials() - { - $view = view('laraguard::auth')->with([ - 'action' => 'qux', - 'user' => $this->user, - 'credentials' => null, - 'remember' => 'on', - 'error' => true, - 'input' => 'foo' - ])->render(); - - $this->assertStringContainsString('action="qux"', $view); - $this->assertStringNotContainsString('', $view); - $this->assertStringContainsString('', $view); - $this->assertStringNotContainsString('assertStringContainsString(trans('The Code is invalid or has expired.'), $view); - } - - public function test_login_with_no_valid_credentials_no_2fa_fails() - { - $this->app['config']->set('auth.providers.users.model', UserStub::class); - - $user = UserStub::create([ - 'name' => 'test', - 'email' => 'bar@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', - ]); - - $this->post('login', [ - 'email' => $user->email, - 'password' => 'invalid', - 'remember' => 'on', - ])->assertSee('authenticated'); - } - - public function test_login_with_no_2fa_no_code_succeeds() - { - $this->app['config']->set('auth.providers.users.model', UserStub::class); - - $user = UserStub::create([ - 'name' => 'test', - 'email' => 'quz@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', - ]); - - $this->post('login', [ - 'email' => $user->email, - 'password' => '12345678', - 'remember' => 'on', - ])->assertSee('authenticated'); - } - - public function test_login_with_no_2fa_with_code_succeeds() - { - $this->app['config']->set('auth.providers.users.model', UserStub::class); - - $user = UserStub::create([ - 'name' => 'test', - 'email' => 'bar@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', - ]); - - $this->post('login', [ - 'email' => $user->email, - 'password' => '12345678', - 'remember' => 'on', - 'code' => '123456', - ])->assertSee('authenticated'); - } - - public function test_login_request_with_2fa_disabled_no_code_succeeds() - { - $this->user->disableTwoFactorAuth(); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertOk()->assertSee('authenticated'); - } - - public function test_login_request_with_2fa_but_invalid_credentials_fails() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => 'invalid', - 'remember' => 'on', - ])->assertSee('unauthenticated'); - } - - public function test_login_request_with_2fa_no_code_shows_form() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ]) - ->assertViewIs('laraguard::auth') - ->assertViewHasAll([ - 'action' => url('login'), - 'credentials' => [ - 'email' => 'foo@test.com', - 'password' => '12345678', - ], - 'user' => $this->user, - 'remember' => true, - ]) - ->assertDontSee('expired'); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - ]) - ->assertViewIs('laraguard::auth') - ->assertViewHasAll([ - 'action' => url('login'), - 'credentials' => [ - 'email' => 'foo@test.com', - 'password' => '12345678', - ], - 'user' => $this->user, - 'remember' => false, - ]) - ->assertDontSee('expired'); - } - - public function test_login_request_with_2fa_with_code_succeeds() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $this->user->twoFactorAuth->makeCode(), - ])->assertSeeText('authenticated'); - } - - public function test_login_request_from_safe_device_with_safe_device_disabled_shows_form() - { - $this->user->twoFactorAuth->safe_devices = collect([ - [ - '2fa_remember' => $token = Str::random(100), - 'ip' => $this->faker->ipv4, - 'added_at' => $this->faker->dateTimeBetween('-1 month'), - ], - ]); - - $this->user->twoFactorAuth->save(); - - config(['laraguard.safe_devices.enabled' => false]); - - $this->withCookie('2fa_remember', $token)->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertViewIs('laraguard::auth'); - } - - public function test_login_request_from_safe_devices_with_safe_devices_enabled_doesnt_save_device() - { - config(['laraguard.safe_devices.enabled' => true]); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $this->user->twoFactorAuth->makeCode(), - ], [ - 'REMOTE_ADDR' => $ip = $this->faker->ipv4, - ])->assertSeeText('authenticated'); - - $this->user->refresh(); - - $this->assertCount(0, $this->user->twoFactorAuth->safe_devices ?? []); - } - - public function test_login_request_from_safe_devices_with_safe_devices_enabled_and_wants_saves_device() - { - config(['laraguard.safe_devices.enabled' => true]); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $this->user->twoFactorAuth->makeCode(), - 'safe_device' => 'on' - ], [ - 'REMOTE_ADDR' => $ip = $this->faker->ipv4, - ])->assertSeeText('authenticated'); - - $this->user->refresh(); - - $this->assertCount(1, $this->user->twoFactorAuth->safe_devices ?? []); - } - - public function test_login_request_from_save_device_with_save_devices_doesnt_save_and_shows_form() - { - config(['laraguard.safe_devices.enabled' => true]); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => 'invalid', - ], [ - 'REMOTE_ADDR' => $ip = $this->faker->ipv4, - ])->assertViewIs('laraguard::auth'); - - $this->user->refresh(); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - } - - public function test_login_request_from_safe_device_without_matching_device_shows_form() - { - $this->user->twoFactorAuth->safe_devices = collect([ - [ - '2fa_remember' => Str::random(100), - 'ip' => $this->faker->ipv4, - 'added_at' => $this->faker->dateTimeBetween('-1 month')->getTimestamp(), - ], - ]); - - $this->user->twoFactorAuth->save(); - - config(['laraguard.safe_devices.enabled' => true]); - - $this->withCookie('2fa_remember', Str::random(100))->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertViewIs('laraguard::auth'); - } - - public function test_login_request_from_safe_device_with_matching_safe_device_succeeds() - { - $this->user->twoFactorAuth->safe_devices = collect([ - [ - '2fa_remember' => $token = Str::random(100), - 'ip' => $this->faker->ipv4, - 'added_at' => $this->faker->dateTimeBetween('-14 days')->getTimestamp(), - ], - ]); - - $this->user->twoFactorAuth->save(); - - config(['laraguard.safe_devices.enabled' => true]); - - $this->withCookie('2fa_remember', $token)->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertOk(); - } - - public function test_auth_request_receives_no_code_shows_form() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => '', - ])->assertViewIs('laraguard::auth')->assertForbidden(); - } - - public function test_auth_request_receives_invalid_code_shows_form() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => 'invalid', - ])->assertViewIs('laraguard::auth')->assertStatus(422); - } - - public function test_auth_request_receives_empty_code_shows_form() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertViewIs('laraguard::auth')->assertForbidden(); - } - - public function test_auth_request_receives_expired_code_shows_form() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $this->user->twoFactorAuth->makeCode('now', -2), - ])->assertViewIs('laraguard::auth')->assertStatus(422); - } - - public function test_auth_request_receives_valid_code_succeeds() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $this->user->twoFactorAuth->makeCode(), - ])->assertSee('authenticated')->assertOk(); - } - - public function test_auth_request_receives_recovery_code_without_recovery_enabled_shows_form() - { - $code = $this->user->generateRecoveryCodes()->first()['code']; - - config(['laraguard.recovery.enabled' => false]); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertViewIs('laraguard::auth')->assertStatus(422); - } - - public function test_auth_request_receives_recovery_code_without_recovery_codes_available_shows_form() - { - $code = $this->user->generateRecoveryCodes()->first()['code']; - - $this->user->twoFactorAuth->setAttribute('recovery_codes', null)->save(); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertViewIs('laraguard::auth')->assertStatus(422); - } - - public function test_auth_request_receives_recovery_code_succeeds() - { - $code = $this->user->generateRecoveryCodes()->first()['code']; - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertSee('authenticated')->assertOk(); - } - - public function test_auth_request_receives_code_and_doesnt_saves_device() - { - Carbon::setTestNow(); - - $code = $this->user->twoFactorAuth->makeCode(); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertSee('authenticated')->assertOk(); - - $this->user->refresh(); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - } - - public function test_auth_requests_receives_code_and_doesnt_save_device() - { - config(['laraguard.safe_devices.enabled' => true]); - - Carbon::setTestNow(); - - $code = $this->user->twoFactorAuth->makeCode(); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertSee('authenticated')->assertOk(); - - $this->user->refresh(); - - $this->assertCount(0, $this->user->twoFactorAuth->safe_devices ?? []); - - $this->app->forgetInstance(TwoFactorListener::class); - - $code = $this->user->generateRecoveryCodes()->first()['code']; - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - ])->assertSee('authenticated')->assertOk(); - - $this->user->refresh(); - - $this->assertNull($this->user->twoFactorAuth->safe_devices); - } - - public function test_auth_requests_receives_code_and_saves_devices() - { - config(['laraguard.safe_devices.enabled' => true]); - - Carbon::setTestNow(); - - $code = $this->user->twoFactorAuth->makeCode(); - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - 'safe_device' => 'on' - ])->assertSee('authenticated')->assertOk(); - - $this->user->refresh(); - - $this->assertCount(1, $this->user->twoFactorAuth->safe_devices ?? []); - - $this->app->forgetInstance(TwoFactorListener::class); - - $code = $this->user->generateRecoveryCodes()->first()['code']; - - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - '2fa_code' => $code, - 'safe_device' => 'on' - ])->assertSee('authenticated')->assertOk(); - - $this->user->refresh(); - - $this->assertCount(2, $this->user->twoFactorAuth->safe_devices); - } -} diff --git a/tests/Listeners/ListenerNotRegisteredTest.php b/tests/Listeners/ListenerNotRegisteredTest.php deleted file mode 100644 index ef8ac85..0000000 --- a/tests/Listeners/ListenerNotRegisteredTest.php +++ /dev/null @@ -1,44 +0,0 @@ -afterApplicationCreated([$this, 'loadLaravelMigrations']); - $this->afterApplicationCreated([$this, 'runPublishableMigration']); - $this->afterApplicationCreated([$this, 'registerLoginRoute']); - $this->afterApplicationCreated([$this, 'createTwoFactorUser']); - parent::setUp(); - } - - protected function getEnvironmentSetUp($app) - { - $app['config']->set('laraguard.listener', false); - $app['config']->set('auth.providers.users.model', UserTwoFactorStub::class); - } - - public function test_listener_disabled_doesnt_enforces_2fa() - { - $this->post('login', [ - 'email' => 'foo@test.com', - 'password' => '12345678', - 'remember' => 'on', - ])->assertOk()->assertSee('authenticated'); - } -} diff --git a/tests/RegistersLoginRoute.php b/tests/RegistersLoginRoute.php index 6fb537d..64f60db 100644 --- a/tests/RegistersLoginRoute.php +++ b/tests/RegistersLoginRoute.php @@ -9,7 +9,7 @@ trait RegistersLoginRoute { - protected function registerLoginRoute() + protected function registerLoginRoute(): void { Route::post('login', function (Request $request) { try { diff --git a/tests/Rules/TotpRuleTest.php b/tests/Rules/TotpRuleTest.php index 5ca3b44..c1b70c6 100644 --- a/tests/Rules/TotpRuleTest.php +++ b/tests/Rules/TotpRuleTest.php @@ -32,7 +32,7 @@ protected function setUp() : void parent::setUp(); } - public function test_validation_fails_if_guest() + public function test_validation_fails_if_guest(): void { $fails = validator([ 'totp_code' => '123456' @@ -43,12 +43,12 @@ public function test_validation_fails_if_guest() $this->assertTrue($fails); } - public function test_validation_fails_if_user_is_not_2fa() + public function test_validation_fails_if_user_is_not_2fa(): void { $user = UserStub::create([ 'name' => 'test', 'email' => 'bar@test.com', - 'password' => '$2y$10$K0WnjWfbVBYcCvoSAh0yRurrgXgWVgQE2JHBJ.zdQdGHXgJofgGKC', + 'password' => UserStub::PASSWORD_SECRET, ]); $this->app['auth']->guard()->setUser($user); @@ -62,7 +62,7 @@ public function test_validation_fails_if_user_is_not_2fa() $this->assertTrue($fails); } - public function test_validator_fails_if_user_is_2fa_but_not_enabled() + public function test_validator_fails_if_user_is_2fa_but_not_enabled(): void { $this->app['auth']->guard()->setUser(tap($this->user)->disableTwoFactorAuth()); @@ -75,7 +75,7 @@ public function test_validator_fails_if_user_is_2fa_but_not_enabled() $this->assertTrue($fails); } - public function test_validator_fails_if_user_is_2fa_but_code_is_invalid() + public function test_validator_fails_if_user_is_2fa_but_code_is_invalid(): void { $this->app['auth']->guard()->setUser($this->user); @@ -88,7 +88,7 @@ public function test_validator_fails_if_user_is_2fa_but_code_is_invalid() $this->assertTrue($fails); } - public function test_validator_fails_if_user_is_2fa_but_code_is_expired_over_window() + public function test_validator_fails_if_user_is_2fa_but_code_is_expired_over_window(): void { Date::setTestNow($now = Date::create(2020, 04, 01, 16, 30)); @@ -103,7 +103,7 @@ public function test_validator_fails_if_user_is_2fa_but_code_is_expired_over_win $this->assertTrue($fails); } - public function test_validator_succeeds_if_code_valid() + public function test_validator_succeeds_if_code_valid(): void { Date::setTestNow($now = Date::create(2020, 04, 01, 16, 30)); @@ -118,7 +118,7 @@ public function test_validator_succeeds_if_code_valid() $this->assertFalse($fails); } - public function test_validator_succeeds_if_code_is_recovery() + public function test_validator_succeeds_if_code_is_recovery(): void { $this->app['auth']->guard()->setUser($this->user); @@ -131,7 +131,7 @@ public function test_validator_succeeds_if_code_is_recovery() $this->assertFalse($fails); } - public function test_validator_rule_uses_translation() + public function test_validator_rule_uses_translation(): void { $validator = validator([ 'totp_code' => 'invalid' @@ -142,4 +142,4 @@ public function test_validator_rule_uses_translation() $this->assertSame('The Code is invalid or has expired.', $validator->errors()->get('totp_code')[0]); } -} \ No newline at end of file +} diff --git a/tests/RunsPublishableMigrations.php b/tests/RunsPublishableMigrations.php index 09896f8..9ed84b8 100644 --- a/tests/RunsPublishableMigrations.php +++ b/tests/RunsPublishableMigrations.php @@ -4,12 +4,12 @@ trait RunsPublishableMigrations { - protected function runPublishableMigration() + protected function runPublishableMigration(): void { $this->loadMigrationsFrom([ '--realpath' => true, '--path' => [ - realpath(__DIR__ . '/../database/migrations') + realpath(__DIR__ . '/../database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php') ] ]); } diff --git a/tests/Stubs/UserStub.php b/tests/Stubs/UserStub.php index 3a6f265..c8834aa 100644 --- a/tests/Stubs/UserStub.php +++ b/tests/Stubs/UserStub.php @@ -10,6 +10,8 @@ class UserStub extends Model implements AuthenticatableContract { use Authenticatable; + public const PASSWORD_SECRET = '$2y$10$O.vJ1iYXIoNH3orPUNWuNui7BUkl4fWYY1R/8GC5KRCXQ7tnqId8K'; + protected $table = 'users'; protected $fillable = [ diff --git a/tests/Stubs/UserTwoFactorStub.php b/tests/Stubs/UserTwoFactorStub.php index 8f32c07..7b187c2 100644 --- a/tests/Stubs/UserTwoFactorStub.php +++ b/tests/Stubs/UserTwoFactorStub.php @@ -11,15 +11,7 @@ /** * @mixin \Illuminate\Database\Eloquent\Builder */ -class UserTwoFactorStub extends Model implements TwoFactorAuthenticatable, AuthenticatableContract +class UserTwoFactorStub extends UserStub implements TwoFactorAuthenticatable { - use TwoFactorAuthentication, Authenticatable; - - protected $table = 'users'; - - protected $fillable = [ - 'name', - 'email', - 'password', - ]; + use TwoFactorAuthentication; } diff --git a/tests/TwoFactorAuthenticationTest.php b/tests/TwoFactorAuthenticationTest.php index 9cc67ad..a328474 100644 --- a/tests/TwoFactorAuthenticationTest.php +++ b/tests/TwoFactorAuthenticationTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Orchestra\Testbench\TestCase; +use Tests\Stubs\UserStub; use Tests\Stubs\UserTwoFactorStub; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Cache; @@ -35,7 +36,7 @@ protected function setUp() : void parent::setUp(); } - public function test_hides_relation_from_serialization() + public function test_hides_relation_from_serialization(): void { $array = $this->user->toArray(); @@ -43,13 +44,13 @@ public function test_hides_relation_from_serialization() $this->assertArrayNotHasKey('twoFactorAuth', $array); } - public function test_returns_two_factor_relation() + public function test_returns_two_factor_relation(): void { $this->assertInstanceOf(MorphOne::class, $this->user->twoFactorAuth()); $this->assertInstanceOf(TwoFactorAuthentication::class, $this->user->twoFactorAuth); } - public function test_has_two_factor_enabled() + public function test_has_two_factor_enabled(): void { $this->assertTrue($this->user->hasTwoFactorEnabled()); @@ -58,7 +59,7 @@ public function test_has_two_factor_enabled() $this->assertFalse($this->user->hasTwoFactorEnabled()); } - public function test_disables_two_factor_authentication() + public function test_disables_two_factor_authentication(): void { $events = Event::fake(); @@ -70,7 +71,7 @@ public function test_disables_two_factor_authentication() }); } - public function test_enables_two_factor_authentication() + public function test_enables_two_factor_authentication(): void { $events = Event::fake(); @@ -82,13 +83,13 @@ public function test_enables_two_factor_authentication() }); } - public function test_creates_two_factor_authentication() + public function test_creates_two_factor_authentication(): void { $events = Event::fake(); $user = UserTwoFactorStub::create([ 'name' => 'bar', 'email' => 'bar@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', + 'password' => UserStub::PASSWORD_SECRET, ]); $this->assertDatabaseMissing('two_factor_authentications', [ @@ -111,7 +112,7 @@ public function test_creates_two_factor_authentication() $events->assertNotDispatched(TwoFactorEnabled::class); } - public function test_creates_two_factor_flushes_old_auth() + public function test_creates_two_factor_flushes_old_auth(): void { $this->user->twoFactorAuth->safe_devices = collect([1, 2, 3]); $this->user->twoFactorAuth->save(); @@ -129,7 +130,7 @@ public function test_creates_two_factor_flushes_old_auth() $this->assertNull($this->user->twoFactorAuth->enabled_at); } - public function test_rewrites_when_creating_two_factor_authentication() + public function test_rewrites_when_creating_two_factor_authentication(): void { $this->assertDatabaseHas('two_factor_authentications', [ ['authenticatable_type', UserTwoFactorStub::class], @@ -147,7 +148,7 @@ public function test_rewrites_when_creating_two_factor_authentication() $this->assertNotEquals($old, $this->user->twoFactorAuth->shared_secret); } - public function test_new_user_confirms_two_factor_successfully() + public function test_new_user_confirms_two_factor_successfully(): void { $event = Event::fake(); @@ -156,7 +157,7 @@ public function test_new_user_confirms_two_factor_successfully() $user = UserTwoFactorStub::create([ 'name' => 'bar', 'email' => 'bar@test.com', - 'password' => '$2y$10$EicEv29xyMt/AbuWc0AIkeWb8Ip0fdhAYqgiXUaoG8Klu43521jQW', + 'password' => UserStub::PASSWORD_SECRET, ]); $user->createTwoFactorAuth(); @@ -177,7 +178,7 @@ public function test_new_user_confirms_two_factor_successfully() }); } - public function test_confirms_twice_but_doesnt_change_the_secret() + public function test_confirms_twice_but_doesnt_change_the_secret(): void { $event = Event::fake(); @@ -201,7 +202,7 @@ public function test_confirms_twice_but_doesnt_change_the_secret() $event->assertNotDispatched(TwoFactorRecoveryCodesGenerated::class); } - public function test_doesnt_confirm_two_factor_auth_with_old_recovery_code() + public function test_doesnt_confirm_two_factor_auth_with_old_recovery_code(): void { $recovery_code = $this->user->twoFactorAuth->recovery_codes->random(); @@ -212,7 +213,7 @@ public function test_doesnt_confirm_two_factor_auth_with_old_recovery_code() $this->assertFalse($this->user->confirmTwoFactorAuth($code)); } - public function test_old_user_confirms_new_two_factor_successfully() + public function test_old_user_confirms_new_two_factor_successfully(): void { $event = Event::fake(); @@ -245,7 +246,7 @@ public function test_old_user_confirms_new_two_factor_successfully() }); } - public function test_validates_two_factor_code() + public function test_validates_two_factor_code(): void { Carbon::setTestNow($now = Carbon::create(2020, 01, 01, 18, 30)); @@ -254,7 +255,7 @@ public function test_validates_two_factor_code() $this->assertTrue($this->user->validateTwoFactorCode($code)); } - public function test_validates_two_factor_code_with_recovery_code() + public function test_validates_two_factor_code_with_recovery_code(): void { Carbon::setTestNow($now = Carbon::create(2020, 01, 01, 18, 30)); @@ -268,7 +269,7 @@ public function test_validates_two_factor_code_with_recovery_code() $this->assertFalse($this->user->validateTwoFactorCode($recovery_code)); } - public function test_doesnt_validates_if_two_factor_auth_is_disabled() + public function test_doesnt_validates_if_two_factor_auth_is_disabled(): void { Carbon::setTestNow($now = Carbon::create(2020, 01, 01, 18, 30)); @@ -284,7 +285,7 @@ public function test_doesnt_validates_if_two_factor_auth_is_disabled() $this->assertFalse($this->user->validateTwoFactorCode($recovery_code)); } - public function test_fires_recovery_codes_depleted() + public function test_fires_recovery_codes_depleted(): void { $event = Event::fake(); @@ -302,7 +303,7 @@ public function test_fires_recovery_codes_depleted() }); } - public function test_safe_device() + public function test_safe_device(): void { Carbon::setTestNow($now = Carbon::create(2020, 01, 01, 18, 30)); @@ -319,7 +320,7 @@ public function test_safe_device() $this->assertEquals(1577903400, $this->user->safeDevices()->first()['added_at']); } - public function test_oldest_safe_device_discarded_when_adding_maximum() + public function test_oldest_safe_device_discarded_when_adding_maximum(): void { Carbon::setTestNow(Carbon::create(2020, 01, 01, 18)); @@ -333,7 +334,7 @@ public function test_oldest_safe_device_discarded_when_adding_maximum() $max_devices = config('laraguard.safe_devices.max_devices'); - for ($i = 0 ; $i < $max_devices ; ++$i) { + for ($i = 0 ; $i <= $max_devices ; ++$i) { Carbon::setTestNow(Carbon::create(2020, 01, 01, 18, 30, $i)); $this->user->addSafeDevice( @@ -348,7 +349,7 @@ public function test_oldest_safe_device_discarded_when_adding_maximum() $this->assertFalse($this->user->safeDevices()->contains('ip', $old_request_ip)); } - public function test_flushes_safe_devices() + public function test_flushes_safe_devices(): void { $max_devices = config('laraguard.safe_devices.max_devices') + 4; @@ -369,7 +370,7 @@ public function test_flushes_safe_devices() $this->assertEmpty($this->user->safeDevices()); } - public function test_is_safe_device_and_safe_with_other_ip() + public function test_is_safe_device_and_safe_with_other_ip(): void { $max_devices = config('laraguard.safe_devices.max_devices'); @@ -393,7 +394,7 @@ public function test_is_safe_device_and_safe_with_other_ip() $this->assertFalse($this->user->isNotSafeDevice($request)); } - public function test_not_safe_device_if_remember_code_doesnt_match() + public function test_not_safe_device_if_remember_code_doesnt_match(): void { $max_devices = config('laraguard.safe_devices.max_devices'); @@ -417,7 +418,7 @@ public function test_not_safe_device_if_remember_code_doesnt_match() $this->assertTrue($this->user->isNotSafeDevice($request)); } - public function test_not_safe_device_if_expired() + public function test_not_safe_device_if_expired(): void { $max_devices = config('laraguard.safe_devices.max_devices'); @@ -451,7 +452,7 @@ public function test_not_safe_device_if_expired() $this->assertFalse($this->user->isSafeDevice($request)); } - public function test_unique_code_works_only_one_time() + public function test_unique_code_works_only_one_time(): void { Carbon::setTestNow(Carbon::create(2020, 01, 01, 18, 30, 0)); @@ -468,7 +469,7 @@ public function test_unique_code_works_only_one_time() $this->assertFalse($this->user->validateTwoFactorCode($code)); } - public function test_unique_code_works_only_one_time_with_extended_window() + public function test_unique_code_works_only_one_time_with_extended_window(): void { $this->user->twoFactorAuth->window = 5; $this->user->twoFactorAuth->save(); @@ -488,7 +489,7 @@ public function test_unique_code_works_only_one_time_with_extended_window() $this->assertFalse($this->user->validateTwoFactorCode($new)); } - public function test_unique_code_works_only_one_time_in_extended_time() + public function test_unique_code_works_only_one_time_in_extended_time(): void { Carbon::setTestNow($now = Carbon::create(2020, 01, 01, 18, 30, 20)); From a7a5ea87afdbc31688c75fd2477e9f44a13f7f7d Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 01:56:39 -0300 Subject: [PATCH 06/22] Fixes test runs. --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5704a18..246db70 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: true matrix: - php: [8.0, 7.4] + php: [8.0] laravel: [8.*] dependency-version: [prefer-lowest, prefer-stable] include: From 60da629bb378d586a56753bcb6a9a4d336682654 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:11:52 -0300 Subject: [PATCH 07/22] Fixes upgrade class redeclared. Other minor fixes. --- src/Eloquent/HandlesCodes.php | 12 ++++-------- tests/Eloquent/UpgradeTest.php | 8 +++----- tests/LaraguardTest.php | 1 - 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Eloquent/HandlesCodes.php b/src/Eloquent/HandlesCodes.php index 963c0c6..3d7f239 100644 --- a/src/Eloquent/HandlesCodes.php +++ b/src/Eloquent/HandlesCodes.php @@ -79,7 +79,7 @@ public function validateCode(string $code, DateTimeInterface|int|string $at = 'n for ($i = 0; $i <= $window; ++$i) { if (hash_equals($this->makeCode($at, -$i), $code)) { - $this->setCodeHasUsed($code, $at); + $this->setCodeAsUsed($code, $at); return true; } } @@ -172,7 +172,7 @@ protected function getBinarySecret(): string * * @return int */ - protected function getTimestampFromPeriod(DatetimeInterface|int|string|null $at, int $period = 0): int + protected function getTimestampFromPeriod(DatetimeInterface|int|string|null $at, int $period): int { $periods = ($this->parseTimestamp($at) / $this->seconds) + $period; @@ -188,11 +188,7 @@ protected function getTimestampFromPeriod(DatetimeInterface|int|string|null $at, */ protected function parseTimestamp(DatetimeInterface|int|string $at): int { - if (!is_int($at)) { - $at = Carbon::parse($at)->getTimestamp(); - } - - return $at; + return is_int($at) ? $at : Carbon::parse($at)->getTimestamp(); } /** @@ -227,7 +223,7 @@ protected function codeHasBeenUsed(string $code): bool * * @return bool */ - protected function setCodeHasUsed(string $code, DateTimeInterface|int|string $at = 'now'): bool + protected function setCodeAsUsed(string $code, DateTimeInterface|int|string $at = 'now'): bool { // We will safely set the cache key for the whole lifetime plus window just to be safe. return $this->cache->set($this->cacheKey($code), true, diff --git a/tests/Eloquent/UpgradeTest.php b/tests/Eloquent/UpgradeTest.php index 88caace..c393582 100644 --- a/tests/Eloquent/UpgradeTest.php +++ b/tests/Eloquent/UpgradeTest.php @@ -20,7 +20,9 @@ protected function setUp(): void { parent::setUp(); - require_once __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; + if (!class_exists('UpgradeTwoFactorAuthenticationsTable')) { + require_once __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; + } } public function test_migrates_old_table_and_records(): void @@ -42,8 +44,6 @@ public function test_migrates_old_table_and_records(): void 'recovery_codes' => null, ]); - require __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; - (new \UpgradeTwoFactorAuthenticationsTable)->up(); static::assertSame($secret, TwoFactorAuthentication::find(1)->shared_secret); @@ -80,8 +80,6 @@ public function test_rollbacks_migration(): void 'recovery_codes' => null, ]); - require __DIR__ . '/../../database/migrations/2020_04_02_000000_upgrade_two_factor_authentications_table.php'; - (new \UpgradeTwoFactorAuthenticationsTable)->down(); static::assertSame($secret, DB::table('two_factor_authentications')->where('id', 1)->first()->shared_secret); diff --git a/tests/LaraguardTest.php b/tests/LaraguardTest.php index 430eab9..bee08d0 100644 --- a/tests/LaraguardTest.php +++ b/tests/LaraguardTest.php @@ -3,7 +3,6 @@ namespace Tests; use DarkGhostHunter\Laraguard\Laraguard; -use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\Request; From 2712548213209bc3cb4cd0588d93f98abc279588 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:16:07 -0300 Subject: [PATCH 08/22] Modified test now to static datetime. --- tests/LaraguardTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/LaraguardTest.php b/tests/LaraguardTest.php index bee08d0..d137ace 100644 --- a/tests/LaraguardTest.php +++ b/tests/LaraguardTest.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cookie; use Illuminate\Validation\ValidationException; @@ -31,7 +32,7 @@ protected function setUp() : void $this->afterApplicationCreated([$this, 'createTwoFactorUser']); $this->afterApplicationCreated(function (): void { app('config')->set('auth.providers.users.model', UserTwoFactorStub::class); - $this->travelTo(today()); + Carbon::setTestNow(today()); }); parent::setUp(); } From 75a6ee9e25f96154b9d6861236b4f23e1e1d3b4b Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:17:30 -0300 Subject: [PATCH 09/22] Modified minimum Laravel version for test run. --- .github/workflows/php.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 246db70..a2bb02f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -12,10 +12,10 @@ jobs: fail-fast: true matrix: php: [8.0] - laravel: [8.*] + laravel: [^8.39] dependency-version: [prefer-lowest, prefer-stable] include: - - laravel: 8.* + - laravel: ^8.39 testbench: 6.* name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} From 92689701870e347e08933e4a0baebad500048ddf Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:20:43 -0300 Subject: [PATCH 10/22] Updated testbench minimum version. --- .github/workflows/php.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a2bb02f..ebcf98d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,7 +16,7 @@ jobs: dependency-version: [prefer-lowest, prefer-stable] include: - laravel: ^8.39 - testbench: 6.* + testbench: ^6.20.1 name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} diff --git a/composer.json b/composer.json index 5313428..5340a5d 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "doctrine/dbal": "^3.1", "mockery/mockery": "^1.4", "orchestra/canvas": "^6.0", - "orchestra/testbench": "^6.0", + "orchestra/testbench": "^6.20.1", "phpunit/phpunit": "^9.3" }, "autoload": { From 01ecfba36fa5c20f574feeb53252f6a021e1564c Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:24:57 -0300 Subject: [PATCH 11/22] Updated testbench core minimum version. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5340a5d..2a65b1a 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,8 @@ "require-dev": { "doctrine/dbal": "^3.1", "mockery/mockery": "^1.4", - "orchestra/canvas": "^6.0", "orchestra/testbench": "^6.20.1", + "orchestra/testbench-core": "^6.24.1", "phpunit/phpunit": "^9.3" }, "autoload": { From 6c53dd44968980881cef8875597b87b632218376 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:40:05 -0300 Subject: [PATCH 12/22] Fixes code being treated as integer. --- composer.json | 3 +-- database/factories/TwoFactorAuthenticationFactory.php | 2 +- src/Eloquent/HandlesCodes.php | 6 +++--- src/Laraguard.php | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 2a65b1a..2b12f2c 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,7 @@ "require-dev": { "doctrine/dbal": "^3.1", "mockery/mockery": "^1.4", - "orchestra/testbench": "^6.20.1", - "orchestra/testbench-core": "^6.24.1", + "orchestra/testbench": "6.*", "phpunit/phpunit": "^9.3" }, "autoload": { diff --git a/database/factories/TwoFactorAuthenticationFactory.php b/database/factories/TwoFactorAuthenticationFactory.php index ee4079a..5c9dce1 100644 --- a/database/factories/TwoFactorAuthenticationFactory.php +++ b/database/factories/TwoFactorAuthenticationFactory.php @@ -34,7 +34,7 @@ public function definition(): array if ($enabled) { $array['recovery_codes'] = TwoFactorAuthentication::generateRecoveryCodes($amount, $length); - $array['recovery_codes_generated_at'] = $this->faker->dateTimeBetween('-1 years'); + $array['recovery_codes_generated_at'] = $this->faker->dateTimeBetween('-1 year'); } return $array; diff --git a/src/Eloquent/HandlesCodes.php b/src/Eloquent/HandlesCodes.php index 3d7f239..536ad26 100644 --- a/src/Eloquent/HandlesCodes.php +++ b/src/Eloquent/HandlesCodes.php @@ -221,12 +221,12 @@ protected function codeHasBeenUsed(string $code): bool * @param string $code * @param \DateTimeInterface|int|string $at * - * @return bool + * @return void */ - protected function setCodeAsUsed(string $code, DateTimeInterface|int|string $at = 'now'): bool + protected function setCodeAsUsed(string $code, DateTimeInterface|int|string $at = 'now'): void { // We will safely set the cache key for the whole lifetime plus window just to be safe. - return $this->cache->set($this->cacheKey($code), true, + $this->cache->set($this->cacheKey($code), true, Carbon::createFromTimestamp($this->getTimestampFromPeriod($at, $this->window + 1)) ); } diff --git a/src/Laraguard.php b/src/Laraguard.php index 5be2b57..5798a34 100644 --- a/src/Laraguard.php +++ b/src/Laraguard.php @@ -114,9 +114,9 @@ protected function requestHasCode(): bool /** * Returns the code from the request input. * - * @return int + * @return string */ - protected function getCode(): int + protected function getCode(): string { return $this->request->input($this->input); } From d5d9478d9965b87f10b268f5ff48a95cb9313efb Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:42:15 -0300 Subject: [PATCH 13/22] Updated PHPUnit version. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2b12f2c..6cf2254 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "doctrine/dbal": "^3.1", "mockery/mockery": "^1.4", "orchestra/testbench": "6.*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.5.9" }, "autoload": { "psr-4": { From a095df39ca55ad7bf673535f35b9f582a77f66c2 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 02:47:36 -0300 Subject: [PATCH 14/22] Slimmed logo. --- .gitattributes | 1 + README.md | 11 +++++------ laraguardlogo.png | Bin 37980 -> 0 bytes 3 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 laraguardlogo.png diff --git a/.gitattributes b/.gitattributes index bb6265e..66b4c28 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,4 @@ /.scrutinizer.yml export-ignore /tests export-ignore /.editorconfig export-ignore +/laraguardlogo.png export-ignore diff --git a/README.md b/README.md index 123fee4..359a4a0 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@

- +

- - -# Laraguard - [![Latest Version on Packagist](https://img.shields.io/packagist/v/darkghosthunter/laraguard.svg?style=flat-square)](https://packagist.org/packages/darkghosthunter/laraguard) [![License](https://poser.pugx.org/darkghosthunter/laraguard/license)](https://packagist.org/packages/darkghosthunter/laraguard) ![](https://img.shields.io/packagist/php-v/darkghosthunter/laraguard.svg) - ![](https://github.com/DarkGhostHunter/Laraguard/workflows/PHP%20Composer/badge.svg) +![](https://github.com/DarkGhostHunter/Laraguard/workflows/PHP%20Composer/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/DarkGhostHunter/Laraguard/badge.svg?branch=master)](https://coveralls.io/github/DarkGhostHunter/Laraguard?branch=master) + +# Laraguard + Two-Factor Authentication via TOTP for all your users out-of-the-box. This package _silently_ enables authentication using 6 digits codes, without Internet or external providers. diff --git a/laraguardlogo.png b/laraguardlogo.png deleted file mode 100644 index ab2e4af8c3786a90550e943c209d87ebe39bed38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37980 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 ZrVWth?AGL~FqHt{0S`h1GQLPW_a7-q;g$dZ From 728c99ddb3e71affccd5bb5ddd034bbc96f16b71 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:00:44 -0300 Subject: [PATCH 15/22] Fixed authentication for non-2fa users. --- src/Laraguard.php | 28 +++++++++++++++++----------- tests/LaraguardTest.php | 30 +++++++++++++++++++----------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/Laraguard.php b/src/Laraguard.php index 5798a34..141a5a8 100644 --- a/src/Laraguard.php +++ b/src/Laraguard.php @@ -59,31 +59,37 @@ public static function hasCodeOrFails(string $input = '2fa_code', string $messag */ public function __construct(protected Repository $config, protected Request $request, protected string $input) { + // } /** * Check if the user uses TOTP and has a valid code. * + * If the user does not use TOTP, no checks will be done. + * * @param \Illuminate\Contracts\Auth\Authenticatable $user * * @return bool */ public function validate(Authenticatable $user): bool { - if ($user instanceof TwoFactorAuthenticatable && $user->hasTwoFactorEnabled()) { - // If safe devices are enabled, and this is a safe device, bypass. - if ($this->isSafeDevicesEnabled() && $user->isSafeDevice($this->request)) { - return true; - } + // If the user does not use 2FA or is not enabled, don't check. + if (! $user instanceof TwoFactorAuthenticatable || ! $user->hasTwoFactorEnabled()) { + return true; + } - // If the code is valid, save the device if it's enabled. - if ($this->requestHasCode() && $user->validateTwoFactorCode($this->getCode())) { - if ($this->isSafeDevicesEnabled() && $this->wantsToAddDevice()) { - $user->addSafeDevice($this->request); - } + // If safe devices are enabled, and this is a safe device, bypass. + if ($this->isSafeDevicesEnabled() && $user->isSafeDevice($this->request)) { + return true; + } - return true; + // If the code is valid, return true after it tries to save the device. + if ($this->requestHasCode() && $user->validateTwoFactorCode($this->getCode())) { + if ($this->isSafeDevicesEnabled() && $this->wantsToAddDevice()) { + $user->addSafeDevice($this->request); } + + return true; } return false; diff --git a/tests/LaraguardTest.php b/tests/LaraguardTest.php index d137ace..4aa8182 100644 --- a/tests/LaraguardTest.php +++ b/tests/LaraguardTest.php @@ -93,24 +93,32 @@ public function test_doesnt_authenticates_with_invalid_code(): void static::assertFalse(Auth::attemptWhen($credentials, Laraguard::hasCode())); } - public function test_non_two_factor_user_doesnt_authenticate(): void + public function test_non_two_factor_user_bypasses_checks(): void { - $user = UserStub::create([ - 'name' => 'bar', - 'email' => 'bar@test.com', - 'password' => UserStub::PASSWORD_SECRET, - ]); + config()->set('auth.providers.users.model', UserStub::class); $credentials = [ - 'email' => $user->email, + 'email' => $this->user->email, 'password' => 'secret' ]; - $this->instance('request', Request::create('test', 'POST', [ - '2fa_code' => $this->user->makeTwoFactorCode() - ])); + $this->instance('request', Request::create('test', 'POST')); - static::assertFalse(Auth::attemptWhen($credentials, Laraguard::hasCode())); + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); + } + + public function test_user_without_2fa_enabled_bypasses_check(): void + { + $credentials = [ + 'email' => $this->user->email, + 'password' => 'secret' + ]; + + $this->user->disableTwoFactorAuth(); + + $this->instance('request', Request::create('test', 'POST')); + + static::assertTrue(Auth::attemptWhen($credentials, Laraguard::hasCode())); } public function test_validation_exception_when_code_invalid(): void From dad7408c5d3d68b4bd5e3b35ec229f5cf6905900 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:32:40 -0300 Subject: [PATCH 16/22] Changed middleware name for a better descriptive one. --- src/LaraguardServiceProvider.php | 2 +- tests/Http/Middleware/RequireTwoFactorEnabledTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LaraguardServiceProvider.php b/src/LaraguardServiceProvider.php index ee9beb5..2e9066a 100644 --- a/src/LaraguardServiceProvider.php +++ b/src/LaraguardServiceProvider.php @@ -57,7 +57,7 @@ public function boot(Repository $config, Router $router, Factory $validator): vo */ protected function registerMiddleware(Router $router): void { - $router->aliasMiddleware('2fa.require', Http\Middleware\RequireTwoFactorEnabled::class); + $router->aliasMiddleware('2fa.enabled', Http\Middleware\RequireTwoFactorEnabled::class); $router->aliasMiddleware('2fa.confirm', Http\Middleware\ConfirmTwoFactorCode::class); } diff --git a/tests/Http/Middleware/RequireTwoFactorEnabledTest.php b/tests/Http/Middleware/RequireTwoFactorEnabledTest.php index a323e90..785ce78 100644 --- a/tests/Http/Middleware/RequireTwoFactorEnabledTest.php +++ b/tests/Http/Middleware/RequireTwoFactorEnabledTest.php @@ -28,7 +28,7 @@ protected function setUp() : void })->name('login'); $this->app['router']->get('test', function () { return 'ok'; - })->middleware('web', 'auth', '2fa.require'); + })->middleware('web', 'auth', '2fa.enabled'); $this->app['router']->get('notice', function () { return '2fa.notice'; })->middleware('web', 'auth')->name('2fa.notice'); From 22e15ee080cd77d7d3267bd2e5c2f2ccbb9de337 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:33:21 -0300 Subject: [PATCH 17/22] Tidied up README. --- README.md | 90 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 359a4a0..2f2fcb7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Two-Factor Authentication via TOTP for all your users out-of-the-box. -This package _silently_ enables authentication using 6 digits codes, without Internet or external providers. +This package enables authentication using 6 digits codes. No need for external APIs. ## Requirements @@ -30,11 +30,11 @@ That's it. ### How this works -This package adds a **Contract** to detect in a **per-user basis** if, after the credentials are deemed valid, should use Two-Factor Authentication as a second layer of authentication. +This package adds a **Contract** to detect if, after the credentials are deemed valid, should use Two-Factor Authentication as a second layer of authentication. It includes a custom **view** and a **callback** to handle the Two-Factor authentication itself during login attempts. -This package was made to be the less invasive possible, but you can go full manual if you want. +With works without middleware or new guards, but you can go full manual if you want. ## Usage @@ -45,6 +45,8 @@ First, create the `two_factor_authentications` table by publishing the migration This will create a table to handle the Two-Factor Authentication information for each model you want to attach to 2FA. +> If you're [upgrading from 3.0](UPGRADE.md), you should run a special migration. + Add the `TwoFactorAuthenticatable` _contract_ and the `TwoFactorAuthentication` trait to the User model, or any other model you want to make Two-Factor Authentication available. ```php @@ -75,6 +77,8 @@ To enable Two-Factor Authentication successfully, the User must sync the Shared To start, generate the needed data using the `createTwoFactorAuth()` method. Once you do, you can show the Shared Secret to the User as a string or QR Code (encoded as SVG) in your view. ```php +use Illuminate\Http\Request; + public function prepareTwoFactor(Request $request) { $secret = $request->user()->createTwoFactorAuth(); @@ -92,11 +96,15 @@ public function prepareTwoFactor(Request $request) Then, the User must confirm the Shared Secret with a Code generated by their Authenticator app. The `confirmTwoFactorAuth()` method will automatically enable it if the code is valid. ```php +use Illuminate\Http\Request; + public function confirmTwoFactor(Request $request) { - $activated = $request->user()->confirmTwoFactorAuth( - $request->input('2fa_code') - ); + $request->validate([ + 'code' => 'required|numeric' + ]); + + $activated = $request->user()->confirmTwoFactorAuth($request->code); // ... } @@ -111,6 +119,8 @@ Recovery Codes are automatically generated each time the Two-Factor Authenticati You can show them using `getRecoveryCodes()`. ```php +use Illuminate\Http\Request; + public function confirmTwoFactor(Request $request) { if ($request->user()->confirmTwoFactorAuth($request->code)) { @@ -123,11 +133,13 @@ public function confirmTwoFactor(Request $request) You're free on how to show these codes to the User, but **ensure** you show them one time after a successfully enabling Two-Factor Authentication, and ask him to print them somewhere. -> These Recovery Codes are handled automatically when the User validates one. If it's a recovery code, the package will use and mark it as invalid. +> These Recovery Codes are handled automatically when the User sends it instead of a TOTP code. If it's a recovery code, the package will use and mark it as invalid. The User can generate a fresh batch of codes using `generateRecoveryCodes()`, which automatically invalidates the previous batch. ```php +use Illuminate\Http\Request; + public function showRecoveryCodes(Request $request) { return $request->user()->generateRecoveryCodes(); @@ -138,7 +150,7 @@ public function showRecoveryCodes(Request $request) ### Logging in -To login, the user must issue a TOTP code along their credentials. Simply use `attemptWhen()` with Laraguard, which will automatically do the checks for you. By default, it checks for the `2fa_code` input name, but you can issue your own as parameter. +To login, the user must issue a TOTP code along their credentials. Simply use `attemptWhen()` with Laraguard, which will automatically do the checks for you. By default, it checks for the `2fa_code` input name, but you can issue your own. ```php use Illuminate\Http\Request; @@ -163,9 +175,9 @@ Behind the scenes, once the User is retrieved and validated from your guard of c #### Separating the TOTP requirement -In some occasions you will want to tell the user the authentication failed because of an invalid TOTP code, instead of just denying the login altogether. +In some occasions you will want to tell the user the authentication failed not because the credentials were incorrect, but because of the TOTP code was invalid. -You can use the `hasCodeOrFails()` method that does the same, but throws a validation exception, which is handled gracefully by the framework. It even accepts a custom message in case of failure, otherwise a default translation will be used. +You can use the `hasCodeOrFails()` method that does the same, but throws a validation exception, which is handled gracefully by the framework. It even accepts a custom message in case of failure, otherwise a default [translation](#translations) line will be used. ```php use Illuminate\Http\Request; @@ -176,20 +188,18 @@ public function login(Request $request) { // ... - $attempt = Auth::attemptWhen( - $request->only('email', 'password'), - Laraguard::hasCodeOrFails(), - $request->filled('remember') - ); + $credentials = $request->only('email', 'password'); - if ($attempt) { - return redirect()->home(); + if (Auth::attemptWhen($credentials, Laraguard::hasCodeOrFails(), $request->filled('remember'))) { + return redirect()->home(); } return back()->withErrors(['email', 'Authentication failed!']); } ``` +Since it's a `ValidationException`, you can catch it and do more complex things, like those fancy views that hold the login procedure until the correct TOTP code is issued. + ### Deactivation You can deactivate Two-Factor Authentication for a given User using the `disableTwoFactorAuth()` method. This will automatically invalidate the authentication data, allowing the User to log in with just his credentials. @@ -216,43 +226,45 @@ The following events are fired in addition to the default Authentication events. ## Middleware -Laraguard comes with two middleware for your routes: `2fa.require` and `2fa.confirm`. +Laraguard comes with two middleware for your routes: `2fa.enabled` and `2fa.confirm`. > To avoid unexpected results, middleware only act on your users models with `TwoFactorAuthenticatable`. If a user model doesn't implement it, the middleware bypass any 2FA logic. ### Require 2FA -If you need to ensure the User has Two-Factor Authentication enabled before entering a given route, you can use the `2fa.require` middleware. This middleware doesn't asks for codes, only checks if is enabled. +If you need to ensure the User has Two-Factor Authentication enabled before entering a given route, you can use the `2fa.enabled` middleware. This middleware doesn't asks for codes, and doesn't checks for not-2FA-compatible users. It only checks if 2FA is enabled. ```php Route::get('system/settings') ->uses('SystemSettingsController@show') - ->middleware('2fa.require'); + ->middleware('2fa.enabled'); ``` This middleware works much like Laravel's `verified` middleware: if the User has not enabled Two-Factor Authentication, it will be redirected to a route name containing the warning, which is `2fa.notice` by default. -You can implement the view easily with the one included in this package: +You can implement the view easily with the one included in this package, with a URL to point the user to enable 2FA: ```php use Illuminate\Support\Facades\Route; -Route::view('2fa-required', 'laraguard::notice')->name('2fa.notice'); +Route::view('2fa-required', 'laraguard::notice', [ + 'url' => url('settings/2fa') +])->name('2fa.notice'); ``` -Alternatively, you can just redirect the user to where he can enable the configuration. +Alternatively, you can just redirect the user to the named route where he can enable 2FA. ```php use Illuminate\Support\Facades\Route Route::get('system/settings') ->uses('SystemSettingsController@show') - ->middleware('2fa.require:account.settings.2fa'); + ->middleware('2fa.enabled:settings.2fa'); ``` ### Confirm 2FA -Much like the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), you can also ask the user to confirm an action using `2fa.confirm`, if it has Two-Factor Authentication enabled. +Much like the [`password.confirm` middleware](https://laravel.com/docs/authentication#password-confirmation), you can also ask the user to confirm an action using `2fa.confirm`, if it has Two-Factor Authentication enabled. ```php Route::get('api/token') @@ -268,9 +280,7 @@ Route::get('api/token') ->middleware('2fa.require', '2fa.confirm'); ``` -Laraguard automatically uses the [`Confirm2FACodeController`](src/Http/Controllers/Confirm2FACodeController.php) to handle the form view and the code confirmation for you. - -Alternatively, [you can point your own controller actions](#confirmation-middleware) to handle the form view and confirmation. Better yet, you can start with the [`Confirms2FACode`](src/Http/Controllers/Confirms2FACode.php) trait to avoid reinventing the wheel. +Laraguard automatically uses the [`Confirm2FACodeController`](src/Http/Controllers/Confirm2FACodeController.php) to handle the form view asking for the 2FA code for you. [You can point your own controller actions](#confirmation-middleware) to handle the form view and confirmation. Better yet, you can start with the [`Confirms2FACode`](src/Http/Controllers/Confirms2FACode.php) trait to avoid reinventing the wheel. ## Validation @@ -361,11 +371,9 @@ return [ ]; ``` -This is the model where the data for Two-Factor Authentication is saved, like the shared secret and recovery codes, and associated to the User model. - -You can change this model for your own if you wish. +This is the model where the data for Two-Factor Authentication is saved, like the shared secret and recovery codes, and associated to the models implementing `TwoFactorAuthenticatable`. -> If you change it for your own Model, ensure it implements the `TwoFactorTotp` contract. +You can change this model for your own if you wish, as long it implements the `TwoFactorTotp` contract. ### Cache Store @@ -378,7 +386,7 @@ return [ ]; ``` -[RFC 6238](https://tools.ietf.org/html/rfc6238#section-5) states that one-time passwords shouldn't be able to be usable again, even if inside the time window. For this, we need to use the Cache to save the code for a given period. +[RFC 6238](https://tools.ietf.org/html/rfc6238#section-5) states that one-time passwords shouldn't be able to be usable more than once, even if is still inside the time window. For this, we need to use the Cache to save the code for a given period. You can change the store to use, which it's the default used by your application, and the prefix to use as cache keys, in case of collisions. @@ -394,7 +402,7 @@ return [ ]; ``` -You can disable the generation and checking of Recovery Codes. If you do, ensure Users can authenticate by other means, like sending an email with a link to a signed URL that logs him in and disables Two-Factor Authentication, or SMS. +Recovery codes handling are enabled by default, but you can disable it. If you do, ensure Users can authenticate by other means, like sending an email with a link to a signed URL that logs him in and disables Two-Factor Authentication, or SMS. The number and length of codes generated is configurable. 10 Codes of 8 random characters are enough for most authentication scenarios. @@ -410,9 +418,9 @@ return [ ]; ``` -Enabling this option will allow the application to "remember" a device using a cookie, allowing it to bypass Two-Factor Authentication once a code is verified in that device. When the User logs in again in that device, it won't be prompted for a 2FA Code again. +Enabling this option will allow the application to "remember" a device using a cookie, allowing it to bypass Two-Factor Authentication once a code is verified in that device. When the User logs in again in that device, it won't be prompted for a 2FA Code again. -There is a limit of devices that can be saved. New devices will displace the oldest devices registered. Devices are considered no longer "safe" until a set amount of days. +There is a limit of devices that can be saved, but usually three is enough (phone, tablet and PC). New devices will displace the oldest devices registered. Devices are considered no longer "safe" until a set amount of days. You can change the maximum number of devices saved and the amount of days of validity once they're registered. More devices and more expiration days will make the Two-Factor Authentication less secure. @@ -432,9 +440,11 @@ return [ If the `view` or `action` are not `null`, the `2fa/notice` and `2fa/confirm` routes will be registered to handle 2FA code notice and confirmation for the [`2fa.confirm` middleware](#confirm-2fa). If you disable it, you will have to register the routes and controller actions yourself. -This array also sets by how much to "remember" the 2FA Code confirmation, and the actions used to show the view to confirm the 2FA Code with also the action to handle the confirmation. +This array sets: -You may want to change these, specially if you want your own view to show the confirmation form. +- By how much to "remember" the 2FA Code confirmation. +- The action that shows the 2FA Code form. +- The action that receives the 2FA Code and validates it. ### Secret length @@ -446,7 +456,7 @@ return [ This controls the length (in bytes) used to create the Shared Secret. While a 160-bit shared secret is enough, you can tighten or loosen the secret length to your liking. -It's recommended to use 128-bit or 160-bit because some Authenticator apps may have some problems with other non-RFC-recommended lengths. +It's recommended to use 128-bit or 160-bit because some Authenticator apps may have problems with non-RFC-recommended lengths. ### TOTP Configuration @@ -501,4 +511,4 @@ If you discover any security related issues, please email darkghosthunter@gmail. The MIT License (MIT). Please see [License File](LICENSE.md) for more information. -Laravel is a Trademark of Taylor Otwell. Copyright © 2011-2020 Laravel LLC. +Laravel is a Trademark of Taylor Otwell. Copyright © 2011-2021 Laravel LLC. From d2c813383d1fda952103fa31e10b7d90e9e34550 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:33:39 -0300 Subject: [PATCH 18/22] Excluded Jetbrain files from git. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 808f8c5..3929e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build composer.lock docs vendor -coverage \ No newline at end of file +coverage +.idea From 08e47a1b55cbd35d725b6627f0e971d176949a28 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:33:55 -0300 Subject: [PATCH 19/22] Added smaller logo --- logo.jpg | Bin 0 -> 23346 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 logo.jpg diff --git a/logo.jpg b/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b9a5901aa13f7683eb0d3cbc72571ea6fc1522f2 GIT binary patch literal 23346 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 L?Rg&#|Gx Date: Sun, 5 Sep 2021 03:34:15 -0300 Subject: [PATCH 20/22] Added phpunit cache files to exclusion. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3929e4b..0054f22 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ docs vendor coverage .idea +/.phpunit.result.cache +/phpunit.xml.dist.bak From 41400e7029eeaca18194ad21fbf952c2289e92d9 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:35:04 -0300 Subject: [PATCH 21/22] Fixed logo extension. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f2fcb7..854f7bc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

[![Latest Version on Packagist](https://img.shields.io/packagist/v/darkghosthunter/laraguard.svg?style=flat-square)](https://packagist.org/packages/darkghosthunter/laraguard) [![License](https://poser.pugx.org/darkghosthunter/laraguard/license)](https://packagist.org/packages/darkghosthunter/laraguard) ![](https://img.shields.io/packagist/php-v/darkghosthunter/laraguard.svg) From 52971472e9071973e5889e128c7a52bebdd8f000 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 5 Sep 2021 03:35:27 -0300 Subject: [PATCH 22/22] Added newline for badges. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 854f7bc..5df05d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

+ [![Latest Version on Packagist](https://img.shields.io/packagist/v/darkghosthunter/laraguard.svg?style=flat-square)](https://packagist.org/packages/darkghosthunter/laraguard) [![License](https://poser.pugx.org/darkghosthunter/laraguard/license)](https://packagist.org/packages/darkghosthunter/laraguard) ![](https://img.shields.io/packagist/php-v/darkghosthunter/laraguard.svg) ![](https://github.com/DarkGhostHunter/Laraguard/workflows/PHP%20Composer/badge.svg)