diff --git a/.github/workflows/composer-coveralls.json b/.github/workflows/composer-coveralls.json deleted file mode 100644 index 7df94f2..0000000 --- a/.github/workflows/composer-coveralls.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "require": { - "cedx/coveralls": "*" - }, - "config": { - "vendor-dir": "vendor-coveralls" - } -} diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ed4703f..373ece8 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -18,9 +18,9 @@ jobs: - laravel: 7.* testbench: 5.* - laravel: 6.* - testbench: 4.* + testbench: ^4.1 - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} steps: - name: Checkout @@ -33,14 +33,10 @@ jobs: extensions: mbstring, intl coverage: xdebug - - name: Get Composer Cache Directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Cache dependencies uses: actions/cache@v1 with: - path: ${{ steps.composer-cache.outputs.dir }} + path: ~/.composer/cache/files key: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} restore-keys: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- @@ -49,10 +45,10 @@ jobs: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-progress --no-update composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress --no-suggest - - name: Run test suite + - name: Run Tests run: composer run-script test - - name: Upload coverage results to Coveralls + - name: Upload Coverage to Coveralls env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_SERVICE_NAME: github diff --git a/README.md b/README.md index 84c058e..bbfdff6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This package _silently_ enables authentication using 6 digits codes, without Int ## Requirements -* Laravel [6.15](https://blog.laravel.com/laravel-v6-15-0-released) or later. +* Laravel [6.15](https://blog.laravel.com/laravel-v6-15-0-released) or Laravel 7. * PHP 7.2+ ## Table of Contents @@ -30,6 +30,7 @@ This package _silently_ enables authentication using 6 digits codes, without Int * [Protecting the Login](#protecting-the-login) * [Configuration](#configuration) + [Listener](#listener) + + [Eloquent Model](#eloquent-model) + [Input name](#input-name) + [Cache Store](#cache-store) + [Recovery](#recovery) @@ -52,13 +53,21 @@ That's it. This packages adds a **Contract** to detect in a **per-user basis** if it should use Two Factor Authentication. It includes a custom **view** and a **listener** to handle the Two Factor authentication itself during login attempts. -It is not invasive, but you can go full manual if you want. +This package was made to be the less invasive possible, but you can go full manual if you want. ## Usage -First, run the migrations. This will create a table to handle the Two Factor Authentication information for each model you set. +First, publish the migration with: - php artisan migrate:run + php artisan vendor:publish --provider="DarkGhostHunter\Laraguard\LaraguardServiceProvider" --tag="migrations" + +> The default migration assumes you are using integers for your user model IDs. If you are using UUIDs, or some other format, adjust the format of the morphs `authenticatable` fields in the published migration before continuing. + +After publishing the migration, you can create the `two_factor_authentications` table by running the migration: + + php artisan migrate + +This will create a table to handle the Two Factor Authentication information for each model you set. Add the `TwoFactorAuthenticatable` _contract_ and the `TwoFactorAuthentication` trait to the User model, or any other model you want to make Two Factor Authentication available. @@ -243,7 +252,8 @@ You will receive the authentication view in `resources/views/vendor/laraguard/au ```php return [ - 'listener' => true, + 'listener' => \DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth::class, + 'model' => \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication::class, 'input' => '2fa_code', 'cache' => [ 'store' => null, @@ -273,13 +283,29 @@ return [ ```php return [ - 'listener' => true, + '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 +return [ + 'model' => \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication::class, ]; ``` -This package works by hooking up the `ForcesTwoFactorAuth` listener to the `Attempting` and `Validated` events as a fallback. +This is the model where the the Two Factor Authentication data, like the shared secret and recovery codes, are saved and associated to the User model. + +You can change this model for your own if you wish. -This may work wonders out-of-the-box, but if you want tighter control on how and when prompt for Two Factor Authentication, you can disable it. For example, to create your own 2FA Guard or greatly modify the Login Controller. +> If you change it for your own Model, ensure it implements the `TwoFactorTotp` contract. ### Input name diff --git a/composer.json b/composer.json index f50f18b..bdcb611 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,8 @@ "2fa" ], "homepage": "https://github.com/darkghosthunter/laraguard", + "minimum-stability": "dev", + "prefer-stable": true, "license": "MIT", "type": "library", "authors": [ @@ -21,14 +23,14 @@ "require": { "php": "^7.2.15", "ext-json": "*", - "bacon/bacon-qr-code": "2.*", - "paragonie/constant_time_encoding": "2.*", + "bacon/bacon-qr-code": "^2.0", + "paragonie/constant_time_encoding": "^2.0", "illuminate/support": "^6.15||^7.0", "illuminate/auth": "^6.15||^7.0" }, "require-dev": { "orchestra/testbench": "^4.0||^5.0", - "orchestra/canvas": "^4.0||5.0", + "orchestra/canvas": "^4.0||^5.0", "phpunit/phpunit": "^8.0" }, "autoload": { diff --git a/config/laraguard.php b/config/laraguard.php index 8c25c72..a5ab2f5 100644 --- a/config/laraguard.php +++ b/config/laraguard.php @@ -7,13 +7,26 @@ | Listener hook |-------------------------------------------------------------------------- | - | If the Listener is enabled, Laraguard will automatically hook into the - | "Attempting" event and magically ask for Two Factor Authentication if - | is necessary. Disable this to use your own 2FA authentication logic. + | If a Listener class is present, Laraguard will hook into the Attempting + | and Validated events and check if it needs Two Factor Authentication. + | Set this to false or null to use your own 2FA logic without events. | */ - 'listener' => true, + 'listener' => \DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth::class, + + /* + |-------------------------------------------------------------------------- + | TwoFactorAuthentication Model + |-------------------------------------------------------------------------- + | + | When using the "TwoFactorAuthentication" trait from this package, we need + | to know which Eloquent model should be used to retrieve your two factor + | authentication records. You can use your own for more advanced logic. + | + */ + + 'model' => \DarkGhostHunter\Laraguard\Eloquent\TwoFactorAuthentication::class, /* |-------------------------------------------------------------------------- @@ -83,9 +96,9 @@ | Secret Length |-------------------------------------------------------------------------- | - | Using a shared secret with a length of 160-bit (as recommended per RFC - | 4226) is recommended, but you may want to tighten or loose the secret - | length. The RFC 4226 standard allows down to 128-bit shared secrets. + | The package uses a shared secret length of 160-bit, as recommended by the + | RFC 4226. This makes it compatible with most 2FA apps. You can change it + | freely but consider the standard allows shared secrets down to 128-bit. | */ diff --git a/database/migrations/2020_01_01_000000_create_two_factor_authentications_table.php b/database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php similarity index 100% rename from database/migrations/2020_01_01_000000_create_two_factor_authentications_table.php rename to database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a6abd70..987fb11 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,7 +20,6 @@ - diff --git a/src/Contracts/TwoFactorListener.php b/src/Contracts/TwoFactorListener.php new file mode 100644 index 0000000..dc87cd7 --- /dev/null +++ b/src/Contracts/TwoFactorListener.php @@ -0,0 +1,25 @@ +registerMiddleware($router); $this->loadViewsFrom(__DIR__ . '/../resources/views', 'laraguard'); - $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->loadFactoriesFrom(__DIR__ . '/../database/factories'); if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../config/laraguard.php' => config_path('laraguard.php'), - ], 'config'); - - $this->publishes([ - __DIR__ . '/../resources/views' => resource_path('views/vendor/laraguard'), - ], 'views'); + $this->publishFiles(); } } @@ -66,18 +60,43 @@ protected function registerMiddleware(Router $router) */ protected function registerListener(Repository $config, Dispatcher $dispatcher) { - if (! $config['laraguard.listener']) { + if (! $listener = $config['laraguard.listener']) { return; } - $this->app->singleton(Listeners\EnforceTwoFactorAuth::class, function ($app) { - return new Listeners\EnforceTwoFactorAuth($app['config'], $app['request']); + $this->app->singleton(Contracts\TwoFactorListener::class, function ($app) use ($listener) { + return new $listener($app['config'], $app['request']); }); - $dispatcher->listen(Attempting::class, - 'DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth@saveCredentials' - ); - $dispatcher->listen(Validated::class, - 'DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth@checkTwoFactor' - ); + + $dispatcher->listen(Attempting::class, Contracts\TwoFactorListener::class . '@saveCredentials'); + $dispatcher->listen(Validated::class, Contracts\TwoFactorListener::class . '@checkTwoFactor'); + } + + /** + * Publish config, view and migrations files. + * + * @return void + */ + protected function publishFiles() + { + $this->publishes([ + __DIR__ . '/../config/laraguard.php' => config_path('laraguard.php'), + ], 'config'); + + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/laraguard'), + ], 'views'); + + // 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')) { + $timestamp = now()->format('Y_m_d_His'); + + $this->publishes([ + __DIR__ . + '/../database/migrations/2020_04_02_000000_create_two_factor_authentications_table.php' => database_path("/migrations/{$timestamp}_create_two_factor_authentications_table.php"), + ], 'migrations'); + } } } diff --git a/src/Listeners/EnforceTwoFactorAuth.php b/src/Listeners/EnforceTwoFactorAuth.php index 12916f2..4e66777 100644 --- a/src/Listeners/EnforceTwoFactorAuth.php +++ b/src/Listeners/EnforceTwoFactorAuth.php @@ -6,9 +6,10 @@ use Illuminate\Auth\Events\Validated; use Illuminate\Auth\Events\Attempting; use Illuminate\Contracts\Config\Repository; +use DarkGhostHunter\Laraguard\Contracts\TwoFactorListener; use DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable; -class EnforceTwoFactorAuth +class EnforceTwoFactorAuth implements TwoFactorListener { use ChecksTwoFactorCode; @@ -61,7 +62,7 @@ public function __construct(Repository $config, Request $request) } /** - * Saves the Credentials for the User. + * Saves the credentials temporarily into the class instance. * * @param \Illuminate\Auth\Events\Attempting $event * @return void diff --git a/src/TwoFactorAuthentication.php b/src/TwoFactorAuthentication.php index badfbe5..37adf31 100644 --- a/src/TwoFactorAuthentication.php +++ b/src/TwoFactorAuthentication.php @@ -28,7 +28,7 @@ public function initializeTwoFactorAuthentication() */ public function twoFactorAuth() { - return $this->morphOne(Eloquent\TwoFactorAuthentication::class, 'authenticatable') + return $this->morphOne(config('laraguard.model'), 'authenticatable') ->withDefault(config('laraguard.totp')); } @@ -184,7 +184,7 @@ public function generateRecoveryCodes() : Collection { [$enabled, $amount, $length] = array_values(config('laraguard.recovery')); - $this->twoFactorAuth->recovery_codes = Eloquent\TwoFactorAuthentication::generateRecoveryCodes($amount, $length); + $this->twoFactorAuth->recovery_codes = config('laraguard.model')::generateRecoveryCodes($amount, $length); $this->twoFactorAuth->recovery_codes_generated_at = now(); $this->twoFactorAuth->save(); @@ -248,7 +248,7 @@ public function addSafeDevice(Request $request) : string */ protected function generateTwoFactorRemember() { - return Eloquent\TwoFactorAuthentication::generateDefaultTwoFactorRemember(); + return config('laraguard.model')::generateDefaultTwoFactorRemember(); } /** diff --git a/tests/Eloquent/TwoFactorAuthenticationTest.php b/tests/Eloquent/TwoFactorAuthenticationTest.php index 5a756ed..37c22ce 100644 --- a/tests/Eloquent/TwoFactorAuthenticationTest.php +++ b/tests/Eloquent/TwoFactorAuthenticationTest.php @@ -3,6 +3,7 @@ namespace Tests\Eloquent; use Carbon\Carbon; +use Tests\RunsPublishableMigrations; use Tests\RegistersPackage; use Orchestra\Testbench\TestCase; use ParagonIE\ConstantTime\Base32; @@ -16,12 +17,14 @@ class TwoFactorAuthenticationTest extends TestCase { use RegistersPackage; use DatabaseMigrations; + use RunsPublishableMigrations; protected $tfa; protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); parent::setUp(); } diff --git a/tests/Events/EventsTest.php b/tests/Events/EventsTest.php index 6096761..ad61f93 100644 --- a/tests/Events/EventsTest.php +++ b/tests/Events/EventsTest.php @@ -2,6 +2,7 @@ namespace Tests\Events; +use Tests\RunsPublishableMigrations; use Tests\RegistersPackage; use Illuminate\Support\Str; use Tests\CreatesTwoFactorUser; @@ -18,11 +19,13 @@ class EventsTest extends TestCase { use RegistersPackage; use DatabaseMigrations; + use RunsPublishableMigrations; use CreatesTwoFactorUser; protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); $this->afterApplicationCreated([$this, 'createTwoFactorUser']); parent::setUp(); diff --git a/tests/Http/Middleware/EnsureTwoFactorEnabledTest.php b/tests/Http/Middleware/EnsureTwoFactorEnabledTest.php index e40e171..fd13d20 100644 --- a/tests/Http/Middleware/EnsureTwoFactorEnabledTest.php +++ b/tests/Http/Middleware/EnsureTwoFactorEnabledTest.php @@ -2,6 +2,7 @@ namespace Tests\Http\Middleware; +use Tests\RunsPublishableMigrations; use Tests\Stubs\UserStub; use Tests\RegistersPackage; use Tests\CreatesTwoFactorUser; @@ -12,11 +13,13 @@ class EnsureTwoFactorEnabledTest extends TestCase { use RegistersPackage; use DatabaseMigrations; + use RunsPublishableMigrations; use CreatesTwoFactorUser; protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); $this->afterApplicationCreated([$this, 'createTwoFactorUser']); $this->afterApplicationCreated(function () { diff --git a/tests/Listeners/ForcesTwoFactorAuthTest.php b/tests/Listeners/ForcesTwoFactorAuthTest.php index 5e1518e..68c06f2 100644 --- a/tests/Listeners/ForcesTwoFactorAuthTest.php +++ b/tests/Listeners/ForcesTwoFactorAuthTest.php @@ -10,14 +10,16 @@ use Tests\CreatesTwoFactorUser; use Orchestra\Testbench\TestCase; use Tests\Stubs\UserTwoFactorStub; +use Tests\RunsPublishableMigrations; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\DatabaseMigrations; -use DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth; +use DarkGhostHunter\Laraguard\Contracts\TwoFactorListener; class ForcesTwoFactorAuthTest extends TestCase { use RegistersPackage; use DatabaseMigrations; + use RunsPublishableMigrations; use CreatesTwoFactorUser; use RegistersLoginRoute; use WithFaker; @@ -25,6 +27,7 @@ class ForcesTwoFactorAuthTest extends TestCase protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); $this->afterApplicationCreated([$this, 'createTwoFactorUser']); $this->afterApplicationCreated([$this, 'registerLoginRoute']); parent::setUp(); @@ -398,7 +401,7 @@ public function test_auth_requests_receives_code_and_saves_device() $this->assertCount(1, $this->user->twoFactorAuth->safe_devices); - $this->app->forgetInstance(EnforceTwoFactorAuth::class); + $this->app->forgetInstance(TwoFactorListener::class); $code = $this->user->generateRecoveryCodes()->first()['code']; diff --git a/tests/Listeners/ListenerNotRegisteredTest.php b/tests/Listeners/ListenerNotRegisteredTest.php index 8e5ec72..ef8ac85 100644 --- a/tests/Listeners/ListenerNotRegisteredTest.php +++ b/tests/Listeners/ListenerNotRegisteredTest.php @@ -2,6 +2,7 @@ namespace Tests\Listeners; +use Tests\RunsPublishableMigrations; use Tests\RegistersPackage; use Tests\RegistersLoginRoute; use Tests\CreatesTwoFactorUser; @@ -12,6 +13,7 @@ class ListenerNotRegisteredTest extends TestCase { use DatabaseMigrations; + use RunsPublishableMigrations; use RegistersPackage; use CreatesTwoFactorUser; use RegistersLoginRoute; @@ -19,6 +21,7 @@ class ListenerNotRegisteredTest extends TestCase protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); $this->afterApplicationCreated([$this, 'registerLoginRoute']); $this->afterApplicationCreated([$this, 'createTwoFactorUser']); parent::setUp(); diff --git a/tests/RunsPublishableMigrations.php b/tests/RunsPublishableMigrations.php new file mode 100644 index 0000000..09896f8 --- /dev/null +++ b/tests/RunsPublishableMigrations.php @@ -0,0 +1,16 @@ +loadMigrationsFrom([ + '--realpath' => true, + '--path' => [ + realpath(__DIR__ . '/../database/migrations') + ] + ]); + } +} diff --git a/tests/TwoFactorAuthenticationTest.php b/tests/TwoFactorAuthenticationTest.php index c246f16..9cc67ad 100644 --- a/tests/TwoFactorAuthenticationTest.php +++ b/tests/TwoFactorAuthenticationTest.php @@ -20,6 +20,7 @@ class TwoFactorAuthenticationTest extends TestCase { use DatabaseMigrations; + use RunsPublishableMigrations; use RegistersPackage; use CreatesTwoFactorUser; use RegistersLoginRoute; @@ -28,6 +29,7 @@ class TwoFactorAuthenticationTest extends TestCase protected function setUp() : void { $this->afterApplicationCreated([$this, 'loadLaravelMigrations']); + $this->afterApplicationCreated([$this, 'runPublishableMigration']); $this->afterApplicationCreated([$this, 'registerLoginRoute']); $this->afterApplicationCreated([$this, 'createTwoFactorUser']); parent::setUp();