From 2e4606eaf947036483a815122f194f6468eb52d5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Apr 2025 12:46:22 +0100 Subject: [PATCH 01/45] Bring Mity Digital's 2FA addon into Core --- composer.json | 2 + config/users.php | 95 ++++++++++++ resources/js/bootstrap/fieldtypes.js | 4 + .../fieldtypes/TwoFactorFieldtype.vue | 87 +++++++++++ .../fieldtypes/TwoFactorIndexFieldtype.vue | 48 ++++++ .../fieldtypes/two-factor/Enable.vue | 26 ++++ .../fieldtypes/two-factor/Locked.vue | 84 ++++++++++ .../fieldtypes/two-factor/RecoveryCodes.vue | 143 ++++++++++++++++++ .../fieldtypes/two-factor/Reset.vue | 100 ++++++++++++ .../views/auth/two-factor/challenge.blade.php | 82 ++++++++++ .../views/auth/two-factor/locked.blade.php | 29 ++++ .../auth/two-factor/recovery-codes.blade.php | 41 +++++ .../views/auth/two-factor/setup.blade.php | 83 ++++++++++ routes/cp.php | 24 +++ .../ChallengeTwoFactorAuthentication.php | 84 ++++++++++ .../CompleteTwoFactorAuthenticationSetup.php | 15 ++ .../ConfirmTwoFactorAuthentication.php | 32 ++++ src/Auth/TwoFactor/CreateRecoveryCodes.php | 20 +++ .../DisableTwoFactorAuthentication.php | 23 +++ .../EnableTwoFactorAuthentication.php | 25 +++ src/Auth/TwoFactor/Google2FA.php | 71 +++++++++ src/Auth/TwoFactor/RecoveryCode.php | 13 ++ src/Auth/TwoFactor/StatamicTwoFactorUser.php | 124 +++++++++++++++ src/Auth/TwoFactor/UnlockUser.php | 15 ++ src/Auth/UserRepository.php | 7 + .../stubs/auth/create_users_table.php.stub | 7 + .../stubs/auth/update_users_table.php.stub | 7 + .../InvalidChallengeModeException.php | 8 + src/Exceptions/TwoFactorNotSetupException.php | 8 + src/Facades/TwoFactorUser.php | 23 +++ src/Fieldtypes/TwoFactor.php | 101 +++++++++++++ .../CP/Auth/Concerns/GetsReferrerUrl.php | 46 ++++++ .../Controllers/CP/Auth/LoginController.php | 12 ++ .../CP/Auth/TwoFactorChallengeController.php | 64 ++++++++ .../CP/Auth/TwoFactorLockedUserController.php | 32 ++++ .../CP/Auth/TwoFactorSetupController.php | 56 +++++++ .../TwoFactorRecoveryCodesController.php | 45 ++++++ .../Users/TwoFactorUserLockedController.php | 27 ++++ .../CP/Users/TwoFactorUserResetController.php | 37 +++++ src/Http/Middleware/CP/EnforceTwoFactor.php | 66 ++++++++ ...pAvailableWhenTwoFactorSetupIncomplete.php | 36 +++++ src/Listeners/TwoFactorUserSavedListener.php | 34 +++++ src/Notifications/RecoveryCodeUsed.php | 48 ++++++ src/Providers/AuthServiceProvider.php | 13 ++ src/Providers/CpServiceProvider.php | 1 + src/Providers/ExtensionServiceProvider.php | 1 + 46 files changed, 1949 insertions(+) create mode 100644 resources/js/components/fieldtypes/TwoFactorFieldtype.vue create mode 100644 resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue create mode 100644 resources/js/components/fieldtypes/two-factor/Enable.vue create mode 100644 resources/js/components/fieldtypes/two-factor/Locked.vue create mode 100644 resources/js/components/fieldtypes/two-factor/RecoveryCodes.vue create mode 100644 resources/js/components/fieldtypes/two-factor/Reset.vue create mode 100644 resources/views/auth/two-factor/challenge.blade.php create mode 100644 resources/views/auth/two-factor/locked.blade.php create mode 100644 resources/views/auth/two-factor/recovery-codes.blade.php create mode 100644 resources/views/auth/two-factor/setup.blade.php create mode 100644 src/Auth/TwoFactor/ChallengeTwoFactorAuthentication.php create mode 100644 src/Auth/TwoFactor/CompleteTwoFactorAuthenticationSetup.php create mode 100644 src/Auth/TwoFactor/ConfirmTwoFactorAuthentication.php create mode 100644 src/Auth/TwoFactor/CreateRecoveryCodes.php create mode 100644 src/Auth/TwoFactor/DisableTwoFactorAuthentication.php create mode 100644 src/Auth/TwoFactor/EnableTwoFactorAuthentication.php create mode 100644 src/Auth/TwoFactor/Google2FA.php create mode 100644 src/Auth/TwoFactor/RecoveryCode.php create mode 100644 src/Auth/TwoFactor/StatamicTwoFactorUser.php create mode 100644 src/Auth/TwoFactor/UnlockUser.php create mode 100644 src/Exceptions/InvalidChallengeModeException.php create mode 100644 src/Exceptions/TwoFactorNotSetupException.php create mode 100644 src/Facades/TwoFactorUser.php create mode 100644 src/Fieldtypes/TwoFactor.php create mode 100644 src/Http/Controllers/CP/Auth/Concerns/GetsReferrerUrl.php create mode 100644 src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php create mode 100644 src/Http/Controllers/CP/Auth/TwoFactorLockedUserController.php create mode 100644 src/Http/Controllers/CP/Auth/TwoFactorSetupController.php create mode 100644 src/Http/Controllers/CP/Users/TwoFactorRecoveryCodesController.php create mode 100644 src/Http/Controllers/CP/Users/TwoFactorUserLockedController.php create mode 100644 src/Http/Controllers/CP/Users/TwoFactorUserResetController.php create mode 100644 src/Http/Middleware/CP/EnforceTwoFactor.php create mode 100644 src/Http/Middleware/CP/SetupAvailableWhenTwoFactorSetupIncomplete.php create mode 100644 src/Listeners/TwoFactorUserSavedListener.php create mode 100644 src/Notifications/RecoveryCodeUsed.php diff --git a/composer.json b/composer.json index dd160ed5a1..7314d320d3 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "ext-json": "*", "ajthinking/archetype": "^1.0.3 || ^2.0", + "bacon/bacon-qr-code": "^3.0", "composer/semver": "^3.4", "guzzlehttp/guzzle": "^6.3 || ^7.0", "james-heinrich/getid3": "^1.9.21", @@ -23,6 +24,7 @@ "michelf/php-smartypants": "^1.8.1", "nesbot/carbon": "^3.0", "pixelfear/composer-dist-plugin": "^0.1.4", + "pragmarx/google2fa": "^8.0", "rebing/graphql-laravel": "^9.8", "rhukster/dom-sanitizer": "^1.0.6", "spatie/blink": "^1.3", diff --git a/config/users.php b/config/users.php index 7937f09f91..efed00c6cd 100644 --- a/config/users.php +++ b/config/users.php @@ -167,6 +167,101 @@ 'redirect' => env('STATAMIC_IMPERSONATE_REDIRECT', null), ], + /* + |-------------------------------------------------------------------------- + | Two Factor Authentication + |-------------------------------------------------------------------------- + | + | ... + | + */ + + 'two_factor' => [ + + /* + |-------------------------------------------------------------------------- + | Is two factor enabled? + |-------------------------------------------------------------------------- + | + | When enabled, two factor authentication challenges will be presented to + | users of the Statamic CP. This will direct them to a setup screen on + | their next page visit, or the next time they sign in. + | + */ + + 'enabled' => env('STATAMIC_TWO_FACTOR_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Role-specific enforcement + |-------------------------------------------------------------------------- + | + | Super admins will always require two factor. + | + | Provide an array of Role handles that should have two factor enforced, + | such as: + | 'enforced_roles' => [ + | 'content_publisher', + | 'users_admin', + | ], + | + | An empty array will mean that no roles are enforced. + | + | Set to null to enforce for all roles. + | + */ + + 'enforced_roles' => null, + + /* + |-------------------------------------------------------------------------- + | Blueprint field + |-------------------------------------------------------------------------- + | + | The name of the blueprint field handle for the status storage of the + | user's two factor authentication status (setup and locked). + | + */ + + 'blueprint' => 'two_factor', + + /* + |-------------------------------------------------------------------------- + | Number of incorrect two factor code attempts + |-------------------------------------------------------------------------- + | + | Only a specific number of incorrect attempts are allowed. This helps by + | locking an account to prevent a bot from brute forcing their way in. + | This count is incremented on each incorrect code or recovery code + | attempt. When a challenge is successfully completed, the value + | resets to zero. + | + | Default: 5 + | + */ + + 'attempts' => env('STATAMIC_TWO_FACTOR_ATTEMPTS_ALLOWED', 5), + + /* + |-------------------------------------------------------------------------- + | Two factor code validity + |-------------------------------------------------------------------------- + | + | The code validity will keep tabs on the last time the user was asked to + | complete a two factor challenge. When this period expires, they will + | be asked to complete another challenge. Stored as the number of + | minutes. + | + | Default: 43200 minutes (30 days) + | + | Set to null to disable this feature. + | + */ + + 'validity' => env('STATAMIC_TWO_FACTOR_VALIDITY', 43200), + + ], + /* |-------------------------------------------------------------------------- | Default Sorting diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js index 3613dc781c..feb0930f56 100644 --- a/resources/js/bootstrap/fieldtypes.js +++ b/resources/js/bootstrap/fieldtypes.js @@ -56,6 +56,8 @@ import TagsIndexFieldtype from '../components/fieldtypes/TagsIndexFieldtype.vue' import TemplateFolderFieldtype from '../components/fieldtypes/TemplateFolderFieldtype.vue'; import ToggleFieldtype from '../components/fieldtypes/ToggleFieldtype.vue'; import ToggleIndexFieldtype from '../components/fieldtypes/ToggleIndexFieldtype.vue'; +import TwoFactorFieldtype from '../components/fieldtypes/TwoFactorFieldtype.vue'; +import TwoFactorIndexFieldtype from '../components/fieldtypes/TwoFactorIndexFieldtype.vue'; import WidthFieldtype from '../components/fieldtypes/WidthFieldtype.vue'; import VideoFieldtype from '../components/fieldtypes/VideoFieldtype.vue'; import SetPicker from '../components/fieldtypes/replicator/SetPicker.vue'; @@ -138,6 +140,8 @@ export default function registerFieldtypes(app) { ); app.component('toggle-fieldtype', ToggleFieldtype); app.component('toggle-fieldtype-index', ToggleIndexFieldtype); + app.component('two_factor-fieldtype', TwoFactorFieldtype); + app.component('two_factor-index-fieldtype', TwoFactorIndexFieldtype); app.component('width-fieldtype', WidthFieldtype); app.component('video-fieldtype', VideoFieldtype); app.component( diff --git a/resources/js/components/fieldtypes/TwoFactorFieldtype.vue b/resources/js/components/fieldtypes/TwoFactorFieldtype.vue new file mode 100644 index 0000000000..93141eb7a7 --- /dev/null +++ b/resources/js/components/fieldtypes/TwoFactorFieldtype.vue @@ -0,0 +1,87 @@ + + + diff --git a/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue b/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue new file mode 100644 index 0000000000..a4dccddab6 --- /dev/null +++ b/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/components/fieldtypes/two-factor/Enable.vue b/resources/js/components/fieldtypes/two-factor/Enable.vue new file mode 100644 index 0000000000..a9dd474101 --- /dev/null +++ b/resources/js/components/fieldtypes/two-factor/Enable.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/components/fieldtypes/two-factor/Locked.vue b/resources/js/components/fieldtypes/two-factor/Locked.vue new file mode 100644 index 0000000000..eed4bcf8c6 --- /dev/null +++ b/resources/js/components/fieldtypes/two-factor/Locked.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/js/components/fieldtypes/two-factor/RecoveryCodes.vue b/resources/js/components/fieldtypes/two-factor/RecoveryCodes.vue new file mode 100644 index 0000000000..fb508b019b --- /dev/null +++ b/resources/js/components/fieldtypes/two-factor/RecoveryCodes.vue @@ -0,0 +1,143 @@ + + + diff --git a/resources/js/components/fieldtypes/two-factor/Reset.vue b/resources/js/components/fieldtypes/two-factor/Reset.vue new file mode 100644 index 0000000000..a8d216071c --- /dev/null +++ b/resources/js/components/fieldtypes/two-factor/Reset.vue @@ -0,0 +1,100 @@ + + + diff --git a/resources/views/auth/two-factor/challenge.blade.php b/resources/views/auth/two-factor/challenge.blade.php new file mode 100644 index 0000000000..8d68a88465 --- /dev/null +++ b/resources/views/auth/two-factor/challenge.blade.php @@ -0,0 +1,82 @@ +@extends('statamic::outside') +@section('title', __('statamic-two-factor::challenge.title')) + +@section('content') + + @include('statamic::partials.outside-logo') +
+
+ +
+ +
+

{{ __('statamic-two-factor::challenge.title') }}

+

{{ __('statamic-two-factor::challenge.code_introduction') }}

+

{{ __('statamic-two-factor::challenge.recovery_code_introduction') }}

+
+ +
+ +
+ {!! csrf_field() !!} + + +
+ + + @error('code') +
{{ $message }}
@enderror +
+ +
+ + + @error('recovery_code') +
{{ $message }}
@enderror +
+ +
+ + + + + + +
+ +
+
+
+ + + +
+
+ +@endsection diff --git a/resources/views/auth/two-factor/locked.blade.php b/resources/views/auth/two-factor/locked.blade.php new file mode 100644 index 0000000000..3a6c3b28b6 --- /dev/null +++ b/resources/views/auth/two-factor/locked.blade.php @@ -0,0 +1,29 @@ +@extends('statamic::outside') +@section('title', __('statamic-two-factor::locked.title')) + +@section('content') + + @include('statamic::partials.outside-logo') +
+
+ +
+ +
+

{{ __('statamic-two-factor::locked.title') }}

+

{{ __('statamic-two-factor::locked.introduction') }}

+
+ +
+ +
+ +
+ +
+
+ +@endsection diff --git a/resources/views/auth/two-factor/recovery-codes.blade.php b/resources/views/auth/two-factor/recovery-codes.blade.php new file mode 100644 index 0000000000..bcb3bc0352 --- /dev/null +++ b/resources/views/auth/two-factor/recovery-codes.blade.php @@ -0,0 +1,41 @@ +@extends('statamic::outside') +@section('title', __('statamic-two-factor::recovery-codes.title')) + +@section('content') + + @include('statamic::partials.outside-logo') +
+
+ +
+ +
+

{{ __('statamic-two-factor::recovery-codes.title') }}

+

{{ __('statamic-two-factor::recovery-codes.introduction') }}

+
+ +
+
+ @foreach ($recovery_codes as $recovery_code) +
{{ $recovery_code }}
+ @endforeach +
+ + +
+ {!! csrf_field() !!} + +
+
+ +
+
+
+ +
+
+
+ +@endsection diff --git a/resources/views/auth/two-factor/setup.blade.php b/resources/views/auth/two-factor/setup.blade.php new file mode 100644 index 0000000000..69e917b36e --- /dev/null +++ b/resources/views/auth/two-factor/setup.blade.php @@ -0,0 +1,83 @@ +@extends('statamic::outside') +@section('title', __('statamic-two-factor::setup.title')) + +@section('content') + + @include('statamic::partials.outside-logo') +
+
+ +
+ +
+

{{ __('statamic-two-factor::setup.title') }}

+
+
+ {!! csrf_field() !!} + +
+ +
+
+ {!! $qr !!} +
+ +
+
{{ __('statamic-two-factor::setup.code') }}:
+
{{ $secret_key }}
+
+
+ +
+
+

{{ __('statamic-two-factor::setup.introduction') }}

+
+ +
+
+ {!! $qr !!} +
+ +
+
{{ __('statamic-two-factor::setup.code') }}:
+
{{ $secret_key }}
+
+
+ +
+ + + @error('code') +
{{ $message }}
@enderror +
+ +
+
+ +
+ @if ($cancellable) + {{ __('statamic-two-factor::setup.cancel') }} + @endif + +
+
+
+
+ +
+
+ + + +
+
+@endsection diff --git a/routes/cp.php b/routes/cp.php index e1f6554852..ed149e49c5 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -23,6 +23,9 @@ use Statamic\Http\Controllers\CP\Auth\ImpersonationController; use Statamic\Http\Controllers\CP\Auth\LoginController; use Statamic\Http\Controllers\CP\Auth\ResetPasswordController; +use Statamic\Http\Controllers\CP\Auth\TwoFactorChallengeController; +use Statamic\Http\Controllers\CP\Auth\TwoFactorLockedUserController; +use Statamic\Http\Controllers\CP\Auth\TwoFactorSetupController; use Statamic\Http\Controllers\CP\Auth\UnauthorizedController; use Statamic\Http\Controllers\CP\Collections\CollectionActionController; use Statamic\Http\Controllers\CP\Collections\CollectionBlueprintsController; @@ -95,6 +98,9 @@ use Statamic\Http\Controllers\CP\Users\AccountController; use Statamic\Http\Controllers\CP\Users\PasswordController; use Statamic\Http\Controllers\CP\Users\RolesController; +use Statamic\Http\Controllers\CP\Users\TwoFactorRecoveryCodesController; +use Statamic\Http\Controllers\CP\Users\TwoFactorUserLockedController; +use Statamic\Http\Controllers\CP\Users\TwoFactorUserResetController; use Statamic\Http\Controllers\CP\Users\UserActionController; use Statamic\Http\Controllers\CP\Users\UserBlueprintController; use Statamic\Http\Controllers\CP\Users\UserGroupBlueprintController; @@ -102,6 +108,8 @@ use Statamic\Http\Controllers\CP\Users\UsersController; use Statamic\Http\Controllers\CP\Users\UserWizardController; use Statamic\Http\Controllers\CP\Utilities\UtilitiesController; +use Statamic\Http\Middleware\CP\EnforceTwoFactor; +use Statamic\Http\Middleware\CP\SetupAvailableWhenTwoFactorSetupIncomplete; use Statamic\Http\Middleware\RequireStatamicPro; use Statamic\Statamic; @@ -114,6 +122,18 @@ Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action'); + + Route::withoutMiddleware(EnforceTwoFactor::class)->group(function () { + Route::middleware(SetupAvailableWhenTwoFactorSetupIncomplete::class)->group(function () { + Route::get('auth/two-factor/setup', [TwoFactorSetupController::class, 'index'])->name('two-factor.setup'); + Route::post('auth/two-factor/setup', [TwoFactorSetupController::class, 'store'])->name('two-factor.confirm'); + Route::post('auth/two-factor/complete', [TwoFactorSetupController::class, 'complete'])->name('two-factor.complete'); + }); + + Route::get('auth/two-factor/challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor.challenge'); + Route::post('auth/two-factor/challenge', [TwoFactorChallengeController::class, 'store'])->name('two-factor.challenge.attempt'); + Route::get('auth/two-factor/locked', [TwoFactorLockedUserController::class, 'index'])->name('two-factor.locked'); + }); } Route::get('logout', [LoginController::class, 'logout'])->name('logout'); @@ -293,6 +313,10 @@ Route::patch('users/blueprint', [UserBlueprintController::class, 'update'])->name('users.blueprint.update'); Route::resource('users', UsersController::class)->except('destroy'); Route::patch('users/{user}/password', [PasswordController::class, 'update'])->name('users.password.update'); + Route::get('users/{user}/two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); + Route::post('users/{user}/two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); + Route::delete('users/{user}/two-factor/lock', [TwoFactorUserLockedController::class, 'destroy'])->name('users.two-factor.unlock'); + Route::delete('users/{user}/two-factor', [TwoFactorUserResetController::class, 'destroy'])->name('users.two-factor.reset'); Route::get('account', AccountController::class)->name('account'); Route::get('user-groups/blueprint', [UserGroupBlueprintController::class, 'edit'])->name('user-groups.blueprint.edit'); Route::patch('user-groups/blueprint', [UserGroupBlueprintController::class, 'update'])->name('user-groups.blueprint.update'); diff --git a/src/Auth/TwoFactor/ChallengeTwoFactorAuthentication.php b/src/Auth/TwoFactor/ChallengeTwoFactorAuthentication.php new file mode 100644 index 0000000000..8d36e476fa --- /dev/null +++ b/src/Auth/TwoFactor/ChallengeTwoFactorAuthentication.php @@ -0,0 +1,84 @@ +two_factor_secret)) { + throw ValidationException::withMessages([ + $mode => [__('statamic-two-factor::messages.two_factor_not_setup')], + ]); + } + + if ($mode != 'code' && $mode != 'recovery_code') { + throw new InvalidChallengeModeException(); + } + + // call the challenge method + // these will either succeed or throw an exception and halt execution + $this->{Str::camel('challenge_'.$mode)}($user, $code); + + // save session + TwoFactorUser::setLastChallenged(); + } + + protected function challengeCode(User $user, ?string $code): void + { + if (empty($code) || + ! $this->provider->verify(decrypt($user->two_factor_secret), $code)) { + throw ValidationException::withMessages([ + 'code' => [__('statamic-two-factor::messages.code_challenge_failed')], + ])->redirectTo(cp_route('two-factor.challenge')); + } + } + + protected function challengeRecoveryCode(User $user, ?string $recovery_code): void + { + // must have a code + if (! $recovery_code || + empty($recovery_code)) { + throw ValidationException::withMessages([ + 'recovery_code' => [__('statamic-two-factor::messages.recovery_code_challenge_failed')], + ]); + } + + // get the recovery codes + $userRecoveryCodes = collect(json_decode(decrypt($user->two_factor_recovery_codes), true)); + + // find the recovery code + $foundRecoveryCode = $userRecoveryCodes->first(fn ($code) => hash_equals($code, $recovery_code) ? $code : null); + + // are we valid? + if (! $foundRecoveryCode) { + throw ValidationException::withMessages([ + 'recovery_code' => [__('statamic-two-factor::messages.recovery_code_challenge_failed')], + ]); + } + + // create a new recovery code + $userRecoveryCodes = $userRecoveryCodes->replace([ + $userRecoveryCodes->search($foundRecoveryCode) => RecoveryCode::generate(), + ]); + + // update the user's codes + $user->set('two_factor_recovery_codes', encrypt(json_encode($userRecoveryCodes))); + $user->save(); + + // notify the user + $user->notify(new RecoveryCodeUsed()); + } +} diff --git a/src/Auth/TwoFactor/CompleteTwoFactorAuthenticationSetup.php b/src/Auth/TwoFactor/CompleteTwoFactorAuthenticationSetup.php new file mode 100644 index 0000000000..04cf9913a5 --- /dev/null +++ b/src/Auth/TwoFactor/CompleteTwoFactorAuthenticationSetup.php @@ -0,0 +1,15 @@ +set('two_factor_completed', now()); + $user->save(); + } +} diff --git a/src/Auth/TwoFactor/ConfirmTwoFactorAuthentication.php b/src/Auth/TwoFactor/ConfirmTwoFactorAuthentication.php new file mode 100644 index 0000000000..6a82cf61a4 --- /dev/null +++ b/src/Auth/TwoFactor/ConfirmTwoFactorAuthentication.php @@ -0,0 +1,32 @@ +two_factor_secret) || + empty($code) || + ! $this->provider->verify(decrypt($user->two_factor_secret), $code)) { + throw ValidationException::withMessages([ + 'code' => [__('statamic-two-factor::messages.code_challenge_failed')], + ]); + } + + // update the user + $user->set('two_factor_confirmed_at', now()); + $user->save(); + + // update (prevents going to the challenge screen after setup) + TwoFactorUser::setLastChallenged(); + } +} diff --git a/src/Auth/TwoFactor/CreateRecoveryCodes.php b/src/Auth/TwoFactor/CreateRecoveryCodes.php new file mode 100644 index 0000000000..6f37124d9c --- /dev/null +++ b/src/Auth/TwoFactor/CreateRecoveryCodes.php @@ -0,0 +1,20 @@ +all(); + + $user->set('two_factor_recovery_codes', encrypt(json_encode($recoveryCodes))); + $user->save(); + } +} diff --git a/src/Auth/TwoFactor/DisableTwoFactorAuthentication.php b/src/Auth/TwoFactor/DisableTwoFactorAuthentication.php new file mode 100644 index 0000000000..2386364823 --- /dev/null +++ b/src/Auth/TwoFactor/DisableTwoFactorAuthentication.php @@ -0,0 +1,23 @@ +set('two_factor_confirmed_at', null); + $user->set('two_factor_completed', null); + $user->set('two_factor_secret', null); + $user->set('two_factor_recovery_codes', null); + $user->set('two_factor_locked', false); + $user->save(); + + // remove the last challenged data + TwoFactorUser::clearLastChallenged($user); + } +} diff --git a/src/Auth/TwoFactor/EnableTwoFactorAuthentication.php b/src/Auth/TwoFactor/EnableTwoFactorAuthentication.php new file mode 100644 index 0000000000..01fb5b8135 --- /dev/null +++ b/src/Auth/TwoFactor/EnableTwoFactorAuthentication.php @@ -0,0 +1,25 @@ +set('two_factor_confirmed_at', null); + $user->set('two_factor_completed', null); + $user->set('two_factor_locked', false); + if ($resetSecret) { + $user->set('two_factor_secret', encrypt($this->provider->generateSecretKey())); + app(CreateRecoveryCodes::class)($user); + } + $user->save(); + } +} diff --git a/src/Auth/TwoFactor/Google2FA.php b/src/Auth/TwoFactor/Google2FA.php new file mode 100644 index 0000000000..b2a12548fa --- /dev/null +++ b/src/Auth/TwoFactor/Google2FA.php @@ -0,0 +1,71 @@ +provider = app(\PragmaRX\Google2FA\Google2FA::class); + } + + public function getQrCodeSvg() + { + $svg = (new Writer( + new ImageRenderer( + new RendererStyle(200, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), + new SvgImageBackEnd + ) + ))->writeString($this->getQrCode()); + + return trim(substr($svg, strpos($svg, "\n") + 1)); + } + + public function getQrCode() + { + return $this->provider->getQRCodeUrl( + config('app.name'), + User::current()->email(), + $this->getSecretKey() + ); + } + + public function getSecretKey() + { + $secret = User::current()?->two_factor_secret; + if (! $secret) { + throw new TwoFactorNotSetUpException(); + } + + return decrypt($secret); + } + + public function generateSecretKey() + { + return $this->provider->generateSecretKey(); + } + + public function verify($secret, $code) + { + $timestamp = $this->provider->verifyKey($secret, $code); + + if ($timestamp !== false) { + return true; + } + + return false; + } +} diff --git a/src/Auth/TwoFactor/RecoveryCode.php b/src/Auth/TwoFactor/RecoveryCode.php new file mode 100644 index 0000000000..900e1b2cd1 --- /dev/null +++ b/src/Auth/TwoFactor/RecoveryCode.php @@ -0,0 +1,13 @@ +get(); + } + + $lastChallenged = null; + + // no user, return + if (! $user) { + return $lastChallenged; + } + + // are we using eloquent or flat file + if (Config::get('statamic.users.repository') === 'eloquent') { + $lastChallenged = $user->get('two_factor_last_challenged', null); + } else { + $lastChallenged = $user->getMeta('statamic_two_factor', null); + } + + // if we have a challenge, decrypt it + if ($lastChallenged) { + $lastChallenged = decrypt($lastChallenged); + } + + return $lastChallenged; + } + + public function get(): ?\Statamic\Contracts\Auth\User + { + return User::current(); + } + + public function setLastChallenged(?\Statamic\Contracts\Auth\User $user = null): static + { + // get the user + if (! $user) { + $user = $this->get(); + } + + if (! $user) { + return $this; + } + + // are we using eloquent or flat file + if (Config::get('statamic.users.repository') === 'eloquent') { + $user->set('two_factor_last_challenged', encrypt(now())); + $user->save(); + } else { + $user->setMeta('statamic_two_factor', encrypt(now())); + } + + return $this; + } + + public function clearLastChallenged(?\Statamic\Contracts\Auth\User $user = null): static + { + // get the user + if (! $user) { + $user = $this->get(); + } + + if (! $user) { + return $this; + } + + // are we using eloquent or flat file + if (Config::get('statamic.users.repository') === 'eloquent') { + $user->set('two_factor_last_challenged', null); + $user->save(); + } else { + $user->setMeta('statamic_two_factor', null); + } + + return $this; + } + + public function isTwoFactorEnforceable(?\Statamic\Contracts\Auth\User $user = null): bool + { + if (! $user) { + $user = $this->get(); + } + + // no user - so not enforceable + if (! $user) { + return false; + } + + // super admin are always enforced + + if ($user->isSuper()) { + return true; + } + + // get configured enforced roles + $enforcedRoles = config('statamic.users.two_factor.enforced_roles', null); + + // null means all roles are enforced + if ($enforcedRoles === null) { + return true; + } + + // if an array of roles check if the user contains ANY of them + if (is_array($enforcedRoles)) { + foreach ($enforcedRoles as $role) { + if ($user->hasRole($role)) { + return true; + } + } + } + + return false; // this far, not enforced + } +} diff --git a/src/Auth/TwoFactor/UnlockUser.php b/src/Auth/TwoFactor/UnlockUser.php new file mode 100644 index 0000000000..0765230d93 --- /dev/null +++ b/src/Auth/TwoFactor/UnlockUser.php @@ -0,0 +1,15 @@ +set('two_factor_locked', false); + $user->save(); + } +} diff --git a/src/Auth/UserRepository.php b/src/Auth/UserRepository.php index 99f1bf3b51..c7fd85ad51 100644 --- a/src/Auth/UserRepository.php +++ b/src/Auth/UserRepository.php @@ -78,6 +78,13 @@ public function blueprint() $blueprint->removeField('groups'); } + $blueprint->ensureField('two_factor', [ + 'type' => 'two_factor', + 'display' => __('Two Factor Authentication'), + 'hide_display' => true, + 'listable' => false, + ]); + Blink::put($blink, $blueprint); UserBlueprintFound::dispatch($blueprint); diff --git a/src/Console/Commands/stubs/auth/create_users_table.php.stub b/src/Console/Commands/stubs/auth/create_users_table.php.stub index f4dbb2874b..3893edbda2 100644 --- a/src/Console/Commands/stubs/auth/create_users_table.php.stub +++ b/src/Console/Commands/stubs/auth/create_users_table.php.stub @@ -10,5 +10,12 @@ Schema::create('USERS_TABLE', function (Blueprint $table) { $table->string('avatar')->nullable(); $table->json('preferences')->nullable(); $table->timestamp('last_login')->nullable(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + $table->timestamp('two_factor_completed')->nullable(); + $table->boolean('two_factor_locked')->default(false); + $table->text('two_factor_last_challenged')->nullable(); + $table->json('two_factor')->nullable(); $table->string('password')->nullable()->change(); }); diff --git a/src/Console/Commands/stubs/auth/update_users_table.php.stub b/src/Console/Commands/stubs/auth/update_users_table.php.stub index e3b38fb591..9a80c4d6d2 100644 --- a/src/Console/Commands/stubs/auth/update_users_table.php.stub +++ b/src/Console/Commands/stubs/auth/update_users_table.php.stub @@ -4,4 +4,11 @@ Schema::table('USERS_TABLE', function (Blueprint $table) { $table->json('preferences')->nullable(); $table->timestamp('last_login')->nullable(); $table->string('password')->nullable()->change(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + $table->timestamp('two_factor_completed')->nullable(); + $table->boolean('two_factor_locked')->default(false); + $table->text('two_factor_last_challenged')->nullable(); + $table->json('two_factor')->nullable(); }); diff --git a/src/Exceptions/InvalidChallengeModeException.php b/src/Exceptions/InvalidChallengeModeException.php new file mode 100644 index 0000000000..bcc2407aac --- /dev/null +++ b/src/Exceptions/InvalidChallengeModeException.php @@ -0,0 +1,8 @@ +'; + + protected $categories = ['special']; + + public function preload(): array + { + $isEnabled = config('statamic.users.two_factor.enabled', false); + $isEnforced = false; + $isLocked = false; + $isSetup = false; + $isMe = false; + $isUserEdit = false; + $routes = []; + + // only do this if we are set up + if ($isEnabled) { + // get the route + $route = request()->route(); + $user = null; + + // are we on the user edit screen? + if ($route->getName() === 'statamic.cp.users.edit' || $route->uri() === config('statamic.cp.route').'/users/{user}/edit') { + // yep, we are on the user edit view + $isUserEdit = true; + + // get the user param from the route + $user_id = $route->parameter('user', null); + + // is it me? + if ($user_id == User::current()->id) { + $isMe = true; + } + + // load the user + $user = User::find($user_id); + if ($user) { + if ($user->two_factor_locked) { + $isLocked = true; + } + + if ($user?->two_factor_confirmed_at) { + $isSetup = true; + } + } + + // build the routes if we have a user + if ($user) { + $routes = [ + 'setup' => null, + 'locked' => null, + 'recovery_codes' => [ + 'generate' => null, + 'show' => null, + ], + 'reset' => cp_route('users.two-factor.reset', ['user' => $user->id]), + ]; + + if ($isMe) { + // setup + $routes['setup'] = cp_route('two-factor.setup'); + + // recovery codes + $routes['recovery_codes']['show'] = cp_route('users.two-factor.recovery-codes.show', + ['user' => $user->id]); + $routes['recovery_codes']['generate'] = cp_route('users.two-factor.recovery-codes.generate', + ['user' => $user->id]); + } + if ($isLocked) { + // unlock ability + $routes['locked'] = cp_route('users.two-factor.unlock', ['user' => $user->id]); + } + + // are we enforced? + $isEnforced = TwoFactorUser::isTwoFactorEnforceable($user); + } + } + } + + return [ + 'enabled' => $isEnabled, + + 'is_enforced' => $isEnforced, + 'is_locked' => $isLocked, + 'is_me' => $isMe, + 'is_setup' => $isSetup, + 'is_user_edit' => $isUserEdit, + + 'routes' => $routes, + ]; + } +} diff --git a/src/Http/Controllers/CP/Auth/Concerns/GetsReferrerUrl.php b/src/Http/Controllers/CP/Auth/Concerns/GetsReferrerUrl.php new file mode 100644 index 0000000000..be3645c8f9 --- /dev/null +++ b/src/Http/Controllers/CP/Auth/Concerns/GetsReferrerUrl.php @@ -0,0 +1,46 @@ +get('two_factor_referer', null); + + session()->put('two_factor_referer', null); + + if ($sessionReferer) { + return $sessionReferer; + } + + $url = url()->previous(); + + $route = collect(Route::getRoutes())->first(function (\Illuminate\Routing\Route $route) use ($url) { + return $route->matches(request()->create($url), false); + }); + + $internalRoutes = [ + 'statamic.cp.two-factor.setup', + 'statamic.cp.two-factor.confirm', + 'statamic.cp.two-factor.complete', + 'statamic.cp.two-factor.challenge', + 'statamic.cp.two-factor.challenge.attempt', +// 'statamic.cp.users.two-factor.enable', + 'statamic.cp.users.two-factor.recovery-codes.show', + 'statamic.cp.users.two-factor.recovery-codes.generate', + 'statamic.cp.users.two-factor.unlock', + 'statamic.cp.users.two-factor.reset', + ]; + + if (! $route || in_array($route->getName(), $internalRoutes)) { + // there is no route, or it is a Statamic Two Factor-defined route (and we don't want to redirect there) + return null; + } + + return $url; + } +} diff --git a/src/Http/Controllers/CP/Auth/LoginController.php b/src/Http/Controllers/CP/Auth/LoginController.php index 885cb5d91d..25f761ba6d 100644 --- a/src/Http/Controllers/CP/Auth/LoginController.php +++ b/src/Http/Controllers/CP/Auth/LoginController.php @@ -7,6 +7,8 @@ use Illuminate\Validation\ValidationException; use Statamic\Auth\ThrottlesLogins; use Statamic\Facades\OAuth; +use Statamic\Facades\TwoFactorUser; +use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; use Statamic\Http\Middleware\CP\RedirectIfAuthorized; use Statamic\Support\Str; @@ -110,6 +112,13 @@ public function redirectPath() protected function authenticated(Request $request, $user) { + if (config('statamic.users.two_factor.enabled', false)) { + // if the user has been locked, show the locked view + if (User::current()->two_factor_locked) { + return redirect(cp_route('two-factor.locked')); + } + } + return $request->expectsJson() ? response('Authenticated') : redirect()->intended($this->redirectPath()); @@ -127,6 +136,9 @@ protected function credentials(Request $request) public function logout(Request $request) { + // remove the last challenged + TwoFactorUser::clearLastChallenged(); + $this->guard()->logout(); $request->session()->invalidate(); diff --git a/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php b/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php new file mode 100644 index 0000000000..80c697e42a --- /dev/null +++ b/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php @@ -0,0 +1,64 @@ +get('errors'))->first('code') || optional(session()->get('errors'))->first('recovery_code')) { + $request->session()->put('statamic_two_factor_attempts', + $request->session()->get('statamic_two_factor_attempts', 0) + 1); + } + + // if we have exceeded the number of attempts, lock the account + if ($request->session()->get('statamic_two_factor_attempts', 0) >= config('statamic.users.two_factor.attempts')) { + // block the account + User::current()->set('two_factor_locked', true)->save(); + + // redirect to the locked view + return redirect(cp_route('two-factor.locked')); + } + + // if we have a referrer URL, set it + if ($referrer = $this->getReferrerUrl($request)) { + // if we are not null, let's set it (this way it won't overwrite on failed attempts) + if ($referrer) { + $request->session()->put('statamic_two_factor_referrer', $referrer); + } + } + + // show the challenge view + return view('statamic::auth.two-factor.challenge', [ + 'mode' => $request->session()->get('mode', 'code'), + ]); + } + + public function store(Request $request, ChallengeTwoFactorAuthentication $challenge) + { + // set the mode + $mode = $request->get('mode', 'code'); + $request->session()->flash('mode', $mode); + + // do the challenge + $challenge(User::current(), $mode, $request->input($mode, null)); + + // forget the attempts count + $request->session()->forget('statamic_two_factor_attempts'); + + // get the redirect route, or the referrer if we set one + $route = cp_route('index'); + if ($referrer = session()->pull('statamic_two_factor_referrer', null)) { + $route = $referrer; + } + + return redirect($route); + } +} diff --git a/src/Http/Controllers/CP/Auth/TwoFactorLockedUserController.php b/src/Http/Controllers/CP/Auth/TwoFactorLockedUserController.php new file mode 100644 index 0000000000..79603f994d --- /dev/null +++ b/src/Http/Controllers/CP/Auth/TwoFactorLockedUserController.php @@ -0,0 +1,32 @@ +two_factor_locked) { + return redirect(cp_route('index')); + } + + // log the user out of the right guard + Auth::guard(config('statamic.users.guards.cp', null))->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + // show the lock view + return view('statamic::auth.two-factor.locked'); + } +} diff --git a/src/Http/Controllers/CP/Auth/TwoFactorSetupController.php b/src/Http/Controllers/CP/Auth/TwoFactorSetupController.php new file mode 100644 index 0000000000..5e0af0ff61 --- /dev/null +++ b/src/Http/Controllers/CP/Auth/TwoFactorSetupController.php @@ -0,0 +1,56 @@ +get('errors'))->first('code')) { + // we have tried a code, but failed + $resetSecret = false; + } + + // enable two factor, and optionally reset the user's code + $enable(User::current(), $resetSecret); + + // show the setup view + return view('statamic::auth.two-factor.setup', [ + 'cancellable' => Arr::get(User::current()->two_factor, 'cancellable', false), + 'qr' => $provider->getQrCodeSvg(), + 'secret_key' => $provider->getSecretKey(), + ]); + } + + public function store(Request $request, ConfirmTwoFactorAuthentication $confirm) + { + // confirm two factor + $confirm(User::current(), $request->input('code', null)); + + // show recovery codes + return view('statamic::auth.two-factor.recovery-codes', [ + 'recovery_codes' => json_decode(decrypt(User::current()->two_factor_recovery_codes), true), + ]); + } + + public function complete(Request $request, CompleteTwoFactorAuthenticationSetup $complete) + { + // complete the setup + $complete(User::current()); + + Toast::success(__('statamic-two-factor::messages.setup')); + + return redirect(cp_route('index')); + } +} diff --git a/src/Http/Controllers/CP/Users/TwoFactorRecoveryCodesController.php b/src/Http/Controllers/CP/Users/TwoFactorRecoveryCodesController.php new file mode 100644 index 0000000000..6adbbaa376 --- /dev/null +++ b/src/Http/Controllers/CP/Users/TwoFactorRecoveryCodesController.php @@ -0,0 +1,45 @@ +user); + + // can only see recovery codes for themselves + if ($requestingUser->id !== $user->id) { + abort(403); + } + + // success + return [ + 'recovery_codes' => json_decode(decrypt(User::current()->two_factor_recovery_codes), true), + ]; + } + + public function store(Request $request, CreateRecoveryCodes $createRecoveryCodes) + { + $requestingUser = User::current(); + $user = User::find($request->user); + + // can only generate recovery codes for themselves + if ($requestingUser->id !== $user->id) { + abort(403); + } + + // create new recovery codes + $createRecoveryCodes($user); + + // success + return [ + 'recovery_codes' => json_decode(decrypt($user->two_factor_recovery_codes), true), + ]; + } +} diff --git a/src/Http/Controllers/CP/Users/TwoFactorUserLockedController.php b/src/Http/Controllers/CP/Users/TwoFactorUserLockedController.php new file mode 100644 index 0000000000..089cca55e2 --- /dev/null +++ b/src/Http/Controllers/CP/Users/TwoFactorUserLockedController.php @@ -0,0 +1,27 @@ +user); + + // can they edit the user AND ARE NOT THEMSELVES - they can't unlock themselves? + if (! $requestingUser->can('edit', $user) || $requestingUser->id == $user->id) { + abort(403); + } + + // clear all two factor states for the user + $unlock($user); + + // success + return []; + } +} diff --git a/src/Http/Controllers/CP/Users/TwoFactorUserResetController.php b/src/Http/Controllers/CP/Users/TwoFactorUserResetController.php new file mode 100644 index 0000000000..40a9b368d6 --- /dev/null +++ b/src/Http/Controllers/CP/Users/TwoFactorUserResetController.php @@ -0,0 +1,37 @@ +user); + + // can they edit the user (or themselves)? + if (! $requestingUser->can('edit', $user)) { + abort(403); + } + + // disable two factor + $disable($user); + + // redirect + // if two factor is enforcable, and the same user, log them out + $redirect = null; + if ($user->id === $requestingUser->id && TwoFactorUser::isTwoFactorEnforceable()) { + $redirect = cp_route('logout'); + } + + // success + return [ + 'redirect' => $redirect, + ]; + } +} diff --git a/src/Http/Middleware/CP/EnforceTwoFactor.php b/src/Http/Middleware/CP/EnforceTwoFactor.php new file mode 100644 index 0000000000..ba7cf2dff6 --- /dev/null +++ b/src/Http/Middleware/CP/EnforceTwoFactor.php @@ -0,0 +1,66 @@ +two_factor_completed || TwoFactorUser::isTwoFactorEnforceable()) { + + // is two factor NOT set up? + if (! $user->two_factor_completed) { + // go to setup + return redirect(cp_route('two-factor.setup')); + } + + // when were we last challenged? + $lastChallenge = TwoFactorUser::getLastChallenged(); + + // do we use validity? + // if so, we need to check if we have a challenge, and if it hasn't expired + // if not, we just need to check we have a challenge + if (config('statamic.users.two_factor.validity', null)) { + // if the request is a POST or PATCH, or a "cp/preferences/js" request, ignore it + // this is so that if you're in the middle of editing when it does in fact expire, you can still + // save changes. This may be a bit controversial, but any other requests would trigger the + // challenge, and provides the better UX. The next GET or DELETE would require the challenge. + // + // Ultimately, it probably doesn't matter if the challenge is a bit longer than the "validity" + // minutes as it doesn't need to be *exact* but at least reminding them roughly after that time in + // a non-obtrusive way for their workflow is a happy approach. + if (! in_array(strtoupper($request->method()), ['PATCH', 'POST']) && + ! Str::startsWith($request->path(), config('statamic.cp.route').'/preferences/js')) { + + // if we have no challenge token, it has expired + if (! $lastChallenge || Carbon::parse($lastChallenge)->addMinutes((int) config('statamic.users.two_factor.validity'))->isPast()) { + // not yet challenged, or expired, so yes, let's challenge + return redirect(cp_route('two-factor.challenge'))->with('two_factor_referer', $request->getRequestUri()); + } + } + } else { + // we don't care about expiry dates - we just need to know if we have been challenged at all + if (! $lastChallenge) { + return redirect(cp_route('two-factor.challenge'))->with('two_factor_referer', $request->getRequestUri()); + } + } + } + } + + // all good, continue + return $next($request); + } +} diff --git a/src/Http/Middleware/CP/SetupAvailableWhenTwoFactorSetupIncomplete.php b/src/Http/Middleware/CP/SetupAvailableWhenTwoFactorSetupIncomplete.php new file mode 100644 index 0000000000..72614c22ca --- /dev/null +++ b/src/Http/Middleware/CP/SetupAvailableWhenTwoFactorSetupIncomplete.php @@ -0,0 +1,36 @@ +user(); + + // is two factor set up? + if ($user->two_factor_completed) { + // redirect to the home page + return redirect(cp_route('index')); + } + } + + // all good, continue + return $next($request); + } +} diff --git a/src/Listeners/TwoFactorUserSavedListener.php b/src/Listeners/TwoFactorUserSavedListener.php new file mode 100644 index 0000000000..42bbac65f7 --- /dev/null +++ b/src/Listeners/TwoFactorUserSavedListener.php @@ -0,0 +1,34 @@ +user; + + // update the user's two factor setup and locked status + $status = [ + 'cancellable' => false, + 'locked' => $user->two_factor_locked ? true : false, + 'setup' => $user->two_factor_confirmed_at ? true : false, + ]; + + if (! $status['setup']) { + // we only care about this if we are not set up + $status['cancellable'] = ! TwoFactorUser::isTwoFactorEnforceable(); + } + + // update the user's status fields, and quietly save (shhhhh!) + $user->set(config('statamic.users.two_factor.blueprint', 'two_factor'), $status) + ->saveQuietly(); + } +} diff --git a/src/Notifications/RecoveryCodeUsed.php b/src/Notifications/RecoveryCodeUsed.php new file mode 100644 index 0000000000..d4d63f3edf --- /dev/null +++ b/src/Notifications/RecoveryCodeUsed.php @@ -0,0 +1,48 @@ +subject(__('statamic-two-factor::messages.recovery_code_used_subject')) + ->greeting(__('statamic-two-factor::messages.recovery_code_used_greeting', ['name' => $notifiable->name()])) + ->line(__('statamic-two-factor::messages.recovery_code_used_body')) + ->line(__('statamic-two-factor::messages.recovery_code_used_body_2')) + ->action(__('statamic-two-factor::messages.recovery_code_used_action'), cp_route('account')) + ->line(__('statamic-two-factor::messages.recovery_code_used_body_3')); + } + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + return [ + // + ]; + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index dd7c5c9d50..8cba4f7617 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -2,6 +2,7 @@ namespace Statamic\Providers; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -9,6 +10,8 @@ use Statamic\Auth\PermissionCache; use Statamic\Auth\Permissions; use Statamic\Auth\Protect\ProtectorManager; +use Statamic\Auth\TwoFactor\Google2FA; +use Statamic\Auth\TwoFactor\StatamicTwoFactorUser; use Statamic\Auth\UserProvider; use Statamic\Auth\UserRepositoryManager; use Statamic\Contracts\Auth\RoleRepository; @@ -76,6 +79,16 @@ public function register() $this->app->singleton(PermissionCache::class, function ($app) { return new PermissionCache; }); + + $this->app->bind('statamicTwoFactorUser', function ($app) { + return new StatamicTwoFactorUser(); + }); + + $this->app->alias(StatamicTwoFactorUser::class, 'statamicTwoFactorUser'); + + $this->app->singleton(Google2FA::class, function (Application $app) { + return new Google2FA(); + }); } public function boot() diff --git a/src/Providers/CpServiceProvider.php b/src/Providers/CpServiceProvider.php index 666ba482b3..1df7083591 100644 --- a/src/Providers/CpServiceProvider.php +++ b/src/Providers/CpServiceProvider.php @@ -102,6 +102,7 @@ protected function registerMiddlewareGroups() \Statamic\Http\Middleware\CP\BootUtilities::class, \Statamic\Http\Middleware\CP\CountUsers::class, \Statamic\Http\Middleware\CP\AddVaryHeaderToResponse::class, + \Statamic\Http\Middleware\CP\EnforceTwoFactor::class, \Statamic\Http\Middleware\DeleteTemporaryFileUploads::class, ]); } diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index e83e6e9893..8e34ff589a 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -113,6 +113,7 @@ class ExtensionServiceProvider extends ServiceProvider Fieldtypes\Textarea::class, Fieldtypes\Time::class, Fieldtypes\Toggle::class, + Fieldtypes\TwoFactor::class, Fieldtypes\UserGroups::class, Fieldtypes\UserRoles::class, Fieldtypes\Users::class, From 78baa37e747a5673f02c84cb700f4aa2579c52aa Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Apr 2025 12:52:32 +0100 Subject: [PATCH 02/45] Hide "Two Factor" fieldtype in field selector Also, remove the "not on user show page" logic since that should be the only page is used on now. --- .../js/components/fieldtypes/TwoFactorFieldtype.vue | 12 +++--------- resources/svg/icons/light/key.svg | 8 ++++++++ src/Fieldtypes/TwoFactor.php | 10 ++-------- 3 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 resources/svg/icons/light/key.svg diff --git a/resources/js/components/fieldtypes/TwoFactorFieldtype.vue b/resources/js/components/fieldtypes/TwoFactorFieldtype.vue index 93141eb7a7..691ef887cf 100644 --- a/resources/js/components/fieldtypes/TwoFactorFieldtype.vue +++ b/resources/js/components/fieldtypes/TwoFactorFieldtype.vue @@ -6,18 +6,18 @@ - @@ -50,14 +46,14 @@ export default { TwoFactorEnable, TwoFactorLocked, TwoFactorRecoveryCodes, - TwoFactorReset + TwoFactorReset, }, data() { return { locked: false, - setup: false - } + setup: false, + }; }, mounted() { @@ -68,14 +64,14 @@ export default { computed: { languageUser() { return (this.meta.is_me ? 'me' : 'user') + (this.meta.is_enforced ? '_enforced' : ''); - } + }, }, methods: { updateState(field, status) { // update the status this.$data[field] = status; - } + }, }, }; diff --git a/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue b/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue index 0f7f205a62..09c24b9a5d 100644 --- a/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue +++ b/resources/js/components/fieldtypes/TwoFactorIndexFieldtype.vue @@ -1,28 +1,38 @@