diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d940ef9 Binary files /dev/null and b/.DS_Store differ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b51066 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +/tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +CHANGELOG.md export-ignore +phpunit.xml.dist export-ignore +UPGRADE.md export-ignore +phpstan.neon.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0bc378d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..32f7754 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.3.6 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..39a77e7 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,24 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.1.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..9d41c0c --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,26 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..f23157d --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,51 @@ +name: run-tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.2, 8.1] + laravel: [10.*] + stability: [prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbcee90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.lock +.phpunit.cache +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..79810c8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e78cd12 --- /dev/null +++ b/README.md @@ -0,0 +1,267 @@ +# Two-Factor-Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/emargareten/two-factor-laravel.svg?style=flat-square)](https://packagist.org/packages/emargareten/two-factor-laravel) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/emargareten/two-factor-laravel/run-tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/emargareten/two-factor-laravel/actions?query=workflow%3Arun-tests+branch%3Amaster) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/emargareten/two-factor-laravel/fix-php-code-style-issues.yml?branch=master&label=code%20style&style=flat-square)](https://github.com/emargareten/two-factor-laravel/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amaster) +[![Total Downloads](https://img.shields.io/packagist/dt/emargareten/two-factor-laravel.svg?style=flat-square)](https://packagist.org/packages/emargareten/two-factor-laravel) + +Two-Factor-Laravel is a package that implements two-factor authentication for your Laravel apps. + +## Installation + +First, install the package into your project using composer: + +```bash +composer require emargareten/two-factor-laravel +``` + +Next, you should publish the configuration and migration files using the `vendor:publish` Artisan command: + +```bash +php artisan vendor:publish --provider="Emargareten\TwoFactor\ServiceProvider" +``` + +Finally, you should run your application's database migrations. This will add the two-factor columns to the `users` table: + +```bash +php artisan migrate +``` + +### Configuration + +After publishing the assets, you may review the `config/two-factor.php` configuration file. This file contains several options that allow you to customize the behavior of the two-factor authentication features. + +## Usage + +To start using two-factor authentication, you should first add the `TwoFactorAuthenticatable` trait to your `User` model: + +```php +use Emargareten\TwoFactor\TwoFactorAuthenticatable; + +class User extends Authenticatable +{ + use TwoFactorAuthenticatable; +} +``` + +### Enabling Two-Factor Authentication + +This package provides the logic for authenticating users using two-factor authentication. However, it is up to you to provide the user interface and controllers for enabling and disabling two-factor authentication. + +To enable two-factor authentication for a user, you should call the `EnableTwoFactorAuthentication` action, this will generate a secret key and recovery codes for the user and store them in the database (encrypted): + +```php +use Emargareten\TwoFactor\Actions\DisableTwoFactorAuthentication; +use Emargareten\TwoFactor\Actions\EnableTwoFactorAuthentication; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; + +class TwoFactorAuthenticationController extends Controller +{ + /** + * Enable two-factor authentication for the user. + */ + public function store(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse + { + $user = $request->user(); + + if ($user->hasEnabledTwoFactorAuthentication()) { + return back()->with('status', 'Two-factor authentication is already enabled'); + } + + if (! $user->hasPendingTwoFactorAuthentication()) { + $enable($user); + } + + return redirect()->route('account.two-factor-authentication.confirm.show'); + } +} +``` + +After enabling two-factor authentication, you should redirect the user to a page where they have to confirm enabling two-factor authentication by entering the one-time password generated by their authenticator app (or sent to them via SMS/email). + +```php +use Emargareten\TwoFactor\Actions\ConfirmTwoFactorAuthentication; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; + +class TwoFactorAuthenticationConfirmationController extends Controller +{ + /** + * Get the two-factor authentication confirmation view. + */ + public function show(Request $request): View|RedirectResponse + { + $user = $request->user(); + + if ($user->hasEnabledTwoFactorAuthentication()) { + return back()->with('status', 'Two-factor authentication is already enabled'); + } + + if (! $user->hasPendingTwoFactorAuthentication()) { + return back()->with('status', 'Two-factor authentication is disabled'); + } + + return view('account.two-factor-confirmation.show', [ + 'qrCodeSvg' => $user->twoFactorQrCodeSvg(), + 'setupKey' => $request->user()->two_factor_secret, + ]); + } + + /** + * Confirm two-factor authentication for the user. + */ + public function store(Request $request, ConfirmTwoFactorAuthentication $confirm): RedirectResponse + { + $request->validate([ + 'code' => ['required', 'string'], + ]); + + $confirm($request->user(), $request->code); + + return redirect() + ->route('account.two-factor-authentication.recovery-codes.index') + ->with('status', 'Two-factor authentication successfully enabled'); + } +} +``` + +If you prefer to use a different method for receiving the one-time password, i.e. SMS/email, you can use the `getCurrentOtp` method on the user model to get retrieve current one-time password: + +```php +$user->getCurrentOtp(); +``` + +> **Note** +> When sending the one-time-password via SMS/email, you should set the window config to a higher value, i.e. 10, to allow the user to enter the one-time password after it has been sent. + +You should also provide a way for the user to disable two-factor authentication. This can be done by calling the `DisableTwoFactorAuthentication` action: + +```php +/** + * Disable two-factor authentication for the user. + */ +public function destroy(Request $request, DisableTwoFactorAuthentication $disable): RedirectResponse +{ + $disable($request->user()); + + return back()->with('status', 'Two-factor authentication disabled successfully'); +} +``` + +Once the user has confirmed enabling two-factor authentication, each time they log in, they will be redirected to a page where they will be asked to enter a one-time password generated by their authenticator app (or sent to them via SMS/email). + +```php +use Emargareten\TwoFactor\Actions\TwoFactorRedirector; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; + +public function login(Request $request, TwoFactorRedirector $redirector): Response +{ + // do login stuff... + + return $redirector->redirect($request); +} +``` + +This will redirect the user to the `two-factor-challenge.create` route. + +You will need to provide a view for this route. This view should contain a form where the user can enter the one-time password, you should bind the view to the `TwoFactorChallengeViewResponse::class` in the `register` method of your `AppServiceProvider` by calling the `TwoFactor::challengeView()` method: + +```php +/** + * Register any application services. + */ +public function register(): void +{ + TwoFactor::challengeView('two-factor-challenge.create'); +} +``` + +Or use a closure to generate a custom response: + +```php +TwoFactor::challengeView(function (Request $request) { + return Inertia::render('TwoFactorChallenge/Create'); +}); +``` + +The form should be submitted to the `two-factor-challenge.store` route. + +Once the user has entered a valid one-time password, he will be redirected to the intended URL (or to the home route defined in the config file if no intended URL was set). + +### Recovery Codes + +This package also provides the logic for generating and using recovery codes. Recovery codes can be used to access the application in case the user loses access to their authenticator app. + +After enabling two-factor authentication, you should redirect the user to a page where they can view their recovery codes. You can generate a fresh set of recovery codes by calling the `GenerateNewRecoveryCodes` action: + +```php +use Emargareten\TwoFactor\Actions\GenerateNewRecoveryCodes; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; + +class TwoFactorAuthenticationRecoveryCodeController extends Controller +{ + /** + * Get the two-factor authentication recovery codes for authenticated user. + */ + public function index(Request $request): View|RedirectResponse + { + if (! $request->user()->hasEnabledTwoFactorAuthentication()) { + return back()->with('status', 'Two-factor authentication is disabled'); + } + + return view('two-factor-recovery-codes.index', [ + 'recoveryCodes' => $request->user()->two_factor_recovery_codes, + ]); + } + + /** + * Generate a fresh set of two-factor authentication recovery codes. + */ + public function store(Request $request, GenerateNewRecoveryCodes $generate): RedirectResponse + { + if (! $request->user()->hasEnabledTwoFactorAuthentication()) { + return back()->with('status', 'Two-factor authentication is disabled'); + } + + $generate($request->user()); + + return redirect()->route('account.two-factor-authentication.recovery-codes.index'); + } +} +``` + +To use the recovery codes, you should add a view for the `two-factor-challenge-recovery.create` route. This view should contain a form where the user can enter a recovery code. You should bind the view to the `TwoFactorChallengeRecoveryViewResponse::class` in the `register` method of your `AppServiceProvider` by calling the `TwoFactor::challengeRecoveryView()` method: + +The form should be submitted to the `two-factor-challenge-recovery.store` route. + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [emargareten](https://github.com/emargareten) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d643803 --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "emargareten/two-factor-laravel", + "description": "Two-factor authentication implementation for Laravel applications.", + "keywords": ["laravel", "2fa", "two-factor-authentication"], + "license": "MIT", + "homepage": "https://github.com/emargareten/two-factor-laravel", + "require": { + "php": "^8.0", + "bacon/bacon-qr-code": "^2.0", + "laravel/framework": "^9.0|^10.0", + "pragmarx/google2fa": "^7.0|^8.0.1" + }, + "require-dev": { + "laravel/pint": "^1.6", + "nunomaduro/larastan": "^2.4", + "orchestra/testbench": "^8.0", + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "Emargareten\\TwoFactor\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Emargareten\\TwoFactor\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Emargareten\\TwoFactor\\ServiceProvider" + ] + } + }, + "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/two-factor.php b/config/two-factor.php new file mode 100644 index 0000000..67ac7f7 --- /dev/null +++ b/config/two-factor.php @@ -0,0 +1,117 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Username + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users, but you are free to change this value here. + | + */ + + 'username' => 'email', + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected after + | successful authentication when the operations are successful and + | the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/home', + + /* + |-------------------------------------------------------------------------- + | Two-Factor Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix should be assigned to all two-factor + | routes registered with the application. You may also change the + | subdomain under which all the two-factor routes will be accessed. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Two-Factor Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware should be assigned to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Validation + |-------------------------------------------------------------------------- + | + | Here you may specify the validation messages / translation keys that + | should appear when an invalid code was submitted. You should not + | use the translation methods as it is being used under the hood. + | + */ + 'validation_messages' => [ + 'invalid_code' => 'The provided code is invalid.', + 'invalid_recovery_code' => 'The provided recovery code is invalid.', + 'throttle' => 'Too many attempts. Please wait before retrying.', + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | Here you may enable rate limiting when submitting the two-factor code. + | If necessary, you may change the max attempts amount per minute or + | turn off the rate-limiting entirely by setting limiter to false. + | + */ + + 'limiter' => true, + + 'max_attempts' => 5, + + /* + |-------------------------------------------------------------------------- + | Window + |-------------------------------------------------------------------------- + | To avoid problems with clocks that are slightly out of sync, we do not + | check against the current key only but also consider window keys each + | from the past and future, each window key is a 30 second timespan. + | Here you can set the window, the defaults is 4 which equals to + | the previous two and next two minutes. + | + */ + + // 'window' => 0, + +]; diff --git a/database/migrations/add_two_factor_columns_to_users_table.php b/database/migrations/add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..9ff1709 --- /dev/null +++ b/database/migrations/add_two_factor_columns_to_users_table.php @@ -0,0 +1,36 @@ +after('password', function (Blueprint $table) { + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..60a58c2 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +parameters: + level: 6 + paths: + - config + - database + - routes + - src + - tests + typeAliases: + \App\Models\User: '\Emargareten\TwoFactor\Tests\TestUser' + ignoreErrors: + - '#Call to an undefined method Illuminate\\Contracts\\Auth\\StatefulGuard::getProvider\(\)#' + checkMissingIterableValueType: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ed0b17b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + ./tests/ + + + + + + diff --git a/routes/routes.php b/routes/routes.php new file mode 100644 index 0000000..d52a876 --- /dev/null +++ b/routes/routes.php @@ -0,0 +1,34 @@ + $middleware], function () { + $challengeMiddleware = config('two-factor.limiter') ? 'throttle:two-factor' : null; + + if (app()->bound(TwoFactorChallengeViewResponse::class)) { + Route::get('/two-factor-challenge', [TwoFactorChallengeController::class, 'create']) + ->name('two-factor-challenge.create'); + } + + Route::post('/two-factor-challenge', [TwoFactorChallengeController::class, 'store']) + ->middleware($challengeMiddleware) + ->name('two-factor-challenge.store'); + + if (app()->bound(TwoFactorChallengeRecoveryViewResponse::class)) { + Route::get('/two-factor-challenge-recovery', [TwoFactorChallengeRecoveryController::class, 'create']) + ->name('two-factor-challenge-recovery.create'); + } + + Route::post('/two-factor-challenge-recovery', [TwoFactorChallengeRecoveryController::class, 'store']) + ->middleware($challengeMiddleware) + ->name('two-factor-challenge-recovery.store'); +}); diff --git a/src/Actions/ConfirmTwoFactorAuthentication.php b/src/Actions/ConfirmTwoFactorAuthentication.php new file mode 100644 index 0000000..ceb169f --- /dev/null +++ b/src/Actions/ConfirmTwoFactorAuthentication.php @@ -0,0 +1,42 @@ +two_factor_secret) + || empty($code) + || ! $this->provider->verify($user->two_factor_secret, $code) + ) { + throw ValidationException::withMessages([ + 'code' => [__(config('two-factor.validation_messages.invalid_code'))], + ]); + } + + $user->forceFill([ + 'two_factor_confirmed_at' => now(), + ])->save(); + + TwoFactorAuthenticationConfirmed::dispatch($user); + } +} diff --git a/src/Actions/DisableTwoFactorAuthentication.php b/src/Actions/DisableTwoFactorAuthentication.php new file mode 100644 index 0000000..bf263c9 --- /dev/null +++ b/src/Actions/DisableTwoFactorAuthentication.php @@ -0,0 +1,28 @@ +two_factor_secret) || + ! is_null($user->two_factor_recovery_codes) || + ! is_null($user->two_factor_confirmed_at)) { + $user->forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + TwoFactorAuthenticationDisabled::dispatch($user); + } + } +} diff --git a/src/Actions/EnableTwoFactorAuthentication.php b/src/Actions/EnableTwoFactorAuthentication.php new file mode 100644 index 0000000..55a4398 --- /dev/null +++ b/src/Actions/EnableTwoFactorAuthentication.php @@ -0,0 +1,37 @@ +forceFill([ + 'two_factor_secret' => $this->provider->generateSecretKey(), + 'two_factor_recovery_codes' => Collection::times(8, function () { + return RecoveryCode::generate(); + })->all(), + ])->save(); + + TwoFactorAuthenticationEnabled::dispatch($user); + } +} diff --git a/src/Actions/GenerateNewRecoveryCodes.php b/src/Actions/GenerateNewRecoveryCodes.php new file mode 100644 index 0000000..2c29341 --- /dev/null +++ b/src/Actions/GenerateNewRecoveryCodes.php @@ -0,0 +1,26 @@ +forceFill([ + 'two_factor_recovery_codes' => Collection::times(8, function () { + return RecoveryCode::generate(); + })->all(), + ])->save(); + + RecoveryCodesGenerated::dispatch($user); + } +} diff --git a/src/Actions/TwoFactorRedirector.php b/src/Actions/TwoFactorRedirector.php new file mode 100644 index 0000000..3001e3b --- /dev/null +++ b/src/Actions/TwoFactorRedirector.php @@ -0,0 +1,46 @@ +user(); + + if (! $user?->hasEnabledTwoFactorAuthentication()) { + return redirect()->intended(config('two-factor.home')); + } + + return $this->twoFactorChallengeResponse($request, $user); + } + + /** + * Get the two-factor authentication enabled response. + * + * @param \App\Models\User $user + */ + protected function twoFactorChallengeResponse(Request $request, $user): Response + { + app(StatefulGuard::class)->logout(); + + $request->session()->put([ + 'two-factor.login.id' => $user->getKey(), + 'two-factor.login.remember' => $request->boolean('remember'), + ]); + + TwoFactorAuthenticationChallenged::dispatch($user); + + return $request->wantsJson() + ? response()->json(['two_factor' => true]) + : redirect()->route('two-factor-challenge.create'); + } +} diff --git a/src/Contracts/TwoFactorChallengeRecoveryViewResponse.php b/src/Contracts/TwoFactorChallengeRecoveryViewResponse.php new file mode 100644 index 0000000..8dc20d0 --- /dev/null +++ b/src/Contracts/TwoFactorChallengeRecoveryViewResponse.php @@ -0,0 +1,10 @@ +guard->getProvider()->getModel(); + + $challengedUser = session()->has('two-factor.login.id') && $model::find(session()->get('two-factor.login.id')); + + throw_unless($challengedUser, AuthenticationException::class); + + return app(TwoFactorChallengeViewResponse::class); + } + + /** + * Attempt to authenticate a new session using the two-factor authentication code. + */ + public function store(TwoFactorChallengeRequest $request): RedirectResponse + { + $user = $request->challengedUser(); + + $this->guard->login($user, $request->remember()); + + $request->session()->regenerate(); + + return redirect()->intended(config('two-factor.home')); + } +} diff --git a/src/Http/Controllers/TwoFactorChallengeRecoveryController.php b/src/Http/Controllers/TwoFactorChallengeRecoveryController.php new file mode 100644 index 0000000..47b26c4 --- /dev/null +++ b/src/Http/Controllers/TwoFactorChallengeRecoveryController.php @@ -0,0 +1,56 @@ +guard->getProvider()->getModel(); + + $challengedUser = session()->has('two-factor.login.id') && $model::find(session()->get('two-factor.login.id')); + + throw_unless($challengedUser, AuthenticationException::class); + + return app(TwoFactorChallengeRecoveryViewResponse::class); + } + + /** + * Attempt to authenticate a new session using the two-factor authentication code. + */ + public function store(TwoFactorChallengeRequest $request): RedirectResponse + { + $user = $request->challengedUser(); + + $user->replaceRecoveryCode($request->input('recovery_code')); + + event(new RecoveryCodeReplaced($user, $request->input('recovery_code'))); + + $this->guard->login($user, $request->remember()); + + $request->session()->regenerate(); + + return redirect()->intended(config('two-factor.home')); + } +} diff --git a/src/Http/Requests/TwoFactorChallengeRequest.php b/src/Http/Requests/TwoFactorChallengeRequest.php new file mode 100644 index 0000000..60c2226 --- /dev/null +++ b/src/Http/Requests/TwoFactorChallengeRequest.php @@ -0,0 +1,118 @@ +routeIs('two-factor-challenge-recovery.store')) { + return [ + 'recovery_code' => ['required', 'string', $this->hasValidRecoveryCode()], + ]; + } + + return [ + 'code' => ['required', 'string', $this->hasValidCode()], + ]; + } + + /** + * Determine if the request has a valid two-factor code. + */ + public function hasValidCode(): Closure + { + return function ($attribute, $value, $fail) { + if (! $value) { + return; + } + + $valid = app(TwoFactorProvider::class)->verify( + $this->challengedUser()->two_factor_secret, $value + ); + + if (! $valid) { + $fail(__(config('two-factor.validation_messages.invalid_code'))); + } + }; + } + + /** + * Determine if the request has a valid two-factor recovery code. + */ + public function hasValidRecoveryCode(): Closure + { + return function ($attribute, $value, $fail) { + if (! $value) { + return; + } + + $valid = in_array($value, $this->challengedUser()->two_factor_recovery_codes); + + if (! $valid) { + $fail(__(config('two-factor.validation_messages.invalid_recovery_code'))); + } + }; + } + + /** + * Get the user that is attempting the two factor challenge. + * + * @return \App\Models\User + */ + public function challengedUser() + { + if ($this->challengedUser) { + return $this->challengedUser; + } + + $model = app(StatefulGuard::class)->getProvider()->getModel(); + + if (! $this->session()->has('two-factor.login.id') || ! $user = $model::find($this->session()->get('two-factor.login.id'))) { + throw new AuthenticationException(); + } + + return $this->challengedUser = $user; + } + + /** + * Determine if the user wanted to be remembered after login. + */ + public function remember(): bool + { + if (! isset($this->remember)) { + $this->remember = $this->session()->pull('two-factor.login.remember', false); + } + + return $this->remember; + } +} diff --git a/src/Http/Responses/SimpleViewResponse.php b/src/Http/Responses/SimpleViewResponse.php new file mode 100644 index 0000000..2edb973 --- /dev/null +++ b/src/Http/Responses/SimpleViewResponse.php @@ -0,0 +1,41 @@ +view) || is_string($this->view)) { + return view($this->view, ['request' => $request]); + } + + $response = call_user_func($this->view, $request); + + if ($response instanceof Responsable) { + return $response->toResponse($request); + } + + return $response; + } +} diff --git a/src/RecoveryCode.php b/src/RecoveryCode.php new file mode 100644 index 0000000..51e132c --- /dev/null +++ b/src/RecoveryCode.php @@ -0,0 +1,16 @@ +app->singleton(TwoFactorProviderContract::class, function ($app) { + return new TwoFactorProvider( + $app->make(Google2FA::class), + $app->make(Repository::class) + ); + }); + + $this->app->bind(StatefulGuard::class, function () { + return Auth::guard(config('two-factor.guard')); + }); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + $this->mergeConfigFrom(__DIR__.'/../config/two-factor.php', 'two-factor'); + + $this->configurePublishing(); + $this->configureRateLimiting(); + $this->configureRoutes(); + + $this->registerEventListeners(); + } + + /** + * Configure the publishable resources offered by the package. + */ + protected function configurePublishing(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/two-factor.php' => config_path('two-factor.php'), + ], 'two-factor-config'); + + $this->publishes([ + __DIR__.'/../database/migrations/add_two_factor_columns_to_users_table.php' => database_path('migrations/'.date('Y_m_d_His').'_add_two_factor_columns_to_users_table.php'), + ], 'two-factor-migrations'); + } + } + + /** + * Configure the routes offered by the application. + */ + protected function configureRoutes(): void + { + if (TwoFactor::$registersRoutes) { + Route::group([ + 'domain' => config('two-factor.domain'), + 'prefix' => config('two-factor.prefix'), + ], function () { + $this->loadRoutesFrom(__DIR__.'/../routes/routes.php'); + }); + } + } + + /** + * Configure the rate limiters for the application. + */ + protected function configureRateLimiting(): void + { + if (! config('two-factor.limiter')) { + return; + } + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(config('two-factor.max_attempts')) + ->by('two-factor:'.$request->session()->get('two-factor.login.id')) + ->response(function (Request $request, array $headers) { + if ($request->wantsJson()) { + throw new ThrottleRequestsException('Too Many Attempts.', null, $headers); + } + + $message = __(config('two-factor.validation_messages.throttle')); + + throw ValidationException::withMessages([ + 'code' => [$message], + 'recovery_code' => [$message], + ]); + }); + }); + } + + protected function registerEventListeners(): void + { + Event::listen(Login::class, function () { + $this->app['session']->forget('two-factor.login.id'); + $this->app['session']->forget('two-factor.login.remember'); + }); + } +} diff --git a/src/TwoFactor.php b/src/TwoFactor.php new file mode 100644 index 0000000..060b204 --- /dev/null +++ b/src/TwoFactor.php @@ -0,0 +1,43 @@ +singleton(TwoFactorChallengeViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the two-factor authentication challenge recovery view. + */ + public static function challengeRecoveryView(callable|string $view): void + { + app()->singleton(TwoFactorChallengeRecoveryViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } +} diff --git a/src/TwoFactorAuthenticatable.php b/src/TwoFactorAuthenticatable.php new file mode 100644 index 0000000..0a73dc2 --- /dev/null +++ b/src/TwoFactorAuthenticatable.php @@ -0,0 +1,90 @@ +mergeCasts([ + 'two_factor_secret' => 'encrypted', + 'two_factor_confirmed_at' => 'datetime', + 'two_factor_recovery_codes' => 'encrypted:array', + ]); + } + + /** + * Determine if two-factor authentication has been enabled. + */ + public function hasEnabledTwoFactorAuthentication(): bool + { + return ! is_null($this->two_factor_secret) && ! is_null($this->two_factor_confirmed_at); + } + + /** + * Determine if two-factor authentication is pending confirmation. + */ + public function hasPendingTwoFactorAuthentication(): bool + { + return ! is_null($this->two_factor_secret) && is_null($this->two_factor_confirmed_at); + } + + /** + * Replace the given recovery code with a new one in the user's stored codes. + */ + public function replaceRecoveryCode(string $code): void + { + $this->forceFill([ + 'two_factor_recovery_codes' => array_map( + fn ($recoveryCode) => $recoveryCode == $code ? RecoveryCode::generate() : $recoveryCode, + $this->two_factor_recovery_codes + ), + ])->save(); + } + + /** + * Get the QR code SVG of the user's two-factor authentication QR code URL. + */ + public function twoFactorQrCodeSvg(): string + { + $svg = (new Writer( + new ImageRenderer( + new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), + new SvgImageBackEnd + ) + ))->writeString($this->twoFactorQrCodeUrl()); + + return trim(substr($svg, strpos($svg, "\n") + 1)); + } + + /** + * Get the two-factor authentication QR code URL. + */ + public function twoFactorQrCodeUrl(): string + { + return app(TwoFactorProvider::class)->qrCodeUrl( + config('app.name'), + $this->{TwoFactor::username()}, + $this->two_factor_secret + ); + } + + /** + * Get the current one time password for the user. + */ + public function getCurrentOtp(): string + { + return app(TwoFactorProvider::class)->getCurrentOtp($this->two_factor_secret); + } +} diff --git a/src/TwoFactorProvider.php b/src/TwoFactorProvider.php new file mode 100644 index 0000000..632a730 --- /dev/null +++ b/src/TwoFactorProvider.php @@ -0,0 +1,69 @@ +engine->generateSecretKey(); + } + + /** + * Get the current one time password for a key. + */ + public function getCurrentOtp(string $secret): string + { + return $this->engine->getCurrentOtp($secret); + } + + /** + * Get the two-factor authentication QR code URL. + */ + public function qrCodeUrl(string $companyName, string $companyEmail, string $secret): string + { + return $this->engine->getQRCodeUrl($companyName, $companyEmail, $secret); + } + + /** + * Verify the given code. + */ + public function verify(string $secret, string $code): bool + { + if (is_int($customWindow = config('two-factor.window'))) { + $this->engine->setWindow($customWindow); + } + + $timestamp = $this->engine->verifyKeyNewer( + $secret, $code, $this->cache?->get($key = 'two-factor.codes.'.md5($code)) + ); + + if ($timestamp !== false) { + if ($timestamp === true) { + $timestamp = $this->engine->getTimestamp(); + } + + $this->cache?->put($key, $timestamp, ($this->engine->getWindow() ?: 1) * 60); + + return true; + } + + return false; + } +} diff --git a/tests/OrchestraTestCase.php b/tests/OrchestraTestCase.php new file mode 100644 index 0000000..1fc1924 --- /dev/null +++ b/tests/OrchestraTestCase.php @@ -0,0 +1,33 @@ +path(__DIR__.'/../database/migrations'); + + $app['config']->set('auth.providers.users.model', TestUser::class); + + $app['config']->set('database.default', 'testbench'); + + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} diff --git a/tests/TestUser.php b/tests/TestUser.php new file mode 100644 index 0000000..f0c10f6 --- /dev/null +++ b/tests/TestUser.php @@ -0,0 +1,29 @@ +loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + } + + public function test_two_factor_challenge_can_be_passed_via_code(): void + { + $tfaEngine = app(Google2FA::class); + $userSecret = $tfaEngine->generateSecretKey(); + $validOtp = $tfaEngine->getCurrentOtp($userSecret); + + $user = TestUser::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret'), + 'two_factor_secret' => $userSecret, + ]); + + $response = $this + ->withSession([ + 'two-factor.login.id' => $user->id, + 'two-factor.login.remember' => false, + ]) + ->post('/two-factor-challenge', [ + 'code' => $validOtp, + ]); + + $response + ->assertRedirect('/home') + ->assertSessionMissing('two-factor.login.id'); + } + + public function test_two_factor_challenge_fails_for_old_otp_and_zero_window(): void + { + app('config')->set('auth.providers.users.model', TestUser::class); + + //Setting window to 0 should mean any old OTP is instantly invalid + app('config')->set('two-factor.window', 0); + + $this->loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $tfaEngine = app(Google2FA::class); + $userSecret = $tfaEngine->generateSecretKey(); + $currentTs = $tfaEngine->getTimestamp(); + $previousOtp = $tfaEngine->oathTotp($userSecret, $currentTs - 1); + + $user = TestUser::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret'), + 'two_factor_secret' => $userSecret, + ]); + + $response = $this + ->from('/two-factor-challenge') + ->withSession([ + 'two-factor.login.id' => $user->id, + 'two-factor.login.remember' => false, + ]) + ->post('/two-factor-challenge', [ + 'code' => $previousOtp, + ]); + + $response + ->assertRedirect('/two-factor-challenge') + ->assertSessionHas('two-factor.login.id') + ->assertSessionHasErrors(['code']); + } + + public function test_two_factor_challenge_can_be_passed_via_recovery_code(): void + { + $user = TestUser::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret'), + 'two_factor_recovery_codes' => ['invalid-code', 'valid-code'], + ]); + + $response = $this + ->from('/two-factor-challenge-recovery') + ->withSession([ + 'two-factor.login.id' => $user->id, + 'two-factor.login.remember' => false, + ]) + ->post('/two-factor-challenge-recovery', [ + 'recovery_code' => 'valid-code', + ]); + + $response + ->assertRedirect('/home') + ->assertSessionMissing('two-factor.login.id'); + + $this->assertAuthenticated(); + $this->assertNotContains('valid-code', $user->fresh()->two_factor_recovery_codes); + } + + public function test_two_factor_challenge_can_fail_via_recovery_code(): void + { + $user = TestUser::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret'), + 'two_factor_recovery_codes' => ['invalid-code', 'valid-code'], + ]); + + $response = $this + ->from('/two-factor-challenge-recovery') + ->withSession([ + 'two-factor.login.id' => $user->id, + 'two-factor.login.remember' => false, + ]) + ->post('/two-factor-challenge-recovery', [ + 'recovery_code' => 'missing-code', + ]); + + $response + ->assertRedirect('/two-factor-challenge-recovery') + ->assertSessionHas('two-factor.login.id') + ->assertSessionHasErrors(['recovery_code']); + + $this->assertGuest(); + } + + public function test_two_factor_challenge_requires_a_challenged_user(): void + { + $response = $this->withSession([])->postJson('/two-factor-challenge', [ + 'code' => 'code', + ]); + + $response->assertUnauthorized(); + + $this->assertGuest(); + } +}