diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000000..7bf18d0a4d --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,40 @@ + $input + */ + public function create(array $input): User + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique(User::class), + ], + 'password' => $this->passwordRules(), + ])->validate(); + + return User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + } +} diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000000..76b19d3309 --- /dev/null +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,18 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000000..7a57c5037b --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,29 @@ + $input + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000000..7005639052 --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,32 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000000..0930ddf38f --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,58 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users')->ignore($user->id), + ], + ])->validateWithBag('updateProfileInformation'); + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/app/Console/Commands/User/DisableTwoFactorCommand.php b/app/Console/Commands/User/DisableTwoFactorCommand.php deleted file mode 100644 index 8a14c81fd4..0000000000 --- a/app/Console/Commands/User/DisableTwoFactorCommand.php +++ /dev/null @@ -1,34 +0,0 @@ -input->isInteractive()) { - $this->output->warning(trans('command/messages.user.2fa_help_text')); - } - - $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(); - - $this->info(trans('command/messages.user.2fa_disabled', ['email' => $user->email])); - } -} diff --git a/app/Events/Auth/ProvidedAuthenticationToken.php b/app/Events/Auth/ProvidedAuthenticationToken.php deleted file mode 100644 index 69fd69ec05..0000000000 --- a/app/Events/Auth/ProvidedAuthenticationToken.php +++ /dev/null @@ -1,11 +0,0 @@ -searchable() ->icon('tabler-mail'), - IconColumn::make('use_totp') - ->label('2FA') - ->visibleFrom('lg') - ->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off') - ->boolean() - ->sortable(), TextColumn::make('roles.name') ->label('Roles') ->badge() diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index f328fdeb48..574be4c84f 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -8,14 +8,7 @@ use App\Models\ActivityLog; use App\Models\ApiKey; use App\Models\User; -use App\Services\Helpers\LanguageService; -use App\Services\Users\ToggleTwoFactorService; -use App\Services\Users\TwoFactorSetupService; use App\Services\Users\UserUpdateService; -use chillerlan\QRCode\Common\EccLevel; -use chillerlan\QRCode\Common\Version; -use chillerlan\QRCode\QRCode; -use chillerlan\QRCode\QROptions; use DateTimeZone; use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions\Action; @@ -27,7 +20,6 @@ use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs\Tab; use Filament\Forms\Components\TagsInput; -use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Get; use Filament\Notifications\Notification; @@ -47,13 +39,6 @@ */ class EditProfile extends BaseEditProfile { - private ToggleTwoFactorService $toggleTwoFactorService; - - public function boot(ToggleTwoFactorService $toggleTwoFactorService): void - { - $this->toggleTwoFactorService = $toggleTwoFactorService; - } - public function getMaxWidth(): MaxWidth|string { return config('panel.filament.display-width', 'screen-2xl'); @@ -175,84 +160,6 @@ protected function getForms(): array return [Actions::make($actions)]; }), - - Tab::make('2FA') - ->icon('tabler-shield-lock') - ->schema(function (TwoFactorSetupService $setupService) { - if ($this->getUser()->use_totp) { - return [ - Placeholder::make('2fa-already-enabled') - ->label('Two Factor Authentication is currently 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('These will not be shown again!') - ->label('Backup Tokens:'), - TextInput::make('2fa-disable-code') - ->label('Disable 2FA') - ->helperText('Enter your current 2FA code to disable Two Factor Authentication'), - ]; - } - - ['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 [ - Placeholder::make('qr') - ->label('Scan QR Code') - ->content(fn () => new HtmlString(" -
$image
- ")) - ->helperText('Setup Key: ' . $secret), - TextInput::make('2facode') - ->label('Code') - ->requiredWith('2fapassword') - ->helperText('Scan the QR code above using your two-step authentication app, then enter the code generated.'), - TextInput::make('2fapassword') - ->label('Current Password') - ->requiredWith('2facode') - ->currentPassword() - ->password() - ->helperText('Enter your current password to verify.'), - ]; - }), Tab::make('API Keys') ->icon('tabler-key') ->schema([ diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php index 681b0b5982..c54e14deca 100644 --- a/app/Filament/Pages/Auth/Login.php +++ b/app/Filament/Pages/Auth/Login.php @@ -8,11 +8,11 @@ use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Component; use Filament\Forms\Components\TextInput; -use Filament\Pages\Auth\Login as BaseLogin; +use Vormkracht10\TwoFactorAuth\Http\Livewire\Auth\Login as TwoFactorLogin; use Filament\Support\Colors\Color; use Illuminate\Validation\ValidationException; -class Login extends BaseLogin +class Login extends TwoFactorLogin { protected function getForms(): array { diff --git a/app/Http/Controllers/Api/Client/TwoFactorController.php b/app/Http/Controllers/Api/Client/TwoFactorController.php deleted file mode 100644 index 62340e515b..0000000000 --- a/app/Http/Controllers/Api/Client/TwoFactorController.php +++ /dev/null @@ -1,100 +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()), - ]); - } - - /** - * Updates a user's account to have two-factor enabled. - * - * @throws \Throwable - * @throws \Illuminate\Validation\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, - ], - ]); - } - - /** - * 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 \App\Models\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 3a098c5bd2..0000000000 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ /dev/null @@ -1,59 +0,0 @@ -user(); - $uri = rtrim($request->getRequestUri(), '/') . '/'; - $current = $request->route()->getName(); - - if (!$user || Str::startsWith($uri, ['/auth/']) || Str::startsWith($current, ['auth.', 'account.'])) { - return $next($request); - } - - /** @var \App\Models\User $user */ - $level = (int) config('panel.auth.2fa_required'); - // 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. - // - // If the level is set as admin and the user is not an admin, pass them through as well. - if ($level === self::LEVEL_NONE || $user->use_totp) { - return $next($request); - } elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) { - 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(); - } - - return redirect()->to($this->redirectRoute); - } -} diff --git a/app/Models/Server.php b/app/Models/Server.php index 39e1ef3be9..2d8d20fa62 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -490,7 +490,7 @@ public function formatResource(string $resourceKey, bool $limit = false, ServerR public function condition(): Attribute { return Attribute::make( - get: fn () => $this->isSuspended() ? ServerState::Suspended->value : $this->status->value ?? $this->retrieveStatus(), + get: fn () => $this->isSuspended() ? ServerState::Suspended->value : $this->status?->value ?? $this->retrieveStatus(), ); } diff --git a/app/Models/User.php b/app/Models/User.php index f71db79b68..cf8269da00 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,8 +29,10 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification; use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Model as IlluminateModel; +use Laravel\Fortify\TwoFactorAuthenticatable; use ResourceBundle; use Spatie\Permission\Traits\HasRoles; +use Vormkracht10\TwoFactorAuth\Enums\TwoFactorType; /** * App\Models\User. @@ -44,9 +46,6 @@ * @property string|null $remember_token * @property string $language * @property string $timezone - * @property bool $use_totp - * @property string|null $totp_secret - * @property \Illuminate\Support\Carbon|null $totp_authenticated_at * @property array|null $oauth * @property bool $gravatar * @property \Illuminate\Support\Carbon|null $created_at @@ -78,10 +77,7 @@ * @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) */ @@ -93,6 +89,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac use HasAccessTokens; use HasRoles; use Notifiable; + use TwoFactorAuthenticatable; public const USER_LEVEL_USER = 0; @@ -124,9 +121,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'password', 'language', 'timezone', - 'use_totp', - 'totp_secret', - 'totp_authenticated_at', 'gravatar', 'oauth', ]; @@ -134,7 +128,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', 'oauth']; /** * Default values for specific fields in the database. @@ -143,8 +137,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'external_id' => null, 'language' => 'en', 'timezone' => 'UTC', - 'use_totp' => false, - 'totp_secret' => null, 'oauth' => '[]', ]; @@ -159,19 +151,15 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'password' => 'sometimes|nullable|string', 'language' => 'string', 'timezone' => 'string', - 'use_totp' => 'boolean', - 'totp_secret' => 'nullable|string', 'oauth' => 'array|nullable', ]; protected function casts(): array { return [ - 'use_totp' => 'boolean', 'gravatar' => 'boolean', - 'totp_authenticated_at' => 'datetime', - 'totp_secret' => 'encrypted', 'oauth' => 'array', + 'two_factor_type' => TwoFactorType::class, ]; } @@ -269,11 +257,6 @@ public function apiKeys(): HasMany ->where('key_type', ApiKey::TYPE_ACCOUNT); } - public function recoveryTokens(): HasMany - { - return $this->hasMany(RecoveryToken::class); - } - public function sshKeys(): HasMany { return $this->hasMany(UserSSHKey::class); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 50a6e42cdd..bc84b146d6 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,6 +4,9 @@ use App\Listeners\DispatchWebhooks; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged; +use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled; +use Vormkracht10\TwoFactorAuth\Listeners\SendTwoFactorCodeListener; class EventServiceProvider extends ServiceProvider { @@ -15,5 +18,7 @@ class EventServiceProvider extends ServiceProvider 'eloquent.created*' => [DispatchWebhooks::class], 'eloquent.deleted*' => [DispatchWebhooks::class], 'eloquent.updated*' => [DispatchWebhooks::class], + TwoFactorAuthenticationChallenged::class => [SendTwoFactorCodeListener::class], + TwoFactorAuthenticationEnabled::class => [SendTwoFactorCodeListener::class], ]; } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index a79bac4f01..91fe762236 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,7 +2,6 @@ namespace App\Providers\Filament; -use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\EditProfile; use App\Http\Middleware\LanguageMiddleware; use Filament\Http\Middleware\Authenticate; @@ -10,6 +9,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Navigation\MenuItem; use Filament\Navigation\NavigationGroup; +use App\Filament\Pages\Auth\Login; use Filament\Panel; use Filament\PanelProvider; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -19,6 +19,8 @@ use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Vormkracht10\TwoFactorAuth\Pages\TwoFactor; +use Vormkracht10\TwoFactorAuth\TwoFactorAuthPlugin; class AdminPanelProvider extends PanelProvider { @@ -46,6 +48,10 @@ public function panel(Panel $panel): Panel ->url('/') ->icon('tabler-arrow-back') ->sort(24), + MenuItem::make() + ->icon('tabler-auth-2fa') + ->label(('Two-Factor Auth')) + ->url(fn (): string => TwoFactor::getUrl()), ]) ->navigationGroups([ NavigationGroup::make('Server') @@ -71,6 +77,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, - ]); + ]) + ->plugin(TwoFactorAuthPlugin::make()->showInUserMenu(false)); } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 8d5111878f..b197ae6221 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -2,7 +2,6 @@ namespace App\Providers\Filament; -use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\EditProfile; use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; @@ -18,6 +17,9 @@ use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use App\Filament\Pages\Auth\Login; +use Vormkracht10\TwoFactorAuth\Pages\TwoFactor; +use Vormkracht10\TwoFactorAuth\TwoFactorAuthPlugin; class AppPanelProvider extends PanelProvider { @@ -38,6 +40,10 @@ public function panel(Panel $panel): Panel ->profile(EditProfile::class, false) ->login(Login::class) ->userMenuItems([ + MenuItem::make() + ->icon('tabler-auth-2fa') + ->label(('Two-Factor Auth')) + ->url(fn (): string => TwoFactor::getUrl()), MenuItem::make() ->label('Admin') ->url('/admin') @@ -59,6 +65,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, - ]); + ]) + ->plugin(TwoFactorAuthPlugin::make()->showInUserMenu(false)); } } diff --git a/app/Providers/Filament/ServerPanelProvider.php b/app/Providers/Filament/ServerPanelProvider.php index d409b4dd84..466f7247b4 100644 --- a/app/Providers/Filament/ServerPanelProvider.php +++ b/app/Providers/Filament/ServerPanelProvider.php @@ -3,7 +3,6 @@ namespace App\Providers\Filament; use App\Filament\App\Resources\ServerResource\Pages\ListServers; -use App\Filament\Pages\Auth\Login; use App\Filament\Admin\Resources\ServerResource\Pages\EditServer; use App\Filament\Pages\Auth\EditProfile; use App\Http\Middleware\Activity\ServerSubject; @@ -23,6 +22,9 @@ use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use App\Filament\Pages\Auth\Login; +use Vormkracht10\TwoFactorAuth\Pages\TwoFactor; +use Vormkracht10\TwoFactorAuth\TwoFactorAuthPlugin; class ServerPanelProvider extends PanelProvider { @@ -49,6 +51,10 @@ public function panel(Panel $panel): Panel ->icon('tabler-brand-docker') ->url(fn () => ListServers::getUrl(panel: 'app')) ->sort(6), + MenuItem::make() + ->icon('tabler-auth-2fa') + ->label(('Two-Factor Auth')) + ->url(fn (): string => TwoFactor::getUrl()), MenuItem::make() ->label('Admin') ->icon('tabler-arrow-forward') @@ -80,6 +86,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, - ]); + ]) + ->plugin(TwoFactorAuthPlugin::make()->showInUserMenu(false)); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000000..2d741e38cc --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,46 @@ +input(Fortify::username())).'|'.$request->ip()); + + return Limit::perMinute(5)->by($throttleKey); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } +} diff --git a/app/Services/Users/ToggleTwoFactorService.php b/app/Services/Users/ToggleTwoFactorService.php deleted file mode 100644 index 2fdcbe7573..0000000000 --- a/app/Services/Users/ToggleTwoFactorService.php +++ /dev/null @@ -1,80 +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) { - $inserts = []; - for ($i = 0; $i < 10; $i++) { - $token = Str::random(10); - - $inserts[] = [ - 'user_id' => $user->id, - 'token' => password_hash($token, PASSWORD_DEFAULT), - // insert() won't actually set the time on the models, so make sure we do this - // manually here. - 'created_at' => Carbon::now(), - ]; - - $tokens[] = $token; - } - - // Before inserting any new records make sure all the old ones are deleted to avoid - // any issues or storing an unnecessary number of tokens in the database. - $user->recoveryTokens()->delete(); - - // Bulk insert the hashed tokens. - RecoveryToken::query()->insert($inserts); - } - - $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 fb906c9dbf..0000000000 --- a/app/Services/Users/TwoFactorSetupService.php +++ /dev/null @@ -1,44 +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 0a5d751b68..a8bd3ab40d 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -39,8 +39,6 @@ public function transform(User $user): array '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" '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 e2399cdb43..90fc1287eb 100644 --- a/app/Transformers/Api/Client/UserTransformer.php +++ b/app/Transformers/Api/Client/UserTransformer.php @@ -29,7 +29,6 @@ public function transform(User $user): array '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, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ]; diff --git a/bootstrap/providers.php b/bootstrap/providers.php index bd18d94237..76dc255f4c 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -8,6 +8,7 @@ App\Providers\Filament\AdminPanelProvider::class, App\Providers\Filament\AppPanelProvider::class, App\Providers\Filament\ServerPanelProvider::class, + App\Providers\FortifyServiceProvider::class, App\Providers\RouteServiceProvider::class, SocialiteProviders\Manager\ServiceProvider::class, ]; diff --git a/composer.json b/composer.json index b2d2fec92f..b7dd272a21 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "symfony/mailgun-mailer": "^7.1", "symfony/postmark-mailer": "^7.0.7", "symfony/yaml": "^7.0.7", + "vormkracht10/filament-2fa": "^2.0", "webbingbrasil/filament-copyactions": "^3.0.1", "webmozart/assert": "~1.11.0" }, diff --git a/composer.lock b/composer.lock index 2b63b57a53..11ffc43ec3 100644 --- a/composer.lock +++ b/composer.lock @@ -305,6 +305,60 @@ }, "time": "2023-11-22T19:35:38+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + }, + "time": "2024-10-01T13:55:55+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.4.0", @@ -1056,6 +1110,56 @@ ], "time": "2024-05-06T09:10:03+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.6" + }, + "time": "2024-08-09T14:30:48+00:00" + }, { "name": "dedoc/scramble", "version": "v0.10.13", @@ -2949,6 +3053,71 @@ }, "time": "2024-10-06T12:28:14+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.25.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "a20e8033e7329b05820007c398f06065a38ae188" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/a20e8033e7329b05820007c398f06065a38ae188", + "reference": "a20e8033e7329b05820007c398f06065a38ae188", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/support": "^10.0|^11.0", + "php": "^8.1", + "pragmarx/google2fa": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.16|^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2025-01-10T20:33:47+00:00" + }, { "name": "laravel/framework", "version": "v11.37.0", @@ -10765,6 +10934,86 @@ ], "time": "2024-11-21T01:49:47+00:00" }, + { + "name": "vormkracht10/filament-2fa", + "version": "v2.0.14", + "source": { + "type": "git", + "url": "https://github.com/vormkracht10/filament-2fa.git", + "reference": "3144addb85e1d34cfd71b6015b28bf45984a947b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vormkracht10/filament-2fa/zipball/3144addb85e1d34cfd71b6015b28bf45984a947b", + "reference": "3144addb85e1d34cfd71b6015b28bf45984a947b", + "shasum": "" + }, + "require": { + "filament/filament": "^3.0", + "filament/support": "^3.2", + "laravel/fortify": "^1.24", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.15.0" + }, + "require-dev": { + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.9", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.0", + "pestphp/pest": "^2.1", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "TwoFactorAuth": "Vormkracht10\\TwoFactorAuth\\Facades\\TwoFactorAuth" + }, + "providers": [ + "Vormkracht10\\TwoFactorAuth\\TwoFactorAuthServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Vormkracht10\\TwoFactorAuth\\": "src/", + "Vormkracht10\\TwoFactorAuth\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Baspa", + "email": "hello@baspa.dev", + "role": "Developer" + } + ], + "description": "This package helps you integrate Laravel Fortify with ease in your Filament apps. ", + "homepage": "https://github.com/vormkracht10/filament-2fa", + "keywords": [ + "filament-2fa", + "laravel", + "vormkracht10" + ], + "support": { + "issues": "https://github.com/vormkracht10/filament-2fa/issues", + "source": "https://github.com/vormkracht10/filament-2fa" + }, + "funding": [ + { + "url": "https://github.com/vormkracht10", + "type": "github" + } + ], + "time": "2024-12-25T08:39:42+00:00" + }, { "name": "webbingbrasil/filament-copyactions", "version": "3.0.1", diff --git a/config/filament-2fa.php b/config/filament-2fa.php new file mode 100644 index 0000000000..48e1183993 --- /dev/null +++ b/config/filament-2fa.php @@ -0,0 +1,85 @@ + [ + TwoFactorType::authenticator, + TwoFactorType::email, + ], + + 'enabled_features' => [ + /* + |-------------------------------------------------------------------------- + | Register + |-------------------------------------------------------------------------- + | + | This value determines whether users may register in the application. + | + */ + 'register' => false, + + /* + |-------------------------------------------------------------------------- + | Tenant + |-------------------------------------------------------------------------- + | + | Set to true if you're using Filament in a multi-tenant setup. If true, you + | need to manually set the user menu item for the two factor authentication + | page panel class. Take a look at the documentation for more information. + | + */ + 'multi_tenancy' => false, + ], + + /* + |-------------------------------------------------------------------------- + | SMS Service + |-------------------------------------------------------------------------- + | + | To use an SMS service, you need to install the corresponding package. + | You then have to create a App\Notifications\SendOTP class that extends + | the Vormkracht10\TwoFactorAuth\Notifications\SendOTP class. After that, + | you can set the class alias in the sms_service key. + | + */ + 'sms_service' => null, // For example 'vonage', 'twilio', 'nexmo', etc. + 'send_otp_class' => null, + 'phone_number_field' => 'phone', + + /* + |-------------------------------------------------------------------------- + | Class Aliases + |-------------------------------------------------------------------------- + | + | If you want to customize the pages, you can override the used classes here. + | Make your that your classes extend the original classes. + | + */ + 'login' => Login::class, + 'register' => Register::class, + 'challenge' => LoginTwoFactor::class, + 'two_factor_settings' => TwoFactor::class, + 'password_reset' => PasswordReset::class, + 'password_confirmation' => PasswordConfirmation::class, + 'request_password_reset' => RequestPasswordReset::class, +]; diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000000..d79ec78ab1 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,158 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::resetPasswords(), + Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + //'window' => 0, + ]), + ], + +]; diff --git a/database/Factories/UserFactory.php b/database/Factories/UserFactory.php index 3ac740d676..d21cc693f4 100644 --- a/database/Factories/UserFactory.php +++ b/database/Factories/UserFactory.php @@ -31,7 +31,6 @@ public function definition(): array '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/2014_10_12_200000_add_two_factor_columns_to_users_table.php b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000000..45739efa66 --- /dev/null +++ b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/database/migrations/2025_01_15_213927_remove_old_2fa.php b/database/migrations/2025_01_15_213927_remove_old_2fa.php new file mode 100644 index 0000000000..68e35fc3d7 --- /dev/null +++ b/database/migrations/2025_01_15_213927_remove_old_2fa.php @@ -0,0 +1,20 @@ +dropColumn('use_totp', 'totp_secret', 'totp_authenticated_at'); + }); + } + + public function down(): void + { + // Point of no return... + } +}; diff --git a/database/migrations/2025_01_15_214824_add_two_factor_type_column_to_users_table.php b/database/migrations/2025_01_15_214824_add_two_factor_type_column_to_users_table.php new file mode 100644 index 0000000000..22b82c910c --- /dev/null +++ b/database/migrations/2025_01_15_214824_add_two_factor_type_column_to_users_table.php @@ -0,0 +1,18 @@ +enum('two_factor_type', TwoFactorType::names())->nullable()->after('two_factor_recovery_codes'); + }); + } + } +}; diff --git a/resources/views/filament/components/server-console.blade.php b/resources/views/filament/components/server-console.blade.php index a27d638851..adacd12eb6 100644 --- a/resources/views/filament/components/server-console.blade.php +++ b/resources/views/filament/components/server-console.blade.php @@ -54,7 +54,7 @@ class="w-full focus:outline-none focus:ring-0 border-none dark:bg-gray-900" }; let options = { - fontSize: 16, + fontSize: 14, disableStdin: true, cursorStyle: 'underline', cursorInactiveStyle: 'none', diff --git a/resources/views/vendor/filament-2fa/.gitkeep b/resources/views/vendor/filament-2fa/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/views/vendor/filament-2fa/auth/login-two-factor.blade.php b/resources/views/vendor/filament-2fa/auth/login-two-factor.blade.php new file mode 100644 index 0000000000..ba1ac73bfb --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/login-two-factor.blade.php @@ -0,0 +1,34 @@ + +

+ {{ __('Authenticate with your code') }} +

+ @if ($twoFactorType === 'email' || $twoFactorType === 'phone') +
+ {{ $this->resend }} +
+ @endif +
+ @csrf + +
+ +
+ + {{ $this->form }} + +
+ + {{ __('Login') }} + +
+
+
+ + \ No newline at end of file diff --git a/resources/views/vendor/filament-2fa/auth/login.blade.php b/resources/views/vendor/filament-2fa/auth/login.blade.php new file mode 100644 index 0000000000..56ee7d3449 --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/login.blade.php @@ -0,0 +1,12 @@ + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE, scopes: $this->getRenderHookScopes()) }} + + + {{ $this->form }} + + + + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::AUTH_LOGIN_FORM_AFTER, scopes: $this->getRenderHookScopes()) }} + diff --git a/resources/views/vendor/filament-2fa/auth/password-confirmation.blade.php b/resources/views/vendor/filament-2fa/auth/password-confirmation.blade.php new file mode 100644 index 0000000000..2a99600940 --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/password-confirmation.blade.php @@ -0,0 +1,23 @@ +
config('filament.dark_mode'), +])> +
+
config('filament.dark_mode'), + ])> + +

