From b396f3e33945823cd7e8a969e8fc45687566475b Mon Sep 17 00:00:00 2001 From: Boy132 Date: Tue, 22 Jul 2025 13:24:06 +0200 Subject: [PATCH] switch to filaments 2fa --- .../Commands/User/DisableTwoFactorCommand.php | 10 +- .../Schemas/CaptchaSchemaInterface.php | 2 +- .../Schemas/Turnstile/TurnstileSchema.php | 3 +- app/Filament/Admin/Resources/UserResource.php | 8 +- app/Filament/Pages/Auth/EditProfile.php | 659 ++++++++---------- app/Filament/Pages/Auth/Login.php | 109 +-- .../FileResource/Pages/ListFiles.php | 4 +- .../Api/Client/TwoFactorController.php | 110 --- .../RequireTwoFactorAuthentication.php | 64 -- app/Jobs/ProcessWebhook.php | 1 - app/Models/User.php | 84 ++- app/Providers/AppServiceProvider.php | 1 + app/Providers/Filament/AdminPanelProvider.php | 8 +- app/Providers/Filament/AppPanelProvider.php | 8 +- .../Filament/ServerPanelProvider.php | 8 +- app/Providers/RouteServiceProvider.php | 7 +- app/Services/Users/ToggleTwoFactorService.php | 70 -- app/Services/Users/TwoFactorSetupService.php | 49 -- .../Api/Application/UserTransformer.php | 4 +- .../Api/Client/UserTransformer.php | 2 +- composer.json | 1 - database/Factories/UserFactory.php | 1 - .../2025_07_22_091435_update_users_totp.php | 47 ++ .../Users/ExternalUserControllerTest.php | 2 +- .../Application/Users/UserControllerTest.php | 10 +- .../Api/Client/TwoFactorControllerTest.php | 200 ------ 26 files changed, 440 insertions(+), 1032 deletions(-) delete mode 100644 app/Http/Controllers/Api/Client/TwoFactorController.php delete mode 100644 app/Http/Middleware/RequireTwoFactorAuthentication.php delete mode 100644 app/Services/Users/ToggleTwoFactorService.php delete mode 100644 app/Services/Users/TwoFactorSetupService.php create mode 100644 database/migrations/2025_07_22_091435_update_users_totp.php delete mode 100644 tests/Integration/Api/Client/TwoFactorControllerTest.php diff --git a/app/Console/Commands/User/DisableTwoFactorCommand.php b/app/Console/Commands/User/DisableTwoFactorCommand.php index 4a3a81232..437058d9c 100644 --- a/app/Console/Commands/User/DisableTwoFactorCommand.php +++ b/app/Console/Commands/User/DisableTwoFactorCommand.php @@ -25,10 +25,12 @@ class DisableTwoFactorCommand extends Command $email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email')); - $user = User::query()->where('email', $email)->firstOrFail(); - $user->use_totp = false; - $user->totp_secret = null; - $user->save(); + $user = User::where('email', $email)->firstOrFail(); + $user->update([ + 'mfa_app_secret' => null, + 'mfa_app_recovery_codes' => null, + 'mfa_email_enabled' => false, + ]); $this->info(trans('command/messages.user.2fa_disabled', ['email' => $user->email])); } diff --git a/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php b/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php index 119b96891..6c8497b0f 100644 --- a/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php +++ b/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php @@ -2,7 +2,7 @@ namespace App\Extensions\Captcha\Schemas; -use Filament\Support\Components\Component; +use Filament\Schemas\Components\Component; interface CaptchaSchemaInterface { diff --git a/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php b/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php index 0b94f4747..69c6c6fbf 100644 --- a/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php +++ b/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php @@ -2,7 +2,6 @@ namespace App\Extensions\Captcha\Schemas\Turnstile; -use Filament\Support\Components\Component; use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface; use App\Extensions\Captcha\Schemas\BaseSchema; use Exception; @@ -39,7 +38,7 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface } /** - * @return Component[] + * @return \Filament\Support\Components\Component[] * * @throws Exception */ diff --git a/app/Filament/Admin/Resources/UserResource.php b/app/Filament/Admin/Resources/UserResource.php index a350cd304..c392152ff 100644 --- a/app/Filament/Admin/Resources/UserResource.php +++ b/app/Filament/Admin/Resources/UserResource.php @@ -23,12 +23,12 @@ use Filament\Resources\Resource; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; -use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Filament\Schemas\Schema; +use Filament\Tables\Columns\IconColumn; class UserResource extends Resource { @@ -88,11 +88,11 @@ class UserResource extends Resource ->label(trans('admin/user.email')) ->icon('tabler-mail') ->searchable(), - IconColumn::make('use_totp') + IconColumn::make('2fa') ->label('2FA') ->visibleFrom('lg') - ->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off') - ->boolean(), + ->icon(fn (User $user) => filled($user->mfa_app_secret) ? 'tabler-qrcode' : ($user->mfa_email_enabled ? 'tabler-mail' : 'tabler-lock-open-off')) + ->tooltip(fn (User $user) => filled($user->mfa_app_secret) ? 'App' : ($user->mfa_email_enabled ? 'E-Mail' : 'None')), TextColumn::make('roles.name') ->label(trans('admin/user.roles')) ->badge() diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index 23486bd4e..36555b8d8 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -2,7 +2,6 @@ namespace App\Filament\Pages\Auth; -use App\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid; use App\Extensions\OAuth\OAuthService; use App\Facades\Activity; use App\Models\ActivityLog; @@ -11,19 +10,16 @@ use App\Models\User; use App\Models\UserSSHKey; use App\Services\Helpers\LanguageService; use App\Services\Ssh\KeyCreationService; -use App\Services\Users\ToggleTwoFactorService; -use App\Services\Users\TwoFactorSetupService; use App\Services\Users\UserUpdateService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; -use chillerlan\QRCode\Common\EccLevel; -use chillerlan\QRCode\Common\Version; -use chillerlan\QRCode\QRCode; -use chillerlan\QRCode\QROptions; use DateTimeZone; use Exception; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use Filament\Auth\MultiFactor\Contracts\MultiFactorAuthenticationProvider; +use Filament\Auth\Pages\EditProfile as BaseEditProfile; +use Filament\Facades\Filament; use Filament\Forms\Components\FileUpload; use Filament\Schemas\Components\Grid; use Filament\Infolists\Components\TextEntry; @@ -39,12 +35,12 @@ use Filament\Forms\Components\ToggleButtons; use Filament\Schemas\Components\Utilities\Get; use Filament\Notifications\Notification; use Filament\Schemas\Components\Actions; +use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Support\Colors\Color; use Filament\Support\Enums\Width; -use Filament\Support\Exceptions\Halt; -use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\HtmlString; @@ -54,18 +50,15 @@ use Laravel\Socialite\Facades\Socialite; /** * @method User getUser() */ -class EditProfile extends \Filament\Auth\Pages\EditProfile +class EditProfile extends BaseEditProfile { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; - private ToggleTwoFactorService $toggleTwoFactorService; - protected OAuthService $oauthService; - public function boot(ToggleTwoFactorService $toggleTwoFactorService, OAuthService $oauthService): void + public function boot(OAuthService $oauthService): void { - $this->toggleTwoFactorService = $toggleTwoFactorService; $this->oauthService = $oauthService; } @@ -74,6 +67,14 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile return config('panel.filament.display-width', 'screen-2xl'); } + public function content(Schema $schema): Schema + { + return $schema + ->components([ + $this->getFormContentComponent(), + ]); + } + /** * @throws Exception */ @@ -88,70 +89,67 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile Tab::make(trans('profile.tabs.account')) ->icon('tabler-user') ->schema([ - Tab::make(trans('profile.tabs.account')) - ->icon('tabler-user') - ->schema([ - TextInput::make('username') - ->label(trans('profile.username')) - ->disabled() - ->readOnly() - ->dehydrated(false) - ->maxLength(255) - ->unique(ignoreRecord: true) - ->autofocus(), - TextInput::make('email') - ->prefixIcon('tabler-mail') - ->label(trans('profile.email')) - ->email() - ->required() - ->maxLength(255) - ->unique(ignoreRecord: true), - TextInput::make('password') - ->label(trans('profile.password')) - ->password() - ->prefixIcon('tabler-password') - ->revealable(filament()->arePasswordsRevealable()) - ->rule(Password::default()) - ->autocomplete('new-password') - ->dehydrated(fn ($state): bool => filled($state)) - ->dehydrateStateUsing(fn ($state): string => Hash::make($state)) - ->live(debounce: 500) - ->same('passwordConfirmation'), - TextInput::make('passwordConfirmation') - ->label(trans('profile.password_confirmation')) - ->password() - ->prefixIcon('tabler-password-fingerprint') - ->revealable(filament()->arePasswordsRevealable()) - ->required() - ->visible(fn (Get $get): bool => filled($get('password'))) - ->dehydrated(false), - Select::make('timezone') - ->label(trans('profile.timezone')) - ->required() - ->prefixIcon('tabler-clock-pin') - ->default('UTC') - ->selectablePlaceholder(false) - ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) - ->searchable() - ->native(false), - Select::make('language') - ->label(trans('profile.language')) - ->required() - ->prefixIcon('tabler-flag') - ->live() - ->default('en') - ->selectablePlaceholder(false) - ->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' - : trans('profile.language_help', ['state' => $state]) . ' Update On Crowdin')) - ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) - ->native(false), - FileUpload::make('avatar') - ->visible(fn () => config('panel.filament.uploadable-avatars')) - ->avatar() - ->acceptedFileTypes(['image/png']) - ->directory('avatars') - ->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png') - ->hintAction(function (FileUpload $fileUpload) { + TextInput::make('username') + ->label(trans('profile.username')) + ->disabled() + ->readOnly() + ->dehydrated(false) + ->maxLength(255) + ->unique(ignoreRecord: true) + ->autofocus(), + TextInput::make('email') + ->prefixIcon('tabler-mail') + ->label(trans('profile.email')) + ->email() + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + TextInput::make('password') + ->label(trans('profile.password')) + ->password() + ->prefixIcon('tabler-password') + ->revealable(filament()->arePasswordsRevealable()) + ->rule(Password::default()) + ->autocomplete('new-password') + ->dehydrated(fn ($state): bool => filled($state)) + ->dehydrateStateUsing(fn ($state): string => Hash::make($state)) + ->live(debounce: 500) + ->same('passwordConfirmation'), + TextInput::make('passwordConfirmation') + ->label(trans('profile.password_confirmation')) + ->password() + ->prefixIcon('tabler-password-fingerprint') + ->revealable(filament()->arePasswordsRevealable()) + ->required() + ->visible(fn (Get $get): bool => filled($get('password'))) + ->dehydrated(false), + Select::make('timezone') + ->label(trans('profile.timezone')) + ->required() + ->prefixIcon('tabler-clock-pin') + ->default('UTC') + ->selectablePlaceholder(false) + ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) + ->searchable() + ->native(false), + Select::make('language') + ->label(trans('profile.language')) + ->required() + ->prefixIcon('tabler-flag') + ->live() + ->default('en') + ->selectablePlaceholder(false) + ->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' + : trans('profile.language_help', ['state' => $state]) . ' Update On Crowdin')) + ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) + ->native(false), + FileUpload::make('avatar') + ->visible(fn () => config('panel.filament.uploadable-avatars')) + ->avatar() + ->acceptedFileTypes(['image/png']) + ->directory('avatars') + ->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png'), + /*->hintAction(function (FileUpload $fileUpload) { $path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png'; return Action::make('remove_avatar') @@ -159,208 +157,135 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile ->iconButton() ->hidden(fn () => !$fileUpload->getDisk()->exists($path)) ->action(fn () => $fileUpload->getDisk()->delete($path)); - }), - ]), + }),*/ + ]), + Tab::make(trans('profile.tabs.oauth')) + ->icon('tabler-brand-oauth') + ->visible(count($oauthSchemas) > 0) + ->schema(function () use ($oauthSchemas) { + $actions = []; - Tab::make(trans('profile.tabs.oauth')) - ->icon('tabler-brand-oauth') - ->visible(count($oauthSchemas) > 0) - ->schema(function () use ($oauthSchemas) { - $actions = []; + foreach ($oauthSchemas as $schema) { - foreach ($oauthSchemas as $schema) { + $id = $schema->getId(); + $name = $schema->getName(); - $id = $schema->getId(); - $name = $schema->getName(); + $unlink = array_key_exists($id, $this->getUser()->oauth ?? []); - $unlink = array_key_exists($id, $this->getUser()->oauth ?? []); + $actions[] = Action::make("oauth_$id") + ->label(($unlink ? trans('profile.unlink') : trans('profile.link')) . $name) + ->icon($unlink ? 'tabler-unlink' : 'tabler-link') + ->color(Color::generateV3Palette($schema->getHexColor())) + ->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) { + if ($unlink) { + $oauth = auth()->user()->oauth; + unset($oauth[$id]); - $actions[] = Action::make("oauth_$id") - ->label(($unlink ? trans('profile.unlink') : trans('profile.link')) . $name) - ->icon($unlink ? 'tabler-unlink' : 'tabler-link') - ->color(Color::generateV3Palette($schema->getHexColor())) - ->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) { - if ($unlink) { - $oauth = auth()->user()->oauth; - unset($oauth[$id]); + $updateService->handle(auth()->user(), ['oauth' => $oauth]); - $updateService->handle(auth()->user(), ['oauth' => $oauth]); + $this->fillForm(); - $this->fillForm(); + Notification::make() + ->title(trans('profile.unlinked', ['name' => $name])) + ->success() + ->send(); + } else { + redirect(Socialite::with($id)->redirect()->getTargetUrl()); + } + }); + } - Notification::make() - ->title(trans('profile.unlinked', ['name' => $name])) - ->success() - ->send(); - } else { - redirect(Socialite::with($id)->redirect()->getTargetUrl()); - } - }); - } - - return [Actions::make($actions)]; - }), - - Tab::make(trans('profile.tabs.2fa')) - ->icon('tabler-shield-lock') - ->schema(function (TwoFactorSetupService $setupService) { - if ($this->getUser()->use_totp) { - return [ - TextEntry::make('2fa-already-enabled') - ->label(trans('profile.2fa_enabled')), - Textarea::make('backup-tokens') - ->hidden(fn () => !cache()->get("users.{$this->getUser()->id}.2fa.tokens")) - ->rows(10) - ->readOnly() - ->dehydrated(false) - ->formatStateUsing(fn () => cache()->get("users.{$this->getUser()->id}.2fa.tokens")) - ->helperText(trans('profile.backup_help')) - ->label(trans('profile.backup_codes')), - TextInput::make('2fa-disable-code') - ->label(trans('profile.disable_2fa')) - ->helperText(trans('profile.disable_2fa_help')), - ]; - } - - ['image_url_data' => $url, 'secret' => $secret] = cache()->remember( - "users.{$this->getUser()->id}.2fa.state", - now()->addMinutes(5), fn () => $setupService->handle($this->getUser()) - ); - - $options = new QROptions([ - 'svgLogo' => public_path('pelican.svg'), - 'svgLogoScale' => 0.05, - 'addLogoSpace' => true, - 'logoSpaceWidth' => 13, - 'logoSpaceHeight' => 13, - 'version' => Version::AUTO, - // 'outputInterface' => QRSvgWithLogo::class, - 'outputBase64' => false, - 'eccLevel' => EccLevel::H, // ECC level H is necessary when using logos - 'addQuietzone' => true, - // 'drawLightModules' => true, - 'connectPaths' => true, - 'drawCircularModules' => true, - // 'circleRadius' => 0.45, - 'svgDefs' => ' - - - - - - - ', - ]); - - // https://github.com/chillerlan/php-qrcode/blob/main/examples/svgWithLogo.php - - $image = (new QRCode($options))->render($url); - - return [ - TextEntry::make('qr') - ->label(trans('profile.scan_qr')) - ->state(fn () => new HtmlString(" -
$image
- ")) - ->helperText(trans('profile.setup_key') .': '. $secret), - TextInput::make('2facode') - ->label(trans('profile.code')) - ->requiredWith('2fapassword') - ->helperText(trans('profile.code_help')), - TextInput::make('2fapassword') - ->label(trans('profile.current_password')) - ->requiredWith('2facode') - ->currentPassword() - ->password(), - ]; - }), - - Tab::make(trans('profile.tabs.api_keys')) - ->icon('tabler-key') + return [Actions::make($actions)]; + }), + Tab::make(trans('profile.tabs.2fa')) + ->icon('tabler-shield-lock') + ->visible(fn () => Filament::hasMultiFactorAuthentication()) + ->schema(collect(Filament::getMultiFactorAuthenticationProviders()) + ->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => $multiFactorAuthenticationProvider->isEnabled(Filament::auth()->user()) ? 0 : 1) + ->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents()) + ->statePath($multiFactorAuthenticationProvider->getId())) + ->all()), + Tab::make(trans('profile.tabs.api_keys')) + ->icon('tabler-key') + ->schema([ + Grid::make(5) ->schema([ - Grid::make(5) - ->schema([ - Section::make(trans('profile.create_api_key'))->columnSpan(3)->schema([ - TextInput::make('description') - ->label(trans('profile.description')) - ->live(), - TagsInput::make('allowed_ips') - ->label(trans('profile.allowed_ips')) - ->live() - ->splitKeys([',', ' ', 'Tab']) - ->placeholder('127.0.0.1 or 192.168.1.1') - ->helperText(trans('profile.allowed_ips_help')) - ->columnSpanFull(), - ])->headerActions([ - Action::make('create') - ->label(trans('filament-actions::create.single.modal.actions.create.label')) - ->disabled(fn (Get $get) => empty($get('description'))) - ->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app')) - ->action(function (Get $get, Action $action, User $user) { - $token = $user->createToken( - $get('description'), - $get('allowed_ips'), - ); + Section::make(trans('profile.create_api_key'))->columnSpan(3)->schema([ + TextInput::make('description') + ->label(trans('profile.description')) + ->live(), + TagsInput::make('allowed_ips') + ->label(trans('profile.allowed_ips')) + ->live() + ->splitKeys([',', ' ', 'Tab']) + ->placeholder('127.0.0.1 or 192.168.1.1') + ->helperText(trans('profile.allowed_ips_help')) + ->columnSpanFull(), + ])->headerActions([ + Action::make('create') + ->label(trans('filament-actions::create.single.modal.actions.create.label')) + ->disabled(fn (Get $get) => empty($get('description'))) + ->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app')) + ->action(function (Get $get, Action $action, User $user) { + $token = $user->createToken( + $get('description'), + $get('allowed_ips'), + ); - Activity::event('user:api-key.create') + Activity::event('user:api-key.create') + ->actor($user) + ->subject($user) + ->subject($token->accessToken) + ->property('identifier', $token->accessToken->identifier) + ->log(); + + Notification::make() + ->title(trans('profile.api_key_created')) + ->body($token->accessToken->identifier . $token->plainTextToken) + ->persistent() + ->success() + ->send(); + + $action->success(); + }), + ]), + Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([ + Repeater::make('api_keys') + ->hiddenLabel() + ->relationship('apiKeys') + ->addable(false) + ->itemLabel(fn ($state) => $state['identifier']) + ->deleteAction(function (Action $action) { + $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) { + $items = $component->getState(); + $key = $items[$arguments['item']]; + + $apiKey = ApiKey::find($key['id'] ?? null); + if ($apiKey->exists()) { + $apiKey->delete(); + + Activity::event('user:api-key.delete') ->actor($user) ->subject($user) - ->subject($token->accessToken) - ->property('identifier', $token->accessToken->identifier) + ->subject($apiKey) + ->property('identifier', $apiKey->identifier) ->log(); + } - Notification::make() - ->title(trans('profile.api_key_created')) - ->body($token->accessToken->identifier . $token->plainTextToken) - ->persistent() - ->success() - ->send(); + unset($items[$arguments['item']]); - $action->success(); - }), + $component->state($items); + + $component->callAfterStateUpdated(); + }); + }) + ->schema(fn () => [ + TextEntry::make('memo') + ->state(fn (ApiKey $key) => $key->memo), ]), - Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([ - Repeater::make('api_keys') - ->hiddenLabel() - ->relationship('apiKeys') - ->addable(false) - ->itemLabel(fn ($state) => $state['identifier']) - ->deleteAction(function (Action $action) { - $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) { - $items = $component->getState(); - $key = $items[$arguments['item']]; - - $apiKey = ApiKey::find($key['id'] ?? null); - if ($apiKey->exists()) { - $apiKey->delete(); - - Activity::event('user:api-key.delete') - ->actor($user) - ->subject($user) - ->subject($apiKey) - ->property('identifier', $apiKey->identifier) - ->log(); - } - - unset($items[$arguments['item']]); - - $component->state($items); - - $component->callAfterStateUpdated(); - }); - }) - ->schema(fn () => [ - TextEntry::make('memo') - ->state(fn (ApiKey $key) => $key->memo), - ]), - ]), - ]), + ]), ]), ]), - Tab::make(trans('profile.tabs.ssh_keys')) ->icon('tabler-lock-code') ->schema([ @@ -444,74 +369,82 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile ]), ]), ]), - Tab::make(trans('profile.tabs.activity')) ->icon('tabler-history') ->schema([ - TextEntry::make('activity!')->label('')->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())), - ]), - ]), - - Tab::make(trans('profile.tabs.customization')) - ->icon('tabler-adjustments') - ->schema([ - Section::make(trans('profile.dashboard')) - ->collapsible() - ->icon('tabler-dashboard') - ->schema([ - ToggleButtons::make('dashboard_layout') - ->label(trans('profile.dashboard_layout')) - ->inline() - ->required() - ->options([ - 'grid' => trans('profile.grid'), - 'table' => trans('profile.table'), - ]), - Section::make(trans('profile.console')) - ->collapsible() - ->icon('tabler-brand-tabler') - ->columns(4) + Repeater::make('activity') + ->hiddenLabel() + ->deletable(false) + ->addable(false) + ->relationship(null, function (Builder $query) { + $query->orderBy('timestamp', 'desc'); + }) ->schema([ - TextInput::make('console_font_size') - ->label(trans('profile.font_size')) - ->columnSpan(1) - ->minValue(1) - ->numeric() + TextEntry::make('log') + ->label('') + ->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())), + ]), + ]), + Tab::make(trans('profile.tabs.customization')) + ->icon('tabler-adjustments') + ->schema([ + Section::make(trans('profile.dashboard')) + ->collapsible() + ->icon('tabler-dashboard') + ->schema([ + ToggleButtons::make('dashboard_layout') + ->label(trans('profile.dashboard_layout')) + ->inline() ->required() - ->default(14), - Select::make('console_font') - ->label(trans('profile.font')) - ->required() - ->options(function () { - $fonts = [ - 'monospace' => 'monospace', //default - ]; + ->options([ + 'grid' => trans('profile.grid'), + 'table' => trans('profile.table'), + ]), + Section::make(trans('profile.console')) + ->collapsible() + ->icon('tabler-brand-tabler') + ->columns(4) + ->schema([ + TextInput::make('console_font_size') + ->label(trans('profile.font_size')) + ->columnSpan(1) + ->minValue(1) + ->numeric() + ->required() + ->default(14), + Select::make('console_font') + ->label(trans('profile.font')) + ->required() + ->options(function () { + $fonts = [ + 'monospace' => 'monospace', //default + ]; - if (!Storage::disk('public')->exists('fonts')) { - Storage::disk('public')->makeDirectory('fonts'); - $this->fillForm(); - } + if (!Storage::disk('public')->exists('fonts')) { + Storage::disk('public')->makeDirectory('fonts'); + $this->fillForm(); + } - foreach (Storage::disk('public')->allFiles('fonts') as $file) { - $fileInfo = pathinfo($file); + foreach (Storage::disk('public')->allFiles('fonts') as $file) { + $fileInfo = pathinfo($file); - if ($fileInfo['extension'] === 'ttf') { - $fonts[$fileInfo['filename']] = $fileInfo['filename']; - } - } + if ($fileInfo['extension'] === 'ttf') { + $fonts[$fileInfo['filename']] = $fileInfo['filename']; + } + } - return $fonts; - }) - ->reactive() - ->default('monospace') - ->afterStateUpdated(fn ($state, Set $set) => $set('font_preview', $state)), - TextEntry::make('font_preview') - ->label(trans('profile.font_preview')) - ->columnSpan(2) - ->state(function (Get $get) { - $fontName = $get('console_font') ?? 'monospace'; - $fontSize = $get('console_font_size') . 'px'; - $style = <<reactive() + ->default('monospace') + ->afterStateUpdated(fn ($state, Set $set) => $set('font_preview', $state)), + TextEntry::make('font_preview') + ->label(trans('profile.font_preview')) + ->columnSpan(2) + ->state(function (Get $get) { + $fontName = $get('console_font') ?? 'monospace'; + $fontSize = $get('console_font_size') . 'px'; + $style = << {$style} The quick blue pelican jumps over the lazy pterodactyl. :) HTML); - }), - TextInput::make('console_graph_period') - ->label(trans('profile.graph_period')) - ->suffix(trans('profile.seconds')) - ->hintIcon('tabler-question-mark') - ->hintIconTooltip(trans('profile.graph_period_helper')) - ->columnSpan(2) - ->numeric() - ->default(30) - ->minValue(10) - ->maxValue(120) - ->required(), - TextInput::make('console_rows') - ->label(trans('profile.rows')) - ->minValue(1) - ->numeric() - ->required() - ->columnSpan(2) - ->default(30), + }), + TextInput::make('console_graph_period') + ->label(trans('profile.graph_period')) + ->suffix(trans('profile.seconds')) + ->hintIcon('tabler-question-mark') + ->hintIconTooltip(trans('profile.graph_period_helper')) + ->columnSpan(2) + ->numeric() + ->default(30) + ->minValue(10) + ->maxValue(120) + ->required(), + TextInput::make('console_rows') + ->label(trans('profile.rows')) + ->minValue(1) + ->numeric() + ->required() + ->columnSpan(2) + ->default(30), + ]), ]), ]), ]), + ]) ->operation('edit') ->model($this->getUser()) @@ -565,40 +500,6 @@ class EditProfile extends \Filament\Auth\Pages\EditProfile ->inlineLabel(!static::isSimple()); } - protected function handleRecordUpdate(Model $record, array $data): Model - { - if (!$record instanceof User) { - return $record; - } - - if ($token = $data['2facode'] ?? null) { - $tokens = $this->toggleTwoFactorService->handle($record, $token, true); - cache()->put("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15)); - - $this->redirect(self::getUrl(['tab' => '-2fa-tab'], panel: 'app')); - } - - if ($token = $data['2fa-disable-code'] ?? null) { - try { - $this->toggleTwoFactorService->handle($record, $token, false); - } catch (TwoFactorAuthenticationTokenInvalid $exception) { - Notification::make() - ->title(trans('profile.invalid_code')) - ->body($exception->getMessage()) - ->color('danger') - ->icon('tabler-2fa') - ->danger() - ->send(); - - throw new Halt(); - } - - cache()->forget("users.$record->id.2fa.state"); - } - - return parent::handleRecordUpdate($record, $data); - } - protected function getFormActions(): array { return []; diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php index 71a969b38..133a7cad1 100644 --- a/app/Filament/Pages/Auth/Login.php +++ b/app/Filament/Pages/Auth/Login.php @@ -2,116 +2,29 @@ namespace App\Filament\Pages\Auth; -use App\Events\Auth\ProvidedAuthenticationToken; use App\Extensions\Captcha\CaptchaService; use App\Extensions\OAuth\OAuthService; -use App\Facades\Activity; -use App\Models\User; -use Filament\Auth\Http\Responses\Contracts\LoginResponse; -use Filament\Facades\Filament; use Filament\Actions\Action; +use Filament\Auth\Pages\Login as BaseLogin; use Filament\Forms\Components\TextInput; -use Filament\Notifications\Notification; use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Component; use Filament\Schemas\Schema; use Filament\Support\Colors\Color; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Sleep; use Illuminate\Validation\ValidationException; -use PragmaRX\Google2FA\Google2FA; -class Login extends \Filament\Auth\Pages\Login +class Login extends BaseLogin { - private Google2FA $google2FA; - - public bool $verifyTwoFactor = false; - protected OAuthService $oauthService; protected CaptchaService $captchaService; - public function boot(Google2FA $google2FA, OAuthService $oauthService, CaptchaService $captchaService): void + public function boot(OAuthService $oauthService, CaptchaService $captchaService): void { - $this->google2FA = $google2FA; $this->oauthService = $oauthService; $this->captchaService = $captchaService; } - public function authenticate(): ?LoginResponse - { - $data = $this->form->getState(); - Filament::auth()->once($this->getCredentialsFromFormData($data)); - - /** @var ?User $user */ - $user = Filament::auth()->user(); - - // Make sure that rate limits apply - if (!$user) { - return parent::authenticate(); - } - - // 2FA disabled - if (!$user->use_totp) { - return parent::authenticate(); - } - - $token = $data['2fa'] ?? null; - - // 2FA not shown yet - if ($token === null) { - $this->verifyTwoFactor = true; - - Activity::event('auth:checkpoint') - ->withRequestMetadata() - ->subject($user) - ->log(); - - return null; - } - - $isValidToken = false; - if (strlen($token) === $this->google2FA->getOneTimePasswordLength()) { - $isValidToken = $this->google2FA->verifyKey( - $user->totp_secret, - $token, - Config::integer('panel.auth.2fa.window'), - ); - - if ($isValidToken) { - event(new ProvidedAuthenticationToken($user)); - } - } else { - foreach ($user->recoveryTokens as $recoveryToken) { - if (password_verify($token, $recoveryToken->token)) { - $isValidToken = true; - $recoveryToken->delete(); - - event(new ProvidedAuthenticationToken($user, true)); - - break; - } - } - } - - if (!$isValidToken) { - // Buffer to prevent bruteforce - Sleep::sleep(1); - - Notification::make() - ->title(trans('auth.failed-two-factor')) - ->body(trans('auth.failed')) - ->color('danger') - ->icon('tabler-auth-2fa') - ->danger() - ->send(); - - return null; - } - - return parent::authenticate(); - } - public function form(Schema $schema): Schema { $components = [ @@ -119,28 +32,16 @@ class Login extends \Filament\Auth\Pages\Login $this->getPasswordFormComponent(), $this->getRememberFormComponent(), $this->getOAuthFormComponent(), - $this->getTwoFactorAuthenticationComponent(), ]; if ($captchaComponent = $this->getCaptchaComponent()) { - $schema = array_merge($schema, [$captchaComponent]); + $components[] = $captchaComponent; } return $schema ->components($components); } - private function getTwoFactorAuthenticationComponent(): Component - { - return TextInput::make('2fa') - ->label(trans('auth.two-factor-code')) - ->hintIcon('tabler-question-mark') - ->hintIconTooltip(trans('auth.two-factor-hint')) - ->visible(fn () => $this->verifyTwoFactor) - ->required() - ->live(); - } - private function getCaptchaComponent(): ?Component { return $this->captchaService->getActiveSchema()?->getFormComponent(); @@ -178,7 +79,7 @@ class Login extends \Filament\Auth\Pages\Login $actions[] = Action::make("oauth_$id") ->label($schema->getName()) ->icon($schema->getIcon()) - //TODO ->color(Color::hex($oauthProvider->getHexColor())) + ->color(Color::generateV3Palette($schema->getHexColor())) ->url(route('auth.oauth.redirect', ['driver' => $id], false)); } diff --git a/app/Filament/Server/Resources/FileResource/Pages/ListFiles.php b/app/Filament/Server/Resources/FileResource/Pages/ListFiles.php index 5e7e5df64..23dd2c84c 100644 --- a/app/Filament/Server/Resources/FileResource/Pages/ListFiles.php +++ b/app/Filament/Server/Resources/FileResource/Pages/ListFiles.php @@ -540,8 +540,8 @@ class ListFiles extends ListRecords ]); } - /** @return array - * @throws Exception + /** + * @return array */ protected function getDefaultHeaderActions(): array { diff --git a/app/Http/Controllers/Api/Client/TwoFactorController.php b/app/Http/Controllers/Api/Client/TwoFactorController.php deleted file mode 100644 index e2edb3176..000000000 --- a/app/Http/Controllers/Api/Client/TwoFactorController.php +++ /dev/null @@ -1,110 +0,0 @@ -user()->use_totp) { - throw new BadRequestHttpException('Two-factor authentication is already enabled on this account.'); - } - - return new JsonResponse([ - 'data' => $this->setupService->handle($request->user()), - ]); - } - - /** - * Enable 2fa - * - * Updates a user's account to have two-factor enabled. - * - * @throws Throwable - * @throws ValidationException - */ - public function store(Request $request): JsonResponse - { - $validator = $this->validation->make($request->all(), [ - 'code' => ['required', 'string', 'size:6'], - 'password' => ['required', 'string'], - ]); - - $data = $validator->validate(); - if (!password_verify($data['password'], $request->user()->password)) { - throw new BadRequestHttpException('The password provided was not valid.'); - } - - $tokens = $this->toggleTwoFactorService->handle($request->user(), $data['code'], true); - - Activity::event('user:two-factor.create')->log(); - - return new JsonResponse([ - 'object' => 'recovery_tokens', - 'attributes' => [ - 'tokens' => $tokens, - ], - ]); - } - - /** - * Disable 2fa - * - * Disables two-factor authentication on an account if the password provided - * is valid. - * - * @throws Throwable - */ - public function delete(Request $request): JsonResponse - { - if (!password_verify($request->input('password') ?? '', $request->user()->password)) { - throw new BadRequestHttpException('The password provided was not valid.'); - } - - /** @var User $user */ - $user = $request->user(); - - $user->update([ - 'totp_authenticated_at' => Carbon::now(), - 'use_totp' => false, - ]); - - Activity::event('user:two-factor.delete')->log(); - - return new JsonResponse([], Response::HTTP_NO_CONTENT); - } -} diff --git a/app/Http/Middleware/RequireTwoFactorAuthentication.php b/app/Http/Middleware/RequireTwoFactorAuthentication.php deleted file mode 100644 index 490ab9f40..000000000 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ /dev/null @@ -1,64 +0,0 @@ -user(); - - $uri = rtrim($request->getRequestUri(), '/') . '/'; - $current = $request->route()->getName(); - - if (!$user || Str::startsWith($uri, ['/auth/', '/profile']) || Str::startsWith($current, ['auth.', 'account.', 'filament.app.auth.'])) { - return $next($request); - } - - $level = (int) config('panel.auth.2fa_required'); - - if ($level === self::LEVEL_NONE || $user->use_totp) { - // If this setting is not configured, or the user is already using 2FA then we can just send them right through, nothing else needs to be checked. - return $next($request); - } elseif ($level === self::LEVEL_ADMIN && !$user->isAdmin()) { - // If the level is set as admin and the user is not an admin, pass them through as well. - return $next($request); - } - - // For API calls return an exception which gets rendered nicely in the API response... - if ($request->isJson() || Str::startsWith($uri, '/api/')) { - throw new TwoFactorAuthRequiredException(); - } - - // ... otherwise display banner and redirect to profile - AlertBanner::make('2fa_must_be_enabled') - ->body(trans('auth.2fa_must_be_enabled')) - ->warning() - ->send(); - - return redirect(EditProfile::getUrl(['tab' => '-2fa-tab'], panel: 'app')); - } -} diff --git a/app/Jobs/ProcessWebhook.php b/app/Jobs/ProcessWebhook.php index 526009bbd..0636db929 100644 --- a/app/Jobs/ProcessWebhook.php +++ b/app/Jobs/ProcessWebhook.php @@ -4,7 +4,6 @@ namespace App\Jobs; use Exception; use App\Models\WebhookConfiguration; -use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; diff --git a/app/Models/User.php b/app/Models/User.php index 9fb0b559a..2f6d718fe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,6 +29,9 @@ use Illuminate\Auth\Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Builder; use App\Models\Traits\HasAccessTokens; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery; +use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication; use Filament\Models\Contracts\HasAvatar; use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -52,10 +55,10 @@ use Spatie\Permission\Traits\HasRoles; * @property string|null $remember_token * @property string $language * @property string $timezone - * @property bool $use_totp - * @property string|null $totp_secret - * @property Carbon|null $totp_authenticated_at * @property string[]|null $oauth + * @property string|null $mfa_app_secret + * @property string[]|null $mfa_app_recovery_codes + * @property bool $mfa_email_enabled * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property \Illuminate\Database\Eloquent\Collection|ApiKey[] $apiKeys @@ -87,14 +90,11 @@ use Spatie\Permission\Traits\HasRoles; * @method static Builder|User whereTimezone($value) * @method static Builder|User wherePassword($value) * @method static Builder|User whereRememberToken($value) - * @method static Builder|User whereTotpAuthenticatedAt($value) - * @method static Builder|User whereTotpSecret($value) * @method static Builder|User whereUpdatedAt($value) - * @method static Builder|User whereUseTotp($value) * @method static Builder|User whereUsername($value) * @method static Builder|User whereUuid($value) */ -class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName, HasTenants, Validatable +class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasAvatar, HasEmailAuthentication, HasName, HasTenants, Validatable { use Authenticatable; use Authorizable { can as protected canned; } @@ -125,9 +125,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'password', 'language', 'timezone', - 'use_totp', - 'totp_secret', - 'totp_authenticated_at', + 'mfa_app_secret', + 'mfa_app_recovery_codes', 'oauth', 'customization', ]; @@ -135,7 +134,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac /** * The attributes excluded from the model's JSON form. */ - protected $hidden = ['password', 'remember_token', 'totp_secret', 'totp_authenticated_at', 'oauth']; + protected $hidden = ['password', 'remember_token', 'mfa_app_secret', 'mfa_app_recovery_codes', 'oauth']; /** * Default values for specific fields in the database. @@ -144,8 +143,9 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'external_id' => null, 'language' => 'en', 'timezone' => 'UTC', - 'use_totp' => false, - 'totp_secret' => null, + 'mfa_app_secret' => null, + 'mfa_app_recovery_codes' => null, + 'mfa_email_enabled' => false, 'oauth' => '[]', 'customization' => null, ]; @@ -159,8 +159,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'password' => ['sometimes', 'nullable', 'string'], 'language' => ['string'], 'timezone' => ['string'], - 'use_totp' => ['boolean'], - 'totp_secret' => ['nullable', 'string'], + 'mfa_app_secret' => ['nullable', 'string'], + 'mfa_app_recovery_codes' => ['nullable', 'array'], + 'mfa_app_recovery_codes.*' => ['string'], + 'mfa_email_enabled' => ['boolean'], 'oauth' => ['array', 'nullable'], 'customization' => ['array', 'nullable'], 'customization.console_rows' => ['integer', 'min:1'], @@ -171,9 +173,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac protected function casts(): array { return [ - 'use_totp' => 'boolean', - 'totp_authenticated_at' => 'datetime', - 'totp_secret' => 'encrypted', + 'mfa_app_secret' => 'encrypted', + 'mfa_app_recovery_codes' => 'encrypted:array', 'oauth' => 'array', 'customization' => 'array', ]; @@ -320,6 +321,12 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac return $this->belongsToMany(Server::class, 'subusers'); } + /** @return array */ + public function getCustomization(): array + { + return json_decode($this->customization, true) ?? []; + } + protected function checkPermission(Server $server, string $permission = ''): bool { if ($this->canned('update', $server) || $server->owner_id === $this->id) { @@ -444,9 +451,44 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac return false; } - /** @return array */ - public function getCustomization(): array + public function getAppAuthenticationSecret(): ?string { - return json_decode($this->customization, true) ?? []; + return $this->mfa_app_secret; + } + + public function saveAppAuthenticationSecret(?string $secret): void + { + $this->update(['mfa_app_secret' => $secret]); + } + + public function getAppAuthenticationHolderName(): string + { + return $this->email; + } + + /** + * @return array|null + */ + public function getAppAuthenticationRecoveryCodes(): ?array + { + return $this->mfa_app_recovery_codes; + } + + /** + * @param array|null $codes + */ + public function saveAppAuthenticationRecoveryCodes(?array $codes): void + { + $this->update(['mfa_app_recovery_codes' => $codes]); + } + + public function hasEmailAuthentication(): bool + { + return $this->mfa_email_enabled; + } + + public function toggleEmailAuthentication(bool $condition): void + { + $this->update(['mfa_email_enabled' => $condition]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5cc12b778..26c0376c8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,6 +22,7 @@ use App\Checks\NodeVersionsCheck; use App\Checks\PanelVersionCheck; use App\Checks\ScheduleCheck; use App\Checks\UsedDiskSpaceCheck; +use App\Filament\Components\Actions\CopyAction; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Support\Generator\OpenApi; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 62503112e..3c098ebbf 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -5,8 +5,9 @@ namespace App\Providers\Filament; use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\Login; use App\Http\Middleware\LanguageMiddleware; -use App\Http\Middleware\RequireTwoFactorAuthentication; use Filament\Actions\Action; +use Filament\Auth\MultiFactor\App\AppAuthentication; +use Filament\Auth\MultiFactor\Email\EmailAuthentication; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; @@ -42,6 +43,10 @@ class AdminPanelProvider extends PanelProvider ->login(Login::class) ->profile(EditProfile::class, false) ->passwordReset() + ->multiFactorAuthentication([ + AppAuthentication::make()->recoverable(), + EmailAuthentication::make(), + ]) ->userMenuItems([ 'profile' => fn (Action $action) => $action->label(auth()->user()->username), Action::make('exitAdmin') @@ -72,7 +77,6 @@ class AdminPanelProvider extends PanelProvider DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, LanguageMiddleware::class, - RequireTwoFactorAuthentication::class, ]) ->authMiddleware([ Authenticate::class, diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 83c239052..52f269422 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -5,8 +5,9 @@ namespace App\Providers\Filament; use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\EditProfile; use App\Http\Middleware\LanguageMiddleware; -use App\Http\Middleware\RequireTwoFactorAuthentication; use Filament\Actions\Action; +use Filament\Auth\MultiFactor\App\AppAuthentication; +use Filament\Auth\MultiFactor\Email\EmailAuthentication; use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -40,6 +41,10 @@ class AppPanelProvider extends PanelProvider ->profile(EditProfile::class, false) ->login(Login::class) ->passwordReset() + ->multiFactorAuthentication([ + AppAuthentication::make()->recoverable(), + EmailAuthentication::make(), + ]) ->userMenuItems([ 'profile' => fn (Action $action) => $action->label(auth()->user()->username), Action::make('toAdmin') @@ -61,7 +66,6 @@ class AppPanelProvider extends PanelProvider DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, LanguageMiddleware::class, - RequireTwoFactorAuthentication::class, ]) ->authMiddleware([ Authenticate::class, diff --git a/app/Providers/Filament/ServerPanelProvider.php b/app/Providers/Filament/ServerPanelProvider.php index fcec64aba..7996b3e7c 100644 --- a/app/Providers/Filament/ServerPanelProvider.php +++ b/app/Providers/Filament/ServerPanelProvider.php @@ -8,9 +8,10 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Admin\Resources\ServerResource\Pages\EditServer; use App\Http\Middleware\Activity\ServerSubject; use App\Http\Middleware\LanguageMiddleware; -use App\Http\Middleware\RequireTwoFactorAuthentication; use App\Models\Server; use Filament\Actions\Action; +use Filament\Auth\MultiFactor\App\AppAuthentication; +use Filament\Auth\MultiFactor\Email\EmailAuthentication; use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -46,6 +47,10 @@ class ServerPanelProvider extends PanelProvider ->login(Login::class) ->profile(EditProfile::class, false) ->passwordReset() + ->multiFactorAuthentication([ + AppAuthentication::make()->recoverable(), + EmailAuthentication::make(), + ]) ->userMenuItems([ 'profile' => fn (Action $action) => $action->label(auth()->user()->username)->url(fn () => EditProfile::getUrl(panel: 'app')), Action::make('toServerList') @@ -81,7 +86,6 @@ class ServerPanelProvider extends PanelProvider DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, LanguageMiddleware::class, - RequireTwoFactorAuthentication::class, ServerSubject::class, ]) ->authMiddleware([ diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 9afa0ef5b..2e39363f2 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -7,7 +7,6 @@ use Illuminate\Foundation\Http\Middleware\TrimStrings; use Illuminate\Support\Facades\Route; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; -use App\Http\Middleware\RequireTwoFactorAuthentication; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; class RouteServiceProvider extends ServiceProvider @@ -29,17 +28,17 @@ class RouteServiceProvider extends ServiceProvider $this->routes(function () { Route::middleware('web')->group(function () { - Route::middleware(['auth.session', RequireTwoFactorAuthentication::class]) + Route::middleware(['auth.session']) ->prefix('docs') ->group(base_path('routes/docs.php')); - Route::middleware(['auth.session', RequireTwoFactorAuthentication::class]) + Route::middleware(['auth.session']) ->group(base_path('routes/base.php')); Route::middleware('guest')->prefix('/auth')->group(base_path('routes/auth.php')); }); - Route::middleware(['api', RequireTwoFactorAuthentication::class])->group(function () { + Route::middleware(['api'])->group(function () { Route::middleware(['application-api', 'throttle:api.application']) ->prefix('/api/application') ->scopeBindings() diff --git a/app/Services/Users/ToggleTwoFactorService.php b/app/Services/Users/ToggleTwoFactorService.php deleted file mode 100644 index ac7750d5e..000000000 --- a/app/Services/Users/ToggleTwoFactorService.php +++ /dev/null @@ -1,70 +0,0 @@ -google2FA->verifyKey($user->totp_secret, $token, config()->get('panel.auth.2fa.window')); - - if (!$isValidToken) { - throw new TwoFactorAuthenticationTokenInvalid(); - } - - return $this->connection->transaction(function () use ($user, $toggleState) { - // Now that we're enabling 2FA on the account, generate 10 recovery tokens for the account - // and store them hashed in the database. We'll return them to the caller so that the user - // can see and save them. - // - // If a user is unable to login with a 2FA token they can provide one of these backup codes - // which will then be marked as deleted from the database and will also bypass 2FA protections - // on their account. - $tokens = []; - if ((!$toggleState && !$user->use_totp) || $toggleState) { - $user->recoveryTokens()->delete(); - for ($i = 0; $i < 10; $i++) { - $token = str_random(10); - $user->recoveryTokens()->forceCreate([ - 'token' => password_hash($token, PASSWORD_DEFAULT), - ]); - $tokens[] = $token; - } - } - - $user->totp_authenticated_at = now(); - $user->use_totp = (is_null($toggleState) ? !$user->use_totp : $toggleState); - $user->save(); - - return $tokens; - }); - } -} diff --git a/app/Services/Users/TwoFactorSetupService.php b/app/Services/Users/TwoFactorSetupService.php deleted file mode 100644 index dff4db8f4..000000000 --- a/app/Services/Users/TwoFactorSetupService.php +++ /dev/null @@ -1,49 +0,0 @@ -getMessage(), 0, $exception); - } - - $user->totp_secret = $secret; - $user->save(); - - $company = urlencode(preg_replace('/\s/', '', config('app.name'))); - - return [ - 'image_url_data' => sprintf( - 'otpauth://totp/%1$s:%2$s?secret=%3$s&issuer=%1$s', - rawurlencode($company), - rawurlencode($user->email), - rawurlencode($secret), - ), - 'secret' => $secret, - ]; - } -} diff --git a/app/Transformers/Api/Application/UserTransformer.php b/app/Transformers/Api/Application/UserTransformer.php index 1d2ddc1e8..e508470e2 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -36,8 +36,8 @@ class UserTransformer extends BaseTransformer 'email' => $user->email, 'language' => $user->language, 'root_admin' => $user->isRootAdmin(), - '2fa_enabled' => (bool) $user->use_totp, - '2fa' => (bool) $user->use_totp, // deprecated, use "2fa_enabled" + '2fa_enabled' => filled($user->mfa_app_secret), + '2fa' => filled($user->mfa_app_secret), // deprecated, use "2fa_enabled" 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ]; diff --git a/app/Transformers/Api/Client/UserTransformer.php b/app/Transformers/Api/Client/UserTransformer.php index df1f4cd93..704cdc2e6 100644 --- a/app/Transformers/Api/Client/UserTransformer.php +++ b/app/Transformers/Api/Client/UserTransformer.php @@ -30,7 +30,7 @@ class UserTransformer extends BaseClientTransformer 'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), // deprecated 'admin' => $user->isRootAdmin(), // deprecated, use "root_admin" 'root_admin' => $user->isRootAdmin(), - '2fa_enabled' => (bool) $user->use_totp, + '2fa_enabled' => filled($user->mfa_app_secret), 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ]; diff --git a/composer.json b/composer.json index 0329c8966..a32fa96a2 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,6 @@ "league/flysystem-aws-s3-v3": "^3.29", "league/flysystem-memory": "^3.29", "phpseclib/phpseclib": "~3.0.18", - "pragmarx/google2fa": "~8.0.0", "predis/predis": "^2.3", "s1lentium/iptools": "~1.2.0", "secondnetwork/blade-tabler-icons": "^3.26", diff --git a/database/Factories/UserFactory.php b/database/Factories/UserFactory.php index 3ac740d67..d21cc693f 100644 --- a/database/Factories/UserFactory.php +++ b/database/Factories/UserFactory.php @@ -31,7 +31,6 @@ class UserFactory extends Factory 'email' => Str::random(32) . '@example.com', 'password' => $password ?: $password = bcrypt('password'), 'language' => 'en', - 'use_totp' => false, 'oauth' => [], 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), diff --git a/database/migrations/2025_07_22_091435_update_users_totp.php b/database/migrations/2025_07_22_091435_update_users_totp.php new file mode 100644 index 000000000..057e5713b --- /dev/null +++ b/database/migrations/2025_07_22_091435_update_users_totp.php @@ -0,0 +1,47 @@ +text('mfa_app_secret')->nullable(); + $table->text('mfa_app_recovery_codes')->nullable(); + $table->boolean('mfa_email_enabled')->default(false); + }); + + $users = User::all(); + foreach ($users as $user) { + $user->update([ + 'mfa_app_secret' => $user->totp_secret, + 'mfa_app_recovery_codes' => null, + 'mfa_email_enabled' => false, + ]); + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('use_totp'); + $table->dropColumn('totp_secret'); + $table->dropColumn('totp_authenticated_at'); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Not needed + } +}; diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php index 7606c80fd..6d1c06a3e 100644 --- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -38,7 +38,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->isRootAdmin(), - '2fa' => (bool) $user->totp_enabled, + '2fa' => filled($user->mfa_app_secret), 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], diff --git a/tests/Integration/Api/Application/Users/UserControllerTest.php b/tests/Integration/Api/Application/Users/UserControllerTest.php index f98d33216..ee5dec06d 100644 --- a/tests/Integration/Api/Application/Users/UserControllerTest.php +++ b/tests/Integration/Api/Application/Users/UserControllerTest.php @@ -56,8 +56,8 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'email' => $this->getApiUser()->email, 'language' => $this->getApiUser()->language, 'root_admin' => $this->getApiUser()->isRootAdmin(), - '2fa_enabled' => (bool) $this->getApiUser()->totp_enabled, - '2fa' => (bool) $this->getApiUser()->totp_enabled, + '2fa_enabled' => filled($this->getApiUser()->mfa_app_secret), + '2fa' => filled($this->getApiUser()->mfa_app_secret), 'created_at' => $this->formatTimestamp($this->getApiUser()->created_at), 'updated_at' => $this->formatTimestamp($this->getApiUser()->updated_at), ], @@ -72,8 +72,8 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->isRootAdmin(), - '2fa_enabled' => (bool) $user->totp_enabled, - '2fa' => (bool) $user->totp_enabled, + '2fa_enabled' => filled($user->mfa_app_secret), + '2fa' => filled($user->mfa_app_secret), 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], @@ -105,7 +105,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->root_admin, - '2fa' => (bool) $user->totp_enabled, + '2fa' => filled($user->mfa_app_secret), 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], diff --git a/tests/Integration/Api/Client/TwoFactorControllerTest.php b/tests/Integration/Api/Client/TwoFactorControllerTest.php deleted file mode 100644 index a36a27ccb..000000000 --- a/tests/Integration/Api/Client/TwoFactorControllerTest.php +++ /dev/null @@ -1,200 +0,0 @@ -create(['use_totp' => false]); - - $this->assertFalse($user->use_totp); - $this->assertEmpty($user->totp_secret); - $this->assertEmpty($user->totp_authenticated_at); - - $response = $this->actingAs($user)->getJson('/api/client/account/two-factor'); - - $response->assertOk(); - $response->assertJsonStructure(['data' => ['image_url_data']]); - - $user = $user->refresh(); - - $this->assertFalse($user->use_totp); - $this->assertNotEmpty($user->totp_secret); - $this->assertEmpty($user->totp_authenticated_at); - } - - /** - * Test that an error is returned if the user's account already has 2FA enabled on it. - */ - public function test_error_is_returned_when_two_factor_is_already_enabled(): void - { - /** @var \App\Models\User $user */ - $user = User::factory()->create(['use_totp' => true]); - - $response = $this->actingAs($user)->getJson('/api/client/account/two-factor'); - - $response->assertStatus(Response::HTTP_BAD_REQUEST); - $response->assertJsonPath('errors.0.code', 'BadRequestHttpException'); - $response->assertJsonPath('errors.0.detail', 'Two-factor authentication is already enabled on this account.'); - } - - /** - * Test that a validation error is thrown if invalid data is passed to the 2FA endpoint. - */ - public function test_validation_error_is_returned_if_invalid_data_is_passed_to_enabled2_fa(): void - { - /** @var \App\Models\User $user */ - $user = User::factory()->create(['use_totp' => false]); - - $this->actingAs($user) - ->postJson('/api/client/account/two-factor', ['code' => '']) - ->assertUnprocessable() - ->assertJsonPath('errors.0.meta.rule', 'required') - ->assertJsonPath('errors.0.meta.source_field', 'code') - ->assertJsonPath('errors.1.meta.rule', 'required') - ->assertJsonPath('errors.1.meta.source_field', 'password'); - } - - /** - * Tests that 2FA can be enabled on an account for the user. - */ - public function test_two_factor_can_be_enabled_on_account(): void - { - /** @var \App\Models\User $user */ - $user = User::factory()->create(['use_totp' => false]); - - // Make the initial call to get the account setup for 2FA. - $this->actingAs($user)->getJson('/api/client/account/two-factor')->assertOk(); - - $user = $user->refresh(); - $this->assertNotNull($user->totp_secret); - - /** @var \PragmaRX\Google2FA\Google2FA $service */ - $service = $this->app->make(Google2FA::class); - - $token = $service->getCurrentOtp($user->totp_secret); - - $response = $this->actingAs($user)->postJson('/api/client/account/two-factor', [ - 'code' => $token, - 'password' => 'password', - ]); - - $response->assertOk(); - $response->assertJsonPath('object', 'recovery_tokens'); - - $user = $user->refresh(); - $this->assertTrue($user->use_totp); - - $tokens = RecoveryToken::query()->where('user_id', $user->id)->get(); - $this->assertCount(10, $tokens); - $this->assertStringStartsWith('$2y$', $tokens[0]->token); - - // Ensure the recovery tokens that were created include a "created_at" timestamp value on them. - $this->assertNotNull($tokens[0]->created_at); - - $tokens = $tokens->pluck('token')->toArray(); - - $rawTokens = $response->json('attributes.tokens'); - $rawToken = reset($rawTokens); - $hashed = reset($tokens); - - throw_unless(password_verify($rawToken, $hashed), new ExpectationFailedException(sprintf('Failed asserting that token [%s] exists as a hashed value in recovery_tokens table.', $rawToken))); - } - - /** - * Test that two-factor authentication can be disabled on an account as long as the password - * provided is valid for the account. - */ - public function test_two_factor_can_be_disabled_on_account(): void - { - Carbon::setTestNow(Carbon::now()); - - /** @var \App\Models\User $user */ - $user = User::factory()->create(['use_totp' => true]); - - $response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [ - 'password' => 'invalid', - ]); - - $response->assertStatus(Response::HTTP_BAD_REQUEST); - $response->assertJsonPath('errors.0.code', 'BadRequestHttpException'); - $response->assertJsonPath('errors.0.detail', 'The password provided was not valid.'); - - $response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [ - 'password' => 'password', - ]); - - $response->assertStatus(Response::HTTP_NO_CONTENT); - - $user = $user->refresh(); - $this->assertFalse($user->use_totp); - $this->assertNotNull($user->totp_authenticated_at); - $this->assertTrue(now()->isSameAs('Y-m-d H:i:s', $user->totp_authenticated_at)); - } - - /** - * Test that no error is returned when trying to disabled two factor on an account where it - * was not enabled in the first place. - */ - public function test_no_error_is_returned_if_two_factor_is_not_enabled(): void - { - Carbon::setTestNow(Carbon::now()); - - /** @var \App\Models\User $user */ - $user = User::factory()->create(['use_totp' => false]); - - $response = $this->actingAs($user)->deleteJson('/api/client/account/two-factor', [ - 'password' => 'password', - ]); - - $response->assertStatus(Response::HTTP_NO_CONTENT); - } - - /** - * Test that a valid account password is required when enabling two-factor. - */ - public function test_enabling_two_factor_requires_valid_password(): void - { - $user = User::factory()->create(['use_totp' => false]); - - $this->actingAs($user) - ->postJson('/api/client/account/two-factor', [ - 'code' => '123456', - 'password' => 'foo', - ]) - ->assertStatus(Response::HTTP_BAD_REQUEST) - ->assertJsonPath('errors.0.detail', 'The password provided was not valid.'); - - $this->assertFalse($user->refresh()->use_totp); - } - - /** - * Test that a valid account password is required when disabling two-factor. - */ - public function test_disabling_two_factor_requires_valid_password(): void - { - $user = User::factory()->create(['use_totp' => true]); - - $this->actingAs($user) - ->deleteJson('/api/client/account/two-factor', [ - 'password' => 'foo', - ]) - ->assertStatus(Response::HTTP_BAD_REQUEST) - ->assertJsonPath('errors.0.detail', 'The password provided was not valid.'); - - $this->assertTrue($user->refresh()->use_totp); - } -}