From 4f903075f01dddb46905a870f3a460fe17e1f461 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Mon, 31 Aug 2020 14:24:03 -0500 Subject: [PATCH] first --- .editorconfig | 15 ++ .gitattributes | 11 + .github/CODE_OF_CONDUCT.md | 3 + .github/CONTRIBUTING.md | 4 + .github/ISSUE_TEMPLATE/1_Bug_report.md | 14 + .github/ISSUE_TEMPLATE/2_Feature_request.md | 4 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/PULL_REQUEST_TEMPLATE.md | 9 + .github/SECURITY.md | 92 +++++++ .github/SUPPORT.md | 3 + .github/workflows/tests.yml | 42 +++ .gitignore | 4 + .styleci.yml | 4 + LICENSE.md | 21 ++ composer.json | 52 ++++ config/fortify.php | 21 ++ ...12_100000_create_password_resets_table.php | 32 +++ ..._add_two_factor_columns_to_users_table.php | 39 +++ phpunit.xml.dist | 26 ++ routes/routes.php | 104 ++++++++ src/Actions/AttemptToAuthenticate.php | 61 +++++ src/Actions/CompletePasswordReset.php | 28 ++ .../DisableTwoFactorAuthentication.php | 20 ++ src/Actions/EnableTwoFactorAuthentication.php | 44 ++++ src/Actions/EnsureLoginIsNotThrottled.php | 46 ++++ src/Actions/GenerateNewRecoveryCodes.php | 24 ++ src/Actions/PrepareAuthenticatedSession.php | 42 +++ .../RedirectIfTwoFactorAuthenticatable.php | 100 ++++++++ src/Contracts/CreatesNewUsers.php | 14 + ...FailedPasswordResetLinkRequestResponse.php | 10 + src/Contracts/FailedPasswordResetResponse.php | 10 + src/Contracts/LockoutResponse.php | 10 + src/Contracts/LoginResponse.php | 10 + src/Contracts/LoginViewResponse.php | 10 + src/Contracts/LogoutResponse.php | 10 + src/Contracts/PasswordResetResponse.php | 10 + src/Contracts/RegisterResponse.php | 10 + src/Contracts/RegisterViewResponse.php | 10 + .../RequestPasswordResetLinkViewResponse.php | 10 + src/Contracts/ResetPasswordViewResponse.php | 10 + src/Contracts/ResetsUserPasswords.php | 15 ++ ...essfulPasswordResetLinkRequestResponse.php | 10 + .../TwoFactorAuthenticationProvider.php | 32 +++ .../TwoFactorChallengeViewResponse.php | 10 + src/Contracts/UpdatesUserPasswords.php | 15 ++ .../UpdatesUserProfileInformation.php | 15 ++ src/Contracts/VerifyEmailViewResponse.php | 10 + src/Features.php | 120 +++++++++ src/Fortify.php | 175 +++++++++++++ src/FortifyServiceProvider.php | 121 +++++++++ .../AuthenticatedSessionController.php | 83 ++++++ ...mailVerificationNotificationController.php | 31 +++ .../EmailVerificationPromptController.php | 23 ++ .../Controllers/NewPasswordController.php | 90 +++++++ src/Http/Controllers/PasswordController.php | 27 ++ .../PasswordResetLinkController.php | 58 +++++ .../ProfileInformationController.php | 28 ++ .../Controllers/RecoveryCodeController.php | 44 ++++ .../Controllers/RegisteredUserController.php | 61 +++++ ...woFactorAuthenticatedSessionController.php | 65 +++++ .../TwoFactorAuthenticationController.php | 43 ++++ .../Controllers/TwoFactorQrCodeController.php | 20 ++ .../Controllers/VerifyEmailController.php | 30 +++ src/Http/Requests/LoginRequest.php | 32 +++ src/Http/Requests/TwoFactorLoginRequest.php | 114 +++++++++ src/Http/Requests/VerifyEmailRequest.php | 36 +++ ...FailedPasswordResetLinkRequestResponse.php | 46 ++++ .../Responses/FailedPasswordResetResponse.php | 47 ++++ .../FailedTwoFactorLoginResponse.php | 29 +++ src/Http/Responses/LockoutResponse.php | 50 ++++ src/Http/Responses/LoginResponse.php | 22 ++ src/Http/Responses/LogoutResponse.php | 22 ++ src/Http/Responses/PasswordResetResponse.php | 41 +++ src/Http/Responses/RegisterResponse.php | 22 ++ src/Http/Responses/SimpleViewResponse.php | 48 ++++ ...essfulPasswordResetLinkRequestResponse.php | 40 +++ src/Http/Responses/TwoFactorLoginResponse.php | 22 ++ src/LoginRateLimiter.php | 83 ++++++ src/RecoveryCode.php | 18 ++ src/Rules/Password.php | 129 ++++++++++ src/TwoFactorAuthenticatable.php | 72 ++++++ src/TwoFactorAuthenticationProvider.php | 62 +++++ stubs/CreateNewUser.php | 33 +++ stubs/FortifyServiceProvider.php | 36 +++ stubs/PasswordValidationRules.php | 18 ++ stubs/ResetUserPassword.php | 31 +++ stubs/UpdateUserPassword.php | 36 +++ stubs/UpdateUserProfileInformation.php | 37 +++ stubs/fortify.php | 95 +++++++ tests/AuthenticatedSessionControllerTest.php | 242 ++++++++++++++++++ ...VerificationNotificationControllerTest.php | 39 +++ .../EmailVerificationPromptControllerTest.php | 25 ++ tests/NewPasswordControllerTest.php | 102 ++++++++ tests/OrchestraTestCase.php | 25 ++ tests/PasswordControllerTest.php | 30 +++ ...PasswordResetLinkRequestControllerTest.php | 65 +++++ tests/PasswordRuleTest.php | 41 +++ tests/ProfileInformationControllerTest.php | 26 ++ tests/RecoveryCodeControllerTest.php | 55 ++++ tests/RegisteredUserControllerTest.php | 39 +++ .../TwoFactorAuthenticationControllerTest.php | 85 ++++++ tests/VerifyEmailControllerTest.php | 98 +++++++ 102 files changed, 4216 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/1_Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/2_Feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md create mode 100644 .github/SUPPORT.md create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .styleci.yml create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 config/fortify.php create mode 100644 database/migrations/2014_10_12_100000_create_password_resets_table.php create mode 100644 database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php create mode 100644 phpunit.xml.dist create mode 100644 routes/routes.php create mode 100644 src/Actions/AttemptToAuthenticate.php create mode 100644 src/Actions/CompletePasswordReset.php create mode 100644 src/Actions/DisableTwoFactorAuthentication.php create mode 100644 src/Actions/EnableTwoFactorAuthentication.php create mode 100644 src/Actions/EnsureLoginIsNotThrottled.php create mode 100644 src/Actions/GenerateNewRecoveryCodes.php create mode 100644 src/Actions/PrepareAuthenticatedSession.php create mode 100644 src/Actions/RedirectIfTwoFactorAuthenticatable.php create mode 100644 src/Contracts/CreatesNewUsers.php create mode 100644 src/Contracts/FailedPasswordResetLinkRequestResponse.php create mode 100644 src/Contracts/FailedPasswordResetResponse.php create mode 100644 src/Contracts/LockoutResponse.php create mode 100644 src/Contracts/LoginResponse.php create mode 100644 src/Contracts/LoginViewResponse.php create mode 100644 src/Contracts/LogoutResponse.php create mode 100644 src/Contracts/PasswordResetResponse.php create mode 100644 src/Contracts/RegisterResponse.php create mode 100644 src/Contracts/RegisterViewResponse.php create mode 100644 src/Contracts/RequestPasswordResetLinkViewResponse.php create mode 100644 src/Contracts/ResetPasswordViewResponse.php create mode 100644 src/Contracts/ResetsUserPasswords.php create mode 100644 src/Contracts/SuccessfulPasswordResetLinkRequestResponse.php create mode 100644 src/Contracts/TwoFactorAuthenticationProvider.php create mode 100644 src/Contracts/TwoFactorChallengeViewResponse.php create mode 100644 src/Contracts/UpdatesUserPasswords.php create mode 100644 src/Contracts/UpdatesUserProfileInformation.php create mode 100644 src/Contracts/VerifyEmailViewResponse.php create mode 100644 src/Features.php create mode 100644 src/Fortify.php create mode 100644 src/FortifyServiceProvider.php create mode 100644 src/Http/Controllers/AuthenticatedSessionController.php create mode 100644 src/Http/Controllers/EmailVerificationNotificationController.php create mode 100644 src/Http/Controllers/EmailVerificationPromptController.php create mode 100644 src/Http/Controllers/NewPasswordController.php create mode 100644 src/Http/Controllers/PasswordController.php create mode 100644 src/Http/Controllers/PasswordResetLinkController.php create mode 100644 src/Http/Controllers/ProfileInformationController.php create mode 100644 src/Http/Controllers/RecoveryCodeController.php create mode 100644 src/Http/Controllers/RegisteredUserController.php create mode 100644 src/Http/Controllers/TwoFactorAuthenticatedSessionController.php create mode 100644 src/Http/Controllers/TwoFactorAuthenticationController.php create mode 100644 src/Http/Controllers/TwoFactorQrCodeController.php create mode 100644 src/Http/Controllers/VerifyEmailController.php create mode 100644 src/Http/Requests/LoginRequest.php create mode 100644 src/Http/Requests/TwoFactorLoginRequest.php create mode 100644 src/Http/Requests/VerifyEmailRequest.php create mode 100644 src/Http/Responses/FailedPasswordResetLinkRequestResponse.php create mode 100644 src/Http/Responses/FailedPasswordResetResponse.php create mode 100644 src/Http/Responses/FailedTwoFactorLoginResponse.php create mode 100644 src/Http/Responses/LockoutResponse.php create mode 100644 src/Http/Responses/LoginResponse.php create mode 100644 src/Http/Responses/LogoutResponse.php create mode 100644 src/Http/Responses/PasswordResetResponse.php create mode 100644 src/Http/Responses/RegisterResponse.php create mode 100644 src/Http/Responses/SimpleViewResponse.php create mode 100644 src/Http/Responses/SuccessfulPasswordResetLinkRequestResponse.php create mode 100644 src/Http/Responses/TwoFactorLoginResponse.php create mode 100644 src/LoginRateLimiter.php create mode 100644 src/RecoveryCode.php create mode 100644 src/Rules/Password.php create mode 100644 src/TwoFactorAuthenticatable.php create mode 100644 src/TwoFactorAuthenticationProvider.php create mode 100644 stubs/CreateNewUser.php create mode 100644 stubs/FortifyServiceProvider.php create mode 100644 stubs/PasswordValidationRules.php create mode 100644 stubs/ResetUserPassword.php create mode 100644 stubs/UpdateUserPassword.php create mode 100644 stubs/UpdateUserProfileInformation.php create mode 100644 stubs/fortify.php create mode 100644 tests/AuthenticatedSessionControllerTest.php create mode 100644 tests/EmailVerificationNotificationControllerTest.php create mode 100644 tests/EmailVerificationPromptControllerTest.php create mode 100644 tests/NewPasswordControllerTest.php create mode 100644 tests/OrchestraTestCase.php create mode 100644 tests/PasswordControllerTest.php create mode 100644 tests/PasswordResetLinkRequestControllerTest.php create mode 100644 tests/PasswordRuleTest.php create mode 100644 tests/ProfileInformationControllerTest.php create mode 100644 tests/RecoveryCodeControllerTest.php create mode 100644 tests/RegisteredUserControllerTest.php create mode 100644 tests/TwoFactorAuthenticationControllerTest.php create mode 100644 tests/VerifyEmailControllerTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6537ca46 --- /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 00000000..9f9414b4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto + +/.github export-ignore +/tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.styleci.yml export-ignore +CHANGELOG.md export-ignore +phpunit.xml.dist export-ignore +UPGRADE.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..92b5bf56 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of Conduct + +The Laravel Code of Conduct can be found in the [Laravel documentation](https://laravel.com/docs/contributions#code-of-conduct). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..38b62e04 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contribution Guide + +The Laravel contributing guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md new file mode 100644 index 00000000..f57fb9e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md @@ -0,0 +1,14 @@ +--- +name: "Bug report" +about: 'Report a general library issue. Please ensure your version is still supported: https://laravel.com/docs/releases#support-policy' +--- + +- Fortify Version: #.#.# +- Laravel Version: #.#.# +- PHP Version: #.#.# +- Database Driver & Version: + +### Description: + + +### Steps To Reproduce: diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md new file mode 100644 index 00000000..dfb10855 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_Feature_request.md @@ -0,0 +1,4 @@ +--- +name: "Feature request" +about: 'For ideas or feature requests: please make a pull request or open an issue' +--- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0f039119 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Support question + url: https://laravel.com/docs/contributions#support-questions + about: 'This repository is only for reporting bugs. If you need help using the library, click:' + - name: Documentation issue + url: https://github.com/laravel/docs + about: For documentation issues, open a pull request at the laravel/docs repository diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..03786937 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..dd673d42 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,92 @@ +# Security Policy + +**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).** + +## Supported Versions + +Only the latest major version receives security fixes. + +## Reporting a Vulnerability + +If you discover a security vulnerability within Laravel, please send an email to Taylor Otwell at taylor@laravel.com. All security vulnerabilities will be promptly addressed. + +### Public PGP Key + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP v2.0.8 +Comment: https://sela.io/pgp/ + +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 +WBGOG/qJGDlNiqBYYt2xNqzHCJoC +=zXOv +-----END PGP PUBLIC KEY BLOCK----- +``` diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 00000000..f0877fc2 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,3 @@ +# Support Questions + +The Laravel support guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions#support-questions). diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..0d334a97 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: tests + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + tests: + + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php: [7.3, 7.4] + laravel: [^8.0] + + name: P${{ matrix.php }} - L${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ~/.composer/cache/files + key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + coverage: none + + - name: Install dependencies + run: composer require "illuminate/contracts=${{ matrix.laravel }}" --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/phpunit --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..660fc15e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.lock +/phpunit.xml +.phpunit.result.cache diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..215fbcfe --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,4 @@ +php: + preset: laravel +js: true +css: true diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..79810c84 --- /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/composer.json b/composer.json new file mode 100644 index 00000000..d565090b --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "laravel/fortify", + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": ["laravel", "auth"], + "license": "MIT", + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "require": { + "php": "^7.3", + "ext-json": "*", + "bacon/bacon-qr-code": "^2.0", + "pragmarx/google2fa": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^6.0", + "phpunit/phpunit": "^8.0" + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Laravel\\Fortify\\Tests\\": "tests/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 00000000..d831d668 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,21 @@ + 'web', + 'passwords' => 'users', + 'username' => 'email', + 'home' => '/home', + 'limiters' => [ + 'login' => null, + ], + 'features' => [ + Features::registration(), + Features::resetPasswords(), + Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication(), + ], +]; diff --git a/database/migrations/2014_10_12_100000_create_password_resets_table.php b/database/migrations/2014_10_12_100000_create_password_resets_table.php new file mode 100644 index 00000000..0ee0a36a --- /dev/null +++ b/database/migrations/2014_10_12_100000_create_password_resets_table.php @@ -0,0 +1,32 @@ +string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('password_resets'); + } +} diff --git a/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..4a34f839 --- /dev/null +++ b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php @@ -0,0 +1,39 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('two_factor_secret'); + $table->dropColumn('two_factor_recovery_codes'); + }); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..72305c95 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./tests/ + + + + + ./src/ + + + + + + diff --git a/routes/routes.php b/routes/routes.php new file mode 100644 index 00000000..3469d30b --- /dev/null +++ b/routes/routes.php @@ -0,0 +1,104 @@ + config('fortify.middleware', ['web'])], function () { + // Authentication... + Route::get('/login', 'AuthenticatedSessionController@create') + ->middleware(['guest']) + ->name('login'); + + $limiter = config('fortify.limiters.login'); + + Route::post('/login', 'AuthenticatedSessionController@store') + ->middleware(array_filter([ + 'guest', + $limiter ? 'throttle:'.$limiter : null, + ])); + + Route::get('/two-factor-challenge', 'TwoFactorAuthenticatedSessionController@create') + ->middleware(['guest']) + ->name('two-factor.login'); + + Route::post('/two-factor-challenge', 'TwoFactorAuthenticatedSessionController@store') + ->middleware(['guest']); + + Route::post('/logout', 'AuthenticatedSessionController@destroy') + ->name('logout'); + + // Password Reset... + if (Features::enabled(Features::resetPasswords())) { + Route::get('/forgot-password', 'PasswordResetLinkController@create') + ->middleware(['guest']) + ->name('password.request'); + + Route::post('/forgot-password', 'PasswordResetLinkController@store') + ->middleware(['guest']) + ->name('password.email'); + + Route::get('/reset-password/{token}', 'NewPasswordController@create') + ->middleware(['guest']) + ->name('password.reset'); + + Route::post('/reset-password', 'NewPasswordController@store') + ->middleware(['guest']) + ->name('password.update'); + } + + // Registration... + if (Features::enabled(Features::registration())) { + Route::get('/register', 'RegisteredUserController@create') + ->middleware(['guest']) + ->name('register'); + + Route::post('/register', 'RegisteredUserController@store') + ->middleware(['guest']) + ->name('register'); + } + + // Email Verification... + if (Features::enabled(Features::emailVerification())) { + Route::get('/email/verify', 'EmailVerificationPromptController') + ->middleware(['auth']) + ->name('verification.notice'); + + Route::get('/email/verify/{id}/{hash}', 'VerifyEmailController') + ->middleware(['auth', 'signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('/email/verification-notification', 'EmailVerificationNotificationController@store') + ->middleware(['auth', 'throttle:6,1']) + ->name('verification.send'); + } + + // Profile Information... + if (Features::enabled(Features::updateProfileInformation())) { + Route::put('/user/profile-information', 'ProfileInformationController@update') + ->middleware(['auth']); + } + + // Passwords... + if (Features::enabled(Features::updatePasswords())) { + Route::put('/user/password', 'PasswordController@update') + ->middleware(['auth']); + } + + // Two Factor Authentication... + if (Features::enabled(Features::twoFactorAuthentication())) { + Route::post('/user/two-factor-authentication', 'TwoFactorAuthenticationController@store') + ->middleware(['auth']); + + Route::delete('/user/two-factor-authentication', 'TwoFactorAuthenticationController@destroy') + ->middleware(['auth']); + + Route::get('/user/two-factor-qr-code', 'TwoFactorQrCodeController@show') + ->middleware(['auth']); + + Route::get('/user/two-factor-recovery-codes', 'RecoveryCodeController@index') + ->middleware(['auth']); + + Route::post('/user/two-factor-recovery-codes', 'RecoveryCodeController@store') + ->middleware(['auth']); + } +}); diff --git a/src/Actions/AttemptToAuthenticate.php b/src/Actions/AttemptToAuthenticate.php new file mode 100644 index 00000000..ece3c8f5 --- /dev/null +++ b/src/Actions/AttemptToAuthenticate.php @@ -0,0 +1,61 @@ +guard = $guard; + $this->limiter = $limiter; + } + + /** + * Handle the incoming request. + * + * @param \Illuminate\Http\Request $request + * @param callable $next + * @return mixed + */ + public function handle($request, $next) + { + if ($this->guard->attempt( + $request->only(Fortify::username(), 'password'), + $request->filled('remember')) + ) { + return $next($request); + } + + $this->limiter->increment($request); + + throw ValidationException::withMessages([ + Fortify::username() => [trans('auth.failed')], + ]); + } +} diff --git a/src/Actions/CompletePasswordReset.php b/src/Actions/CompletePasswordReset.php new file mode 100644 index 00000000..0f65fce3 --- /dev/null +++ b/src/Actions/CompletePasswordReset.php @@ -0,0 +1,28 @@ +setRememberToken(Str::random(60)); + + $user->save(); + + event(new PasswordReset($user)); + + // $guard->login($user); + } +} diff --git a/src/Actions/DisableTwoFactorAuthentication.php b/src/Actions/DisableTwoFactorAuthentication.php new file mode 100644 index 00000000..50543d8f --- /dev/null +++ b/src/Actions/DisableTwoFactorAuthentication.php @@ -0,0 +1,20 @@ +forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ])->save(); + } +} diff --git a/src/Actions/EnableTwoFactorAuthentication.php b/src/Actions/EnableTwoFactorAuthentication.php new file mode 100644 index 00000000..e399a074 --- /dev/null +++ b/src/Actions/EnableTwoFactorAuthentication.php @@ -0,0 +1,44 @@ +provider = $provider; + } + + /** + * Enable two factor authentication for the user. + * + * @param mixed $user + * @return void + */ + public function __invoke($user) + { + $user->forceFill([ + 'two_factor_secret' => encrypt($this->provider->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ])->save(); + } +} diff --git a/src/Actions/EnsureLoginIsNotThrottled.php b/src/Actions/EnsureLoginIsNotThrottled.php new file mode 100644 index 00000000..6be96ab2 --- /dev/null +++ b/src/Actions/EnsureLoginIsNotThrottled.php @@ -0,0 +1,46 @@ +limiter = $limiter; + } + + /** + * Handle the incoming request. + * + * @param \Illuminate\Http\Request $request + * @param callable $next + * @return mixed + */ + public function handle($request, $next) + { + if (! $this->limiter->tooManyAttempts($request)) { + return $next($request); + } + + event(new Lockout($request)); + + return app(LockoutResponse::class); + } +} diff --git a/src/Actions/GenerateNewRecoveryCodes.php b/src/Actions/GenerateNewRecoveryCodes.php new file mode 100644 index 00000000..d301c886 --- /dev/null +++ b/src/Actions/GenerateNewRecoveryCodes.php @@ -0,0 +1,24 @@ +forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ])->save(); + } +} diff --git a/src/Actions/PrepareAuthenticatedSession.php b/src/Actions/PrepareAuthenticatedSession.php new file mode 100644 index 00000000..f5e0fb7c --- /dev/null +++ b/src/Actions/PrepareAuthenticatedSession.php @@ -0,0 +1,42 @@ +limiter = $limiter; + } + + /** + * Handle the incoming request. + * + * @param \Illuminate\Http\Request $request + * @param callable $next + * @return mixed + */ + public function handle($request, $next) + { + $request->session()->regenerate(); + + $this->limiter->clear($request); + + return $next($request); + } +} diff --git a/src/Actions/RedirectIfTwoFactorAuthenticatable.php b/src/Actions/RedirectIfTwoFactorAuthenticatable.php new file mode 100644 index 00000000..2824f5be --- /dev/null +++ b/src/Actions/RedirectIfTwoFactorAuthenticatable.php @@ -0,0 +1,100 @@ +guard = $guard; + $this->limiter = $limiter; + } + + /** + * Handle the incoming request. + * + * @param \Illuminate\Http\Request $request + * @param callable $next + * @return mixed + */ + public function handle($request, $next) + { + $user = $this->validateCredentials( + $request, $this->guard->getProvider()->getModel() + ); + + if (optional($user)->two_factor_secret && + in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) { + return $this->twoFactorChallengeResponse($request, $user); + } + + return $next($request); + } + + /** + * Attempt to validate the incoming credentials. + * + * @param \Illuminate\Http\Request $request + * @param string $model + * @return mixed + */ + protected function validateCredentials($request, $model) + { + return tap($model::where(Fortify::username(), $request->{Fortify::username()})->first(), function ($user) use ($request) { + if (! $user || ! Hash::check($request->password, $user->password)) { + $this->limiter->increment($request); + + throw ValidationException::withMessages([ + Fortify::username() => [trans('auth.failed')], + ]); + } + }); + } + + /** + * Get the two factor authentication enabled response. + * + * @param \Illuminate\Http\Request $request + * @param mixed $user + * @return \Illuminate\Http\Response + */ + protected function twoFactorChallengeResponse($request, $user) + { + $request->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => $request->filled('remember'), + ]); + + return $request->wantsJson() + ? response()->json(['two_factor' => true]) + : redirect()->route('two-factor.login'); + } +} diff --git a/src/Contracts/CreatesNewUsers.php b/src/Contracts/CreatesNewUsers.php new file mode 100644 index 00000000..4b8a0c04 --- /dev/null +++ b/src/Contracts/CreatesNewUsers.php @@ -0,0 +1,14 @@ +singleton(LoginViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the two factor authentication challenge view. + * + * @param string $view + * @return void + */ + public static function twoFactorChallengeView($view) + { + app()->singleton(TwoFactorChallengeViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the new password view. + * + * @param string $view + * @return void + */ + public static function resetPasswordView($view) + { + app()->singleton(ResetPasswordViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the registration view. + * + * @param string $view + * @return void + */ + public static function registerView($view) + { + app()->singleton(RegisterViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the email verification prompt. + * + * @param string $view + * @return void + */ + public static function verifyEmailView($view) + { + app()->singleton(VerifyEmailViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Specify which view should be used as the request password reset link view. + * + * @param string $view + * @return void + */ + public static function requestPasswordResetLinkView($view) + { + app()->singleton(RequestPasswordResetLinkViewResponse::class, function () use ($view) { + return new SimpleViewResponse($view); + }); + } + + /** + * Register a class / callback that should be used to create new users. + * + * @param string $callback + * @return void + */ + public static function createUsersUsing(string $callback) + { + return app()->singleton(CreatesNewUsers::class, $callback); + } + + /** + * Register a class / callback that should be used to update user profile information. + * + * @param string $callback + * @return void + */ + public static function updateUserProfileInformationUsing(string $callback) + { + return app()->singleton(UpdatesUserProfileInformation::class, $callback); + } + + /** + * Register a class / callback that should be used to update user passwords. + * + * @param string $callback + * @return void + */ + public static function updateUserPasswordsUsing(string $callback) + { + return app()->singleton(UpdatesUserPasswords::class, $callback); + } + + /** + * Register a class / callback that should be used to reset user passwords. + * + * @param string $callback + * @return void + */ + public static function resetUserPasswordsUsing(string $callback) + { + return app()->singleton(ResetsUserPasswords::class, $callback); + } +} diff --git a/src/FortifyServiceProvider.php b/src/FortifyServiceProvider.php new file mode 100644 index 00000000..776b7f50 --- /dev/null +++ b/src/FortifyServiceProvider.php @@ -0,0 +1,121 @@ +app->configurationIsCached()) { + $this->mergeConfigFrom(__DIR__.'/../config/fortify.php', 'fortify'); + } + + $this->registerResponseBindings(); + + $this->app->singleton( + TwoFactorAuthenticationProviderContract::class, + TwoFactorAuthenticationProvider::class + ); + + $this->app->bind(StatefulGuard::class, function () { + return Auth::guard(config('fortify.guard', null)); + }); + } + + /** + * Register the response bindings. + * + * @return void + */ + protected function registerResponseBindings() + { + $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + $this->app->singleton(LockoutResponseContract::class, LockoutResponse::class); + $this->app->singleton(LogoutResponseContract::class, LogoutResponse::class); + $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); + $this->app->singleton(SuccessfulPasswordResetLinkRequestResponseContract::class, SuccessfulPasswordResetLinkRequestResponse::class); + $this->app->singleton(FailedPasswordResetLinkRequestResponseContract::class, FailedPasswordResetLinkRequestResponse::class); + $this->app->singleton(PasswordResetResponseContract::class, PasswordResetResponse::class); + $this->app->singleton(FailedPasswordResetResponseContract::class, FailedPasswordResetResponse::class); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + $this->configurePublishing(); + $this->configureRoutes(); + } + + /** + * Configure the publishable resources offered by the package. + * + * @return void + */ + protected function configurePublishing() + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../stubs/fortify.php' => config_path('fortify.php'), + ], 'fortify-config'); + + $this->publishes([ + __DIR__.'/../stubs/CreateNewUser.php' => app_path('Actions/Fortify/CreateNewUser.php'), + __DIR__.'/../stubs/FortifyServiceProvider.php' => app_path('Providers/FortifyServiceProvider.php'), + __DIR__.'/../stubs/PasswordValidationRules.php' => app_path('Actions/Fortify/PasswordValidationRules.php'), + __DIR__.'/../stubs/ResetUserPassword.php' => app_path('Actions/Fortify/ResetUserPassword.php'), + __DIR__.'/../stubs/UpdateUserProfileInformation.php' => app_path('Actions/Fortify/UpdateUserProfileInformation.php'), + __DIR__.'/../stubs/UpdateUserPassword.php' => app_path('Actions/Fortify/UpdateUserPassword.php'), + ], 'fortify-support'); + + $this->publishes([ + __DIR__.'/../database/migrations' => database_path('migrations'), + ], 'fortify-migrations'); + } + } + + /** + * Configure the routes offered by the application. + * + * @return void + */ + protected function configureRoutes() + { + Route::group([ + 'namespace' => 'Laravel\Fortify\Http\Controllers', + 'domain' => config('fortify.domain', null), + ], function () { + $this->loadRoutesFrom(__DIR__.'/../routes/routes.php'); + }); + } +} diff --git a/src/Http/Controllers/AuthenticatedSessionController.php b/src/Http/Controllers/AuthenticatedSessionController.php new file mode 100644 index 00000000..4fe92b06 --- /dev/null +++ b/src/Http/Controllers/AuthenticatedSessionController.php @@ -0,0 +1,83 @@ +guard = $guard; + } + + /** + * Show the login view. + * + * @param \Illuminate\Http\Request $request + * @return \Laravel\Fortify\Contracts\LoginViewResponse + */ + public function create(Request $request): LoginViewResponse + { + return app(LoginViewResponse::class); + } + + /** + * Attempt to authenticate a new session. + * + * @param \Laravel\Fortify\Http\Requests\LoginRequest $request + * @return mixed + */ + public function store(LoginRequest $request) + { + return (new Pipeline(app()))->send($request)->through(array_filter([ + config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class, + RedirectIfTwoFactorAuthenticatable::class, + AttemptToAuthenticate::class, + PrepareAuthenticatedSession::class, + ]))->then(function ($request) { + return app(LoginResponse::class); + }); + } + + /** + * Destroy an authenticated session. + * + * @param \Illuminate\Http\Request $request + * @return \Laravel\Fortify\Contracts\LogoutResponse + */ + public function destroy(Request $request): LogoutResponse + { + $this->guard->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return app(LogoutResponse::class); + } +} diff --git a/src/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php new file mode 100644 index 00000000..aa8d4ac8 --- /dev/null +++ b/src/Http/Controllers/EmailVerificationNotificationController.php @@ -0,0 +1,31 @@ +user()->hasVerifiedEmail()) { + return $request->wantsJson() + ? new Response('', 204) + : redirect(config('fortify.home')); + } + + $request->user()->sendEmailVerificationNotification(); + + return $request->wantsJson() + ? new Response('', 202) + : back()->with('status', 'verification-link-sent'); + } +} diff --git a/src/Http/Controllers/EmailVerificationPromptController.php b/src/Http/Controllers/EmailVerificationPromptController.php new file mode 100644 index 00000000..ac4b0441 --- /dev/null +++ b/src/Http/Controllers/EmailVerificationPromptController.php @@ -0,0 +1,23 @@ +user()->hasVerifiedEmail() + ? redirect(config('fortify.home')) + : app(VerifyEmailViewResponse::class); + } +} diff --git a/src/Http/Controllers/NewPasswordController.php b/src/Http/Controllers/NewPasswordController.php new file mode 100644 index 00000000..7434ed95 --- /dev/null +++ b/src/Http/Controllers/NewPasswordController.php @@ -0,0 +1,90 @@ +guard = $guard; + } + + /** + * Show the new password view. + * + * @param \Illuminate\Http\Request $request + * @return \Laravel\Fortify\Contracts\ResetPasswordViewResponse + */ + public function create(Request $request): ResetPasswordViewResponse + { + return app(ResetPasswordViewResponse::class); + } + + /** + * Reset the user's password. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Contracts\Support\Responsable + */ + public function store(Request $request): Responsable + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = $this->broker()->reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user, $password) use ($request) { + app(ResetsUserPasswords::class)->reset($user, $request->all()); + + app(CompletePasswordReset::class)($this->guard, $user); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + return $status == Password::PASSWORD_RESET + ? app(PasswordResetResponse::class, ['status' => $status]) + : app(FailedPasswordResetResponse::class, ['status' => $status]); + } + + /** + * Get the broker to be used during password reset. + * + * @return \Illuminate\Contracts\Auth\PasswordBroker + */ + protected function broker(): PasswordBroker + { + return Password::broker(config('fortify.passwords')); + } +} diff --git a/src/Http/Controllers/PasswordController.php b/src/Http/Controllers/PasswordController.php new file mode 100644 index 00000000..ea5adee2 --- /dev/null +++ b/src/Http/Controllers/PasswordController.php @@ -0,0 +1,27 @@ +update($request->user(), $request->all()); + + return $request->wantsJson() + ? new Response('', 200) + : back()->with('status', 'password-updated'); + } +} diff --git a/src/Http/Controllers/PasswordResetLinkController.php b/src/Http/Controllers/PasswordResetLinkController.php new file mode 100644 index 00000000..0222f3c4 --- /dev/null +++ b/src/Http/Controllers/PasswordResetLinkController.php @@ -0,0 +1,58 @@ +validate(['email' => 'required|email']); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = $this->broker()->sendResetLink( + $request->only('email') + ); + + return $status == Password::RESET_LINK_SENT + ? app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => $status]) + : app(FailedPasswordResetLinkRequestResponse::class, ['status' => $status]); + } + + /** + * Get the broker to be used during password reset. + * + * @return \Illuminate\Contracts\Auth\PasswordBroker + */ + protected function broker(): PasswordBroker + { + return Password::broker(config('fortify.passwords')); + } +} diff --git a/src/Http/Controllers/ProfileInformationController.php b/src/Http/Controllers/ProfileInformationController.php new file mode 100644 index 00000000..be4c4631 --- /dev/null +++ b/src/Http/Controllers/ProfileInformationController.php @@ -0,0 +1,28 @@ +update($request->user(), $request->all()); + + return $request->wantsJson() + ? new Response('', 200) + : back()->with('status', 'profile-information-updated'); + } +} diff --git a/src/Http/Controllers/RecoveryCodeController.php b/src/Http/Controllers/RecoveryCodeController.php new file mode 100644 index 00000000..f6cca06b --- /dev/null +++ b/src/Http/Controllers/RecoveryCodeController.php @@ -0,0 +1,44 @@ +user()->two_factor_secret || + ! $request->user()->two_factor_recovery_codes) { + return []; + } + + return response()->json(json_decode(decrypt( + $request->user()->two_factor_recovery_codes + ), true)); + } + + /** + * Generate a fresh set of two factor authentication recovery codes. + * + * @param \Illuminate\Http\Request $request + * @param \Laravel\Fortify\Actions\GenerateNewRecoveryCodes $generate + * @return \Illuminate\Http\Response + */ + public function store(Request $request, GenerateNewRecoveryCodes $generate) + { + $generate($request->user()); + + return $request->wantsJson() + ? response('', 200) + : back()->with('status', 'recovery-codes-generated'); + } +} diff --git a/src/Http/Controllers/RegisteredUserController.php b/src/Http/Controllers/RegisteredUserController.php new file mode 100644 index 00000000..88b5876d --- /dev/null +++ b/src/Http/Controllers/RegisteredUserController.php @@ -0,0 +1,61 @@ +guard = $guard; + } + + /** + * Show the registration view. + * + * @param \Illuminate\Http\Request $request + * @return \Laravel\Fortify\Contracts\RegisterViewResponse + */ + public function create(Request $request): RegisterViewResponse + { + return app(RegisterViewResponse::class); + } + + /** + * Create a new registered user. + * + * @param \Illuminate\Http\Request $request + * @param \Laravel\Fortify\Contracts\ValidateUserRegistration $validate + * @param \Laravel\Fortify\Contracts\CreatesNewUsers $register + * @return \Laravel\Fortify\Contracts\RegisterResponse + */ + public function store(Request $request, + CreatesNewUsers $creator): RegisterResponse + { + event(new Registered($user = $creator->create($request->all()))); + + $this->guard->login($user); + + return app(RegisterResponse::class); + } +} diff --git a/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php b/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php new file mode 100644 index 00000000..8bccda4d --- /dev/null +++ b/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php @@ -0,0 +1,65 @@ +guard = $guard; + } + + /** + * Show the two factor authentication challenge view. + * + * @param \Illuminate\Http\Request $request + * @return \Laravel\Fortify\Contracts\TwoFactorChallengeViewResponse + */ + public function create(Request $request): TwoFactorChallengeViewResponse + { + return app(TwoFactorChallengeViewResponse::class); + } + + /** + * Attempt to authenticate a new session using the two factor authentication code. + * + * @param \Laravel\Fortify\Http\Requests\TwoFactorLoginRequest $request + * @return mixed + */ + public function store(TwoFactorLoginRequest $request) + { + $user = $request->challengedUser(); + + if ($code = $request->validRecoveryCode()) { + $user->replaceRecoveryCode($code); + } elseif (! $request->hasValidCode()) { + return app(FailedTwoFactorLoginResponse::class); + } + + $this->guard->login($user, $request->remember()); + + return app(TwoFactorLoginResponse::class); + } +} diff --git a/src/Http/Controllers/TwoFactorAuthenticationController.php b/src/Http/Controllers/TwoFactorAuthenticationController.php new file mode 100644 index 00000000..5fbd2144 --- /dev/null +++ b/src/Http/Controllers/TwoFactorAuthenticationController.php @@ -0,0 +1,43 @@ +user()); + + return $request->wantsJson() + ? response('', 200) + : back()->with('status', 'two-factor-authentication-enabled'); + } + + /** + * Disable two factor authentication for the user. + * + * @param \Illuminate\Http\Request $request + * @param \Laravel\Fortify\Actions\DisableTwoFactorAuthentication $disable + * @return \Illuminate\Http\Response + */ + public function destroy(Request $request, DisableTwoFactorAuthentication $disable) + { + $disable($request->user()); + + return $request->wantsJson() + ? response('', 200) + : back()->with('status', 'two-factor-authentication-disabled'); + } +} diff --git a/src/Http/Controllers/TwoFactorQrCodeController.php b/src/Http/Controllers/TwoFactorQrCodeController.php new file mode 100644 index 00000000..ff855a22 --- /dev/null +++ b/src/Http/Controllers/TwoFactorQrCodeController.php @@ -0,0 +1,20 @@ +json(['svg' => $request->user()->twoFactorQrCodeSvg()]); + } +} diff --git a/src/Http/Controllers/VerifyEmailController.php b/src/Http/Controllers/VerifyEmailController.php new file mode 100644 index 00000000..f9b4f77c --- /dev/null +++ b/src/Http/Controllers/VerifyEmailController.php @@ -0,0 +1,30 @@ +user()->hasVerifiedEmail()) { + return redirect(config('fortify.home').'?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect(config('fortify.home').'?verified=1'); + } +} diff --git a/src/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php new file mode 100644 index 00000000..0e040a07 --- /dev/null +++ b/src/Http/Requests/LoginRequest.php @@ -0,0 +1,32 @@ + 'required|string', + 'password' => 'required|string', + ]; + } +} diff --git a/src/Http/Requests/TwoFactorLoginRequest.php b/src/Http/Requests/TwoFactorLoginRequest.php new file mode 100644 index 00000000..cf5c085d --- /dev/null +++ b/src/Http/Requests/TwoFactorLoginRequest.php @@ -0,0 +1,114 @@ + 'nullable|string', + 'recovery_code' => 'nullable|string', + ]; + } + + /** + * Determine if the request has a valid two factor code. + * + * @return bool + */ + public function hasValidCode() + { + return $this->code && app(TwoFactorAuthenticationProvider::class)->verify( + decrypt($this->challengedUser()->two_factor_secret), $this->code + ); + } + + /** + * Get the valid recovery code if one exists on the request. + * + * @return string|null + */ + public function validRecoveryCode() + { + if (! $this->recovery_code) { + return; + } + + return collect($this->challengedUser()->recoveryCodes())->first(function ($code) { + return hash_equals($this->recovery_code, $code) ? $code : null; + }); + } + + /** + * Get the user that is attempting the two factor challenge. + * + * @return mixed + */ + public function challengedUser() + { + if ($this->challengedUser) { + return $this->challengedUser; + } + + $model = app(StatefulGuard::class)->getProvider()->getModel(); + + if (! $this->session()->has('login.id') || + ! $user = $model::find($this->session()->pull('login.id'))) { + throw new HttpResponseException( + app(FailedTwoFactorLoginResponse::class)->toResponse($this) + ); + } + + return $this->challengedUser = $user; + } + + /** + * Determine if the user wanted to be remembered after login. + * + * @return bool + */ + public function remember() + { + if (! $this->remember) { + $this->remember = $this->session()->pull('login.remember', false); + } + + return $this->remember; + } +} diff --git a/src/Http/Requests/VerifyEmailRequest.php b/src/Http/Requests/VerifyEmailRequest.php new file mode 100644 index 00000000..46439903 --- /dev/null +++ b/src/Http/Requests/VerifyEmailRequest.php @@ -0,0 +1,36 @@ +route('id'), (string) $this->user()->getKey())) { + return false; + } + + if (! hash_equals((string) $this->route('hash'), sha1($this->user()->getEmailForVerification()))) { + return false; + } + + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return []; + } +} diff --git a/src/Http/Responses/FailedPasswordResetLinkRequestResponse.php b/src/Http/Responses/FailedPasswordResetLinkRequestResponse.php new file mode 100644 index 00000000..8add3a4a --- /dev/null +++ b/src/Http/Responses/FailedPasswordResetLinkRequestResponse.php @@ -0,0 +1,46 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + if ($request->wantsJson()) { + throw ValidationException::withMessages([ + 'email' => [trans($this->status)], + ]); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => trans($this->status)]); + } +} diff --git a/src/Http/Responses/FailedPasswordResetResponse.php b/src/Http/Responses/FailedPasswordResetResponse.php new file mode 100644 index 00000000..7c698839 --- /dev/null +++ b/src/Http/Responses/FailedPasswordResetResponse.php @@ -0,0 +1,47 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + if ($request->wantsJson()) { + throw ValidationException::withMessages([ + 'email' => [trans($this->status)], + ]); + } + + return redirect()->back() + ->withInput($request->only('email')) + ->withErrors(['email' => trans($this->status)]); + } +} diff --git a/src/Http/Responses/FailedTwoFactorLoginResponse.php b/src/Http/Responses/FailedTwoFactorLoginResponse.php new file mode 100644 index 00000000..93620c32 --- /dev/null +++ b/src/Http/Responses/FailedTwoFactorLoginResponse.php @@ -0,0 +1,29 @@ +wantsJson()) { + throw ValidationException::withMessages([ + 'code' => [$message], + ]); + } + + return redirect()->route('login')->withErrors(['email' => $message]); + } +} diff --git a/src/Http/Responses/LockoutResponse.php b/src/Http/Responses/LockoutResponse.php new file mode 100644 index 00000000..0a10efc3 --- /dev/null +++ b/src/Http/Responses/LockoutResponse.php @@ -0,0 +1,50 @@ +limiter = $limiter; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return with($this->limiter->availableIn($request), function ($seconds) { + throw ValidationException::withMessages([ + Fortify::username() => [ + trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ], + ])->status(Response::HTTP_TOO_MANY_REQUESTS); + }); + } +} diff --git a/src/Http/Responses/LoginResponse.php b/src/Http/Responses/LoginResponse.php new file mode 100644 index 00000000..793e4060 --- /dev/null +++ b/src/Http/Responses/LoginResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? response()->json(['two_factor' => false]) + : redirect()->intended(config('fortify.home')); + } +} diff --git a/src/Http/Responses/LogoutResponse.php b/src/Http/Responses/LogoutResponse.php new file mode 100644 index 00000000..23e59e5f --- /dev/null +++ b/src/Http/Responses/LogoutResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? new Response('', 204) + : redirect('/'); + } +} diff --git a/src/Http/Responses/PasswordResetResponse.php b/src/Http/Responses/PasswordResetResponse.php new file mode 100644 index 00000000..6a4b4bcd --- /dev/null +++ b/src/Http/Responses/PasswordResetResponse.php @@ -0,0 +1,41 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return $request->wantsJson() + ? new JsonResponse(['message' => trans($this->status)], 200) + : redirect()->route('login')->with('status', trans($this->status)); + } +} diff --git a/src/Http/Responses/RegisterResponse.php b/src/Http/Responses/RegisterResponse.php new file mode 100644 index 00000000..e8920921 --- /dev/null +++ b/src/Http/Responses/RegisterResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? new Response('', 201) + : redirect(config('fortify.home')); + } +} diff --git a/src/Http/Responses/SimpleViewResponse.php b/src/Http/Responses/SimpleViewResponse.php new file mode 100644 index 00000000..d42f4527 --- /dev/null +++ b/src/Http/Responses/SimpleViewResponse.php @@ -0,0 +1,48 @@ +view = $view; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return view($this->view, ['request' => $request]); + } +} diff --git a/src/Http/Responses/SuccessfulPasswordResetLinkRequestResponse.php b/src/Http/Responses/SuccessfulPasswordResetLinkRequestResponse.php new file mode 100644 index 00000000..fb67890a --- /dev/null +++ b/src/Http/Responses/SuccessfulPasswordResetLinkRequestResponse.php @@ -0,0 +1,40 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return $request->wantsJson() + ? new JsonResponse(['message' => trans($this->status)], 200) + : back()->with('status', trans($this->status)); + } +} diff --git a/src/Http/Responses/TwoFactorLoginResponse.php b/src/Http/Responses/TwoFactorLoginResponse.php new file mode 100644 index 00000000..0774137f --- /dev/null +++ b/src/Http/Responses/TwoFactorLoginResponse.php @@ -0,0 +1,22 @@ +wantsJson() + ? response('', 204) + : redirect(config('fortify.home')); + } +} diff --git a/src/LoginRateLimiter.php b/src/LoginRateLimiter.php new file mode 100644 index 00000000..f74e4bda --- /dev/null +++ b/src/LoginRateLimiter.php @@ -0,0 +1,83 @@ +limiter = $limiter; + } + + /** + * Determine if the user has too many failed login attempts. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + public function tooManyAttempts(Request $request) + { + return $this->limiter->tooManyAttempts($this->throttleKey($request), 5); + } + + /** + * Increment the login attempts for the user. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + public function increment(Request $request) + { + $this->limiter->hit($this->throttleKey($request), 60); + } + + /** + * Determine the numebr of seconds until logging in is available again. + * + * @param \Illuminate\Http\Request $request + * @return int + */ + public function availableIn(Request $request) + { + $this->limiter->availableIn($this->throttleKey($request)); + } + + /** + * Clear the login locks for the given user credentials. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + public function clear(Request $request) + { + $this->limiter->clear($this->throttleKey($request)); + } + + /** + * Get the throttle key for the given request. + * + * @param \Illuminate\Http\Request $request + * @return string + */ + protected function throttleKey(Request $request) + { + return Str::lower($request->input(Fortify::username())).'|'.$request->ip(); + } +} diff --git a/src/RecoveryCode.php b/src/RecoveryCode.php new file mode 100644 index 00000000..54c4a29b --- /dev/null +++ b/src/RecoveryCode.php @@ -0,0 +1,18 @@ +requireUppercase && Str::lower($value) === $value) { + return false; + } + + if ($this->requireNumeric && ! preg_match('/[0-9]/', $value)) { + return false; + } + + return Str::length($value) >= $this->length; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message() + { + if ($this->message) { + return $this->message; + } + + if ($this->requireUppercase && ! $this->requireNumeric) { + return __('The :attribute must be at least '.$this->length.' characters and contain at least one uppercase character.'); + } elseif ($this->requireNumeric && ! $this->requireUppercase) { + return __('The :attribute must be at least '.$this->length.' characters and contain at least one number.'); + } elseif ($this->requireUppercase && $this->requireNumeric) { + return __('The :attribute must be at least '.$this->length.' characters and contain at least one uppercase character and number.'); + } else { + return __('The :attribute must be at least '.$this->length.' characters.'); + } + } + + /** + * Set the minimum length of the password. + * + * @param int $length + * @return $this + */ + public function length(int $length) + { + $this->length = $length; + + return $this; + } + + /** + * Indicate that at least one uppercase character is required. + * + * @return $this + */ + public function requireUppercase() + { + $this->requireUppercase = true; + + return $this; + } + + /** + * Indicate that at least one numeric digit is required. + * + * @return $this + */ + public function requireNumeric() + { + $this->requireNumeric = true; + + return $this; + } + + /** + * Set the message that should be used when the rule fails. + * + * @param string $message + * @return $this + */ + public function withMessage(string $message) + { + $this->message = $message; + + return $this; + } +} diff --git a/src/TwoFactorAuthenticatable.php b/src/TwoFactorAuthenticatable.php new file mode 100644 index 00000000..17aa4048 --- /dev/null +++ b/src/TwoFactorAuthenticatable.php @@ -0,0 +1,72 @@ +two_factor_recovery_codes), true); + } + + /** + * Replace the given recovery code with a new one in the user's stored codes. + * + * @param string $code + * @return void + */ + public function replaceRecoveryCode($code) + { + $this->forceFill([ + 'two_factor_recovery_codes' => encrypt(str_replace( + $code, + RecoveryCode::generate(), + decrypt($this->two_factor_recovery_codes) + )), + ])->save(); + } + + /** + * Get the QR code SVG of the user's two factor authentication QR code URL. + * + * @return string + */ + public function twoFactorQrCodeSvg() + { + $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. + * + * @return string + */ + public function twoFactorQrCodeUrl() + { + return app(TwoFactorAuthenticationProvider::class)->qrCodeUrl( + config('app.name'), + $this->email, + decrypt($this->two_factor_secret) + ); + } +} diff --git a/src/TwoFactorAuthenticationProvider.php b/src/TwoFactorAuthenticationProvider.php new file mode 100644 index 00000000..f45f5ec7 --- /dev/null +++ b/src/TwoFactorAuthenticationProvider.php @@ -0,0 +1,62 @@ +engine = $engine; + } + + /** + * Generate a new secret key. + * + * @return string + */ + public function generateSecretKey() + { + return $this->engine->generateSecretKey(); + } + + /** + * Get the two factor authentication QR code URL. + * + * @param string $companyName + * @param string $companyEmail + * @param string $secret + * @return string + */ + public function qrCodeUrl($companyName, $companyEmail, $secret) + { + return $this->engine->getQRCodeUrl($companyName, $companyEmail, $secret); + } + + /** + * Verify the given code. + * + * @param string $secret + * @param string $code + * @return bool + */ + public function verify($secret, $code) + { + return $this->engine->verifyKey($secret, $code); + } +} diff --git a/stubs/CreateNewUser.php b/stubs/CreateNewUser.php new file mode 100644 index 00000000..ce092915 --- /dev/null +++ b/stubs/CreateNewUser.php @@ -0,0 +1,33 @@ + ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['required', 'string', new Password, 'confirmed'], + ])->validate(); + + return User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + } +} diff --git a/stubs/FortifyServiceProvider.php b/stubs/FortifyServiceProvider.php new file mode 100644 index 00000000..38c4055b --- /dev/null +++ b/stubs/FortifyServiceProvider.php @@ -0,0 +1,36 @@ + $this->passwordRules(), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/stubs/UpdateUserPassword.php b/stubs/UpdateUserPassword.php new file mode 100644 index 00000000..c469dd6c --- /dev/null +++ b/stubs/UpdateUserPassword.php @@ -0,0 +1,36 @@ + ['required', 'string'], + 'password' => $this->passwordRules(), + ])->after(function ($validator) use ($user, $input) { + if (! Hash::check($input['current_password'], $user->password)) { + $validator->errors()->add('current_password', __('The provided password does not match your current password.')); + } + })->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/stubs/UpdateUserProfileInformation.php b/stubs/UpdateUserProfileInformation.php new file mode 100644 index 00000000..2208cbbe --- /dev/null +++ b/stubs/UpdateUserProfileInformation.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users')->ignore($user->id), + ], + ])->validateWithBag('updateProfileInformation'); + + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } +} diff --git a/stubs/fortify.php b/stubs/fortify.php new file mode 100644 index 00000000..4dbed281 --- /dev/null +++ b/stubs/fortify.php @@ -0,0 +1,95 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "useranme" 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 during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => RouteServiceProvider::HOME, + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + // Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication(), + ], + +]; diff --git a/tests/AuthenticatedSessionControllerTest.php b/tests/AuthenticatedSessionControllerTest.php new file mode 100644 index 00000000..598735d2 --- /dev/null +++ b/tests/AuthenticatedSessionControllerTest.php @@ -0,0 +1,242 @@ +mock(LoginViewResponse::class) + ->shouldReceive('toResponse') + ->andReturn(response('hello world')); + + $response = $this->get('/login'); + + $response->assertStatus(200); + $response->assertSeeText('hello world'); + } + + public function test_user_can_authenticate() + { + $this->loadLaravelMigrations(['--database' => 'testbench']); + + $user = TestAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + ]); + + $response = $this->withoutExceptionHandling()->post('/login', [ + 'email' => 'taylor@laravel.com', + 'password' => 'secret', + ]); + + $response->assertRedirect('/home'); + } + + public function test_user_is_redirected_to_challenge_when_using_two_factor_authentication() + { + app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class); + + $this->loadLaravelMigrations(['--database' => 'testbench']); + + Schema::table('users', function ($table) { + $table->text('two_factor_secret')->nullable(); + }); + + $user = TestTwoFactorAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + 'two_factor_secret' => 'test-secret', + ]); + + $response = $this->withoutExceptionHandling()->post('/login', [ + 'email' => 'taylor@laravel.com', + 'password' => 'secret', + ]); + + $response->assertRedirect('/two-factor-challenge'); + } + + public function test_validation_exception_returned_on_failure() + { + $this->loadLaravelMigrations(['--database' => 'testbench']); + + $user = TestAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + ]); + + $response = $this->post('/login', [ + 'email' => 'taylor@laravel.com', + 'password' => 'password', + ]); + + $response->assertStatus(302); + $response->assertSessionHasErrors(['email']); + } + + public function test_login_attempts_are_throttled() + { + $this->mock(LoginRateLimiter::class, function ($mock) { + $mock->shouldReceive('tooManyAttempts')->andReturn(true); + $mock->shouldReceive('availableIn')->andReturn(10); + }); + + $response = $this->postJson('/login', [ + 'email' => 'taylor@laravel.com', + 'password' => 'secret', + ]); + + $response->assertStatus(429); + $response->assertJsonValidationErrors(['email']); + } + + public function test_the_user_can_logout_of_the_application() + { + Auth::guard()->setUser( + Mockery::mock(Authenticatable::class)->shouldIgnoreMissing() + ); + + $response = $this->post('/logout'); + + $response->assertRedirect('/'); + $this->assertNull(Auth::guard()->getUser()); + } + + public function test_the_user_can_logout_of_the_application_using_json_request() + { + Auth::guard()->setUser( + Mockery::mock(Authenticatable::class)->shouldIgnoreMissing() + ); + + $response = $this->postJson('/logout'); + + $response->assertStatus(204); + $this->assertNull(Auth::guard()->getUser()); + } + + public function test_two_factor_challenge_can_be_passed_via_code() + { + app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class); + + $this->loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $this->mock(TwoFactorAuthenticationProvider::class, function ($mock) { + $mock->shouldReceive('verify')->andReturn(true); + }); + + $user = TestTwoFactorAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + 'two_factor_secret' => encrypt('test-secret'), + ]); + + $response = $this->withSession([ + 'login.id' => $user->id, + 'login.remember' => false, + ])->withoutExceptionHandling()->post('/two-factor-challenge', [ + 'code' => '123456', + ]); + + $response->assertRedirect('/home'); + } + + public function test_two_factor_challenge_can_be_passed_via_recovery_code() + { + app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class); + + $this->loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $user = TestTwoFactorAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['invalid-code', 'valid-code'])), + ]); + + $response = $this->withSession([ + 'login.id' => $user->id, + 'login.remember' => false, + ])->withoutExceptionHandling()->post('/two-factor-challenge', [ + 'recovery_code' => 'valid-code', + ]); + + $response->assertRedirect('/home'); + $this->assertNotNull(Auth::getUser()); + $this->assertFalse(in_array('valid-code', json_decode(decrypt($user->fresh()->two_factor_recovery_codes), true))); + } + + public function test_two_factor_challenge_can_fail_via_recovery_code() + { + app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class); + + $this->loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $user = TestTwoFactorAuthenticationSessionUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['invalid-code', 'valid-code'])), + ]); + + $response = $this->withSession([ + 'login.id' => $user->id, + 'login.remember' => false, + ])->withoutExceptionHandling()->post('/two-factor-challenge', [ + 'recovery_code' => 'missing-code', + ]); + + $response->assertRedirect('/login'); + $this->assertNull(Auth::getUser()); + } + + protected function getPackageProviders($app) + { + return [FortifyServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app) + { + $app['migrator']->path(__DIR__.'/../database/migrations'); + + $app['config']->set('auth.providers.users.model', TestAuthenticationSessionUser::class); + + $app['config']->set('database.default', 'testbench'); + + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} + +class TestAuthenticationSessionUser extends User +{ + protected $table = 'users'; +} + +class TestTwoFactorAuthenticationSessionUser extends User +{ + use TwoFactorAuthenticatable; + + protected $table = 'users'; +} diff --git a/tests/EmailVerificationNotificationControllerTest.php b/tests/EmailVerificationNotificationControllerTest.php new file mode 100644 index 00000000..87d617f5 --- /dev/null +++ b/tests/EmailVerificationNotificationControllerTest.php @@ -0,0 +1,39 @@ +shouldReceive('hasVerifiedEmail')->andReturn(false); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('sendEmailVerificationNotification')->once(); + + $response = $this->from('/email/verify') + ->actingAs($user) + ->post('/email/verification-notification'); + + $response->assertRedirect('/email/verify'); + } + + public function test_user_is_redirect_if_already_verified() + { + $user = Mockery::mock(Authenticatable::class); + + $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('sendEmailVerificationNotification')->never(); + + $response = $this->from('/email/verify') + ->actingAs($user) + ->post('/email/verification-notification'); + + $response->assertRedirect('/home'); + } +} diff --git a/tests/EmailVerificationPromptControllerTest.php b/tests/EmailVerificationPromptControllerTest.php new file mode 100644 index 00000000..c12cddf5 --- /dev/null +++ b/tests/EmailVerificationPromptControllerTest.php @@ -0,0 +1,25 @@ +mock(VerifyEmailViewResponse::class) + ->shouldReceive('toResponse') + ->andReturn(response('hello world')); + + $user = Mockery::mock(Authenticatable::class); + $user->shouldReceive('hasVerifiedEmail')->andReturn(false); + + $response = $this->actingAs($user)->get('/email/verify'); + + $response->assertStatus(200); + $response->assertSeeText('hello world'); + } +} diff --git a/tests/NewPasswordControllerTest.php b/tests/NewPasswordControllerTest.php new file mode 100644 index 00000000..0f1ba465 --- /dev/null +++ b/tests/NewPasswordControllerTest.php @@ -0,0 +1,102 @@ +mock(ResetPasswordViewResponse::class) + ->shouldReceive('toResponse') + ->andReturn(response('hello world')); + + $response = $this->get('/reset-password/token'); + + $response->assertStatus(200); + $response->assertSeeText('hello world'); + } + + public function test_password_can_be_reset() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $guard = $this->mock(StatefulGuard::class); + $user = Mockery::mock(Authenticatable::class); + + $user->shouldReceive('setRememberToken')->once(); + $user->shouldReceive('save')->once(); + + $guard->shouldReceive('login')->never(); + + $updater = $this->mock(ResetsUserPasswords::class); + $updater->shouldReceive('reset')->once()->with($user, Mockery::type('array')); + + $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) use ($user) { + $callback($user, 'password'); + + return Password::PASSWORD_RESET; + }); + + $response = $this->withoutExceptionHandling()->post('/reset-password', [ + 'token' => 'token', + 'email' => 'taylor@laravel.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertStatus(302); + $response->assertRedirect('/login'); + } + + public function test_password_reset_can_fail() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $guard = $this->mock(StatefulGuard::class); + $user = Mockery::mock(Authenticatable::class); + + $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { + return Password::INVALID_TOKEN; + }); + + $response = $this->withoutExceptionHandling()->post('/reset-password', [ + 'token' => 'token', + 'email' => 'taylor@laravel.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertStatus(302); + $response->assertSessionHasErrors('email'); + } + + public function test_password_reset_can_fail_with_json() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $guard = $this->mock(StatefulGuard::class); + $user = Mockery::mock(Authenticatable::class); + + $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { + return Password::INVALID_TOKEN; + }); + + $response = $this->postJson('/reset-password', [ + 'token' => 'token', + 'email' => 'taylor@laravel.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } +} diff --git a/tests/OrchestraTestCase.php b/tests/OrchestraTestCase.php new file mode 100644 index 00000000..1fc3f13f --- /dev/null +++ b/tests/OrchestraTestCase.php @@ -0,0 +1,25 @@ +password = Hash::make('password'); + + $this->mock(UpdatesUserPasswords::class) + ->shouldReceive('update') + ->once(); + + $response = $this->withoutExceptionHandling()->actingAs($user)->putJson('/user/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response->assertStatus(200); + } +} diff --git a/tests/PasswordResetLinkRequestControllerTest.php b/tests/PasswordResetLinkRequestControllerTest.php new file mode 100644 index 00000000..9ab78dc5 --- /dev/null +++ b/tests/PasswordResetLinkRequestControllerTest.php @@ -0,0 +1,65 @@ +mock(RequestPasswordResetLinkViewResponse::class) + ->shouldReceive('toResponse') + ->andReturn(response('hello world')); + + $response = $this->get('/forgot-password'); + + $response->assertStatus(200); + $response->assertSeeText('hello world'); + } + + public function test_reset_link_can_be_successfully_requested() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + + $response = $this->from(url('/forgot-password')) + ->post('/forgot-password', ['email' => 'taylor@laravel.com']); + + $response->assertStatus(302); + $response->assertRedirect('/forgot-password'); + $response->assertSessionHasNoErrors(); + $response->assertSessionHas('status', trans(Password::RESET_LINK_SENT)); + } + + public function test_reset_link_request_can_fail() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + + $response = $this->from(url('/forgot-password')) + ->post('/forgot-password', ['email' => 'taylor@laravel.com']); + + $response->assertStatus(302); + $response->assertRedirect('/forgot-password'); + $response->assertSessionHasErrors('email'); + } + + public function test_reset_link_request_can_fail_with_json() + { + Password::shouldReceive('broker')->andReturn($broker = Mockery::mock(PasswordBroker::class)); + + $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + + $response = $this->from(url('/forgot-password')) + ->postJson('/forgot-password', ['email' => 'taylor@laravel.com']); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } +} diff --git a/tests/PasswordRuleTest.php b/tests/PasswordRuleTest.php new file mode 100644 index 00000000..0fa4fcd2 --- /dev/null +++ b/tests/PasswordRuleTest.php @@ -0,0 +1,41 @@ +assertTrue($rule->passes('password', 'password')); + $this->assertFalse($rule->passes('password', 'secret')); + + $this->assertTrue(Str::contains($rule->message(), 'must be at least 8 characters')); + + $rule->length(10); + + $this->assertFalse($rule->passes('password', 'password')); + $this->assertTrue($rule->passes('password', 'password11')); + + $this->assertTrue(Str::contains($rule->message(), 'must be at least 10 characters')); + + $rule->length(8)->requireUppercase(); + + $this->assertFalse($rule->passes('password', 'password')); + $this->assertTrue($rule->passes('password', 'Password')); + + $this->assertTrue(Str::contains($rule->message(), 'characters and contain at least one uppercase character')); + + $rule->length(8)->requireNumeric(); + + $this->assertFalse($rule->passes('password', 'Password')); + $this->assertFalse($rule->passes('password', 'password1')); + $this->assertTrue($rule->passes('password', 'Password1')); + + $this->assertTrue(Str::contains($rule->message(), 'characters and contain at least one uppercase character and number')); + } +} diff --git a/tests/ProfileInformationControllerTest.php b/tests/ProfileInformationControllerTest.php new file mode 100644 index 00000000..52b4c71e --- /dev/null +++ b/tests/ProfileInformationControllerTest.php @@ -0,0 +1,26 @@ +mock(UpdatesUserProfileInformation::class) + ->shouldReceive('update') + ->once(); + + $response = $this->withoutExceptionHandling()->actingAs($user)->putJson('/user/profile-information', [ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + ]); + + $response->assertStatus(200); + } +} diff --git a/tests/RecoveryCodeControllerTest.php b/tests/RecoveryCodeControllerTest.php new file mode 100644 index 00000000..a5a745c4 --- /dev/null +++ b/tests/RecoveryCodeControllerTest.php @@ -0,0 +1,55 @@ +loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $user = TestTwoFactorRecoveryCodeUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + ]); + + $response = $this->withoutExceptionHandling()->actingAs($user)->postJson( + '/user/two-factor-recovery-codes' + ); + + $response->assertStatus(200); + + $user->fresh(); + + $this->assertNotNull($user->two_factor_recovery_codes); + $this->assertTrue(is_array(json_decode(decrypt($user->two_factor_recovery_codes), true))); + } + + protected function getPackageProviders($app) + { + return [FortifyServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app) + { + $app['migrator']->path(__DIR__.'/../database/migrations'); + + $app['config']->set('database.default', 'testbench'); + + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} + +class TestTwoFactorRecoveryCodeUser extends User +{ + protected $table = 'users'; +} diff --git a/tests/RegisteredUserControllerTest.php b/tests/RegisteredUserControllerTest.php new file mode 100644 index 00000000..c7dfad44 --- /dev/null +++ b/tests/RegisteredUserControllerTest.php @@ -0,0 +1,39 @@ +mock(RegisterViewResponse::class) + ->shouldReceive('toResponse') + ->andReturn(response('hello world')); + + $response = $this->get('/register'); + + $response->assertStatus(200); + $response->assertSeeText('hello world'); + } + + public function test_users_can_be_created() + { + $this->mock(CreatesNewUsers::class) + ->shouldReceive('create') + ->andReturn(Mockery::mock(Authenticatable::class)); + + $this->mock(StatefulGuard::class) + ->shouldReceive('login') + ->once(); + + $response = $this->post('/register', []); + + $response->assertRedirect('/home'); + } +} diff --git a/tests/TwoFactorAuthenticationControllerTest.php b/tests/TwoFactorAuthenticationControllerTest.php new file mode 100644 index 00000000..16913f62 --- /dev/null +++ b/tests/TwoFactorAuthenticationControllerTest.php @@ -0,0 +1,85 @@ +loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $user = TestTwoFactorAuthenticationUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + ]); + + $response = $this->withoutExceptionHandling()->actingAs($user)->postJson( + '/user/two-factor-authentication' + ); + + $response->assertStatus(200); + + $user->fresh(); + + $this->assertNotNull($user->two_factor_secret); + $this->assertNotNull($user->two_factor_recovery_codes); + $this->assertTrue(is_array(json_decode(decrypt($user->two_factor_recovery_codes), true))); + $this->assertNotNull($user->twoFactorQrCodeSvg()); + } + + public function test_two_factor_authentication_can_be_disabled() + { + $this->loadLaravelMigrations(['--database' => 'testbench']); + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $user = TestTwoFactorAuthenticationUser::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'password' => bcrypt('secret'), + 'two_factor_secret' => encrypt('foo'), + 'two_factor_recovery_codes' => encrypt(json_encode([])), + ]); + + $response = $this->withoutExceptionHandling()->actingAs($user)->deleteJson( + '/user/two-factor-authentication' + ); + + $response->assertStatus(200); + + $user->fresh(); + + $this->assertNull($user->two_factor_secret); + $this->assertNull($user->two_factor_recovery_codes); + } + + protected function getPackageProviders($app) + { + return [FortifyServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app) + { + $app['migrator']->path(__DIR__.'/../database/migrations'); + + $app['config']->set('database.default', 'testbench'); + + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} + +class TestTwoFactorAuthenticationUser extends User +{ + use TwoFactorAuthenticatable; + + protected $table = 'users'; +} diff --git a/tests/VerifyEmailControllerTest.php b/tests/VerifyEmailControllerTest.php new file mode 100644 index 00000000..9f839f4a --- /dev/null +++ b/tests/VerifyEmailControllerTest.php @@ -0,0 +1,98 @@ +addMinutes(60), + [ + 'id' => 1, + 'hash' => sha1('taylor@laravel.com'), + ] + ); + + $user = Mockery::mock(Authenticatable::class); + $user->shouldReceive('getKey')->andReturn(1); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); + $user->shouldReceive('hasVerifiedEmail')->andReturn(false); + $user->shouldReceive('markEmailAsVerified')->once(); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(302); + } + + public function test_redirected_if_email_is_already_verified() + { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + [ + 'id' => 1, + 'hash' => sha1('taylor@laravel.com'), + ] + ); + + $user = Mockery::mock(Authenticatable::class); + $user->shouldReceive('getKey')->andReturn(1); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); + $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user->shouldReceive('markEmailAsVerified')->never(); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(302); + } + + public function test_email_is_not_verified_if_id_does_not_match() + { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + [ + 'id' => 2, + 'hash' => sha1('taylor@laravel.com'), + ] + ); + + $user = Mockery::mock(Authenticatable::class); + $user->shouldReceive('getKey')->andReturn(1); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(403); + } + + public function test_email_is_not_verified_if_email_does_not_match() + { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + [ + 'id' => 1, + 'hash' => sha1('abigail@laravel.com'), + ] + ); + + $user = Mockery::mock(Authenticatable::class); + $user->shouldReceive('getKey')->andReturn(1); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(403); + } +}