+ {{ __('Confirm') }} +

+ + @csrf + {{ $this->form }} + + + {{ __('Confirm') }} + +
+
+
diff --git a/resources/views/vendor/filament-2fa/auth/password-reset.blade.php b/resources/views/vendor/filament-2fa/auth/password-reset.blade.php new file mode 100644 index 0000000000..de4d23cfce --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/password-reset.blade.php @@ -0,0 +1,31 @@ +
+
+
+ +

+ {{ __('Reset Password') }} +

+ +
+ + @csrf + {{ $this->form }} + + + {{ __('Reset Password') }} + +
+ +
+
+ +
diff --git a/resources/views/vendor/filament-2fa/auth/register.blade.php b/resources/views/vendor/filament-2fa/auth/register.blade.php new file mode 100644 index 0000000000..93456acc31 --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/register.blade.php @@ -0,0 +1,20 @@ + + @if (filament()->hasLogin()) + + {{ __('filament-panels::pages/auth/register.actions.login.before') }} + + {{ $this->loginAction }} + + @endif + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::AUTH_REGISTER_FORM_BEFORE, scopes: $this->getRenderHookScopes()) }} + + + {{ $this->form }} + + + + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::AUTH_REGISTER_FORM_AFTER, scopes: $this->getRenderHookScopes()) }} + + diff --git a/resources/views/vendor/filament-2fa/auth/request-password-reset.blade.php b/resources/views/vendor/filament-2fa/auth/request-password-reset.blade.php new file mode 100644 index 0000000000..92bb4f8e90 --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/request-password-reset.blade.php @@ -0,0 +1,37 @@ +
+
+
+ +

+ {{ __('Reset Password') }} +

+ +
+ @csrf + {{ $this->form }} + +
+ + {{ __('Cancel') }} + + + + {{ __('Submit') }} + +
+
+ +
+
+ +
diff --git a/resources/views/vendor/filament-2fa/auth/verify-email.blade.php b/resources/views/vendor/filament-2fa/auth/verify-email.blade.php new file mode 100644 index 0000000000..b43a4bcb1d --- /dev/null +++ b/resources/views/vendor/filament-2fa/auth/verify-email.blade.php @@ -0,0 +1,9 @@ +
+
+ + @csrf + + {{ __('Verify') }} + +
+
diff --git a/resources/views/vendor/filament-2fa/emails/two-factor-code.blade.php b/resources/views/vendor/filament-2fa/emails/two-factor-code.blade.php new file mode 100644 index 0000000000..e878c55242 --- /dev/null +++ b/resources/views/vendor/filament-2fa/emails/two-factor-code.blade.php @@ -0,0 +1,17 @@ + +{{ __('Hello') }}, + +{{ __('You recently requested to log in to your account. To complete the login, please use the following two-factor authentication (2FA) code:') }} + +
+ + {{ $code }} + +
+ + +{{ __('If you didn\'t try to log in, please change your password immediately to protect your account.') }} + +{{ __('Kind regards') }},
+{{ config('app.name') }} +
\ No newline at end of file diff --git a/resources/views/vendor/filament-2fa/layouts/login.blade.php b/resources/views/vendor/filament-2fa/layouts/login.blade.php new file mode 100644 index 0000000000..1d0a0eb85f --- /dev/null +++ b/resources/views/vendor/filament-2fa/layouts/login.blade.php @@ -0,0 +1,52 @@ +@php + use Filament\Support\Enums\MaxWidth; +@endphp + + + @props([ + 'after' => null, + 'heading' => null, + 'subheading' => null, + ]) + +
+ @if (($hasTopbar ?? true) && filament()->auth()->check()) +
+ @if (filament()->hasDatabaseNotifications()) + @livewire(Filament\Livewire\DatabaseNotifications::class, ['lazy' => true]) + @endif + + +
+ @endif + +
+
'sm:max-w-xs', + MaxWidth::Small, 'sm' => 'sm:max-w-sm', + MaxWidth::Medium, 'md' => 'sm:max-w-md', + MaxWidth::ExtraLarge, 'xl' => 'sm:max-w-xl', + MaxWidth::TwoExtraLarge, '2xl' => 'sm:max-w-2xl', + MaxWidth::ThreeExtraLarge, '3xl' => 'sm:max-w-3xl', + MaxWidth::FourExtraLarge, '4xl' => 'sm:max-w-4xl', + MaxWidth::FiveExtraLarge, '5xl' => 'sm:max-w-5xl', + MaxWidth::SixExtraLarge, '6xl' => 'sm:max-w-6xl', + MaxWidth::SevenExtraLarge, '7xl' => 'sm:max-w-7xl', + default => 'sm:max-w-lg', + }, + ]) + > + {{ $slot }} +
+
+ + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::FOOTER, scopes: $livewire->getRenderHookScopes()) }} +
+
diff --git a/routes/api-client.php b/routes/api-client.php index ed1190af64..c37878e230 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -22,9 +22,6 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function () { Route::prefix('/')->withoutMiddleware(RequireTwoFactorAuthentication::class)->group(function () { Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account'); - Route::get('/two-factor', [Client\TwoFactorController::class, 'index']); - Route::post('/two-factor', [Client\TwoFactorController::class, 'store']); - Route::delete('/two-factor', [Client\TwoFactorController::class, 'delete']); }); Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email'); diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php index 4e7ec06140..33c5fa75ac 100644 --- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -38,7 +38,6 @@ public function testGetRemoteUser(): void 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->isRootAdmin(), - '2fa' => (bool) $user->totp_enabled, '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 3d6b196de9..d061cf54e7 100644 --- a/tests/Integration/Api/Application/Users/UserControllerTest.php +++ b/tests/Integration/Api/Application/Users/UserControllerTest.php @@ -25,8 +25,8 @@ public function testGetUsers(): void $response->assertJsonStructure([ 'object', 'data' => [ - ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], - ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', 'created_at', 'updated_at']], ], 'meta' => ['pagination' => ['total', 'count', 'per_page', 'current_page', 'total_pages']], ]); @@ -55,8 +55,6 @@ public function testGetUsers(): void '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, 'created_at' => $this->formatTimestamp($this->getApiUser()->created_at), 'updated_at' => $this->formatTimestamp($this->getApiUser()->updated_at), ], @@ -71,8 +69,6 @@ public function testGetUsers(): void 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->isRootAdmin(), - '2fa_enabled' => (bool) $user->totp_enabled, - '2fa' => (bool) $user->totp_enabled, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], @@ -91,7 +87,7 @@ public function testGetSingleUser(): void $response->assertJsonCount(2); $response->assertJsonStructure([ 'object', - 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', 'created_at', 'updated_at'], ]); $response->assertJson([ @@ -104,7 +100,6 @@ public function testGetSingleUser(): void 'email' => $user->email, 'language' => $user->language, 'root_admin' => (bool) $user->root_admin, - '2fa' => (bool) $user->totp_enabled, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], @@ -125,7 +120,7 @@ public function testRelationshipsCanBeLoaded(): void $response->assertJsonStructure([ 'object', 'attributes' => [ - 'id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at', + 'id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', 'created_at', 'updated_at', 'relationships' => ['servers' => ['object', 'data' => [['object', 'attributes' => []]]]], ], ]); @@ -243,7 +238,7 @@ public function testUpdateUser(): void $response->assertJsonCount(2); $response->assertJsonStructure([ 'object', - 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', 'created_at', 'updated_at'], ]); $this->assertDatabaseHas('users', ['username' => 'new.test.name', 'email' => 'new@emailtest.com']); diff --git a/tests/Integration/Api/Client/AccountControllerTest.php b/tests/Integration/Api/Client/AccountControllerTest.php index 80c9a21ed6..d3cf4648e4 100644 --- a/tests/Integration/Api/Client/AccountControllerTest.php +++ b/tests/Integration/Api/Client/AccountControllerTest.php @@ -29,7 +29,6 @@ public function testAccountDetailsAreReturned(): void 'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), 'admin' => false, 'root_admin' => false, - '2fa_enabled' => false, 'created_at' => $this->formatTimestamp($user->created_at), 'updated_at' => $this->formatTimestamp($user->updated_at), ], diff --git a/vite.config.js b/vite.config.js index b771712771..5dc58f8c59 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,14 +1,12 @@ import { defineConfig } from 'vite'; -import laravel, { refreshPaths } from 'laravel-vite-plugin' +import laravel, { refreshPaths } from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: [ - ...refreshPaths, - 'app/Livewire/**', - ], + content: ['./vendor/vormkracht10/filament-2fa/resources/**.*.blade.php'], + refresh: [...refreshPaths, 'app/Livewire/**'], }), ], });