requireConfirmedPassword(); if (! $operator = $this->currentOperator()) { return; } app(EnableTwoFactorAuthentication::class)($operator); } /** * Step back out of a half-finished setup. * * Without this the only way out of the QR step was to navigate away, which * left an unconfirmed secret sitting on the account — invisible, because * nothing shows a pending enrolment anywhere else. Clearing it costs * nothing: it was never confirmed, so it never protected anything. */ public function cancelSetup(): void { $this->requireConfirmedPassword(); if (! $operator = $this->currentOperator()) { return; } app(DisableTwoFactorAuthentication::class)($operator); $this->twoFactorCode = ''; $this->resetErrorBag('twoFactorCode'); $this->dispatch('notify', message: __('two_factor_setup.cancelled')); } /** Accept a code from the app, which is what actually turns it on. */ public function confirmTwoFactor(): void { $this->requireConfirmedPassword(); if (! $operator = $this->currentOperator()) { return; } try { app(ConfirmTwoFactorAuthentication::class)( $operator, $this->twoFactorCode, ); } catch (ValidationException $e) { $this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('two_factor_setup.code_wrong')); return; } $this->twoFactorCode = ''; // Shown once, here, because this is the only moment they exist in a // form anyone will read. They stay retrievable afterwards, but nobody // writes down what they were not shown. $this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true); $this->dispatch('notify', message: __('two_factor_setup.on')); } public function regenerateRecoveryCodes(): void { $this->requireConfirmedPassword(); if (! $operator = $this->currentOperator()) { return; } app(GenerateNewRecoveryCodes::class)($operator); $this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true); } public function disableTwoFactor(): void { $this->requireConfirmedPassword(); if (! $operator = $this->currentOperator()) { return; } app(DisableTwoFactorAuthentication::class)($operator); $this->recoveryCodes = null; $this->dispatch('notify', message: __('two_factor_setup.off')); } /** * The disable button opens ConfirmDisableTwoFactor instead of calling * disableTwoFactor() directly (R23); its confirm button dispatches back * here, and disableTwoFactor() still runs its own password check. */ #[On('two-factor-disable-confirmed')] public function onDisableConfirmed(): void { $this->disableTwoFactor(); } /** * Every two-factor action goes through this. * * Server-side, on each call, and not merely by hiding the buttons: a * Livewire action is reachable by anyone who can post to /livewire/update, * and "the form was not on screen" has never stopped anybody. */ private function requireConfirmedPassword(): void { abort_unless($this->passwordRecentlyConfirmed(), 403); } public function render() { // Unlike the actions above, render() has no requireConfirmedPassword() // gate in front of it — Livewire calls it on every update, including a // bare property sync, so it is its own reachable path for a session // that ended after the page loaded. if (! $operator = $this->currentOperator()) { return view('livewire.admin.two-factor-setup', [ 'twoFactorPending' => false, 'twoFactorOn' => false, 'twoFactorQr' => null, 'passwordConfirmed' => false, ]); } return view('livewire.admin.two-factor-setup', [ // Never the secret itself — only whether it exists, and the SVG // Fortify renders from it. The secret in a Livewire property would // travel to the browser and back in the component snapshot. 'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null, 'twoFactorOn' => $operator->two_factor_confirmed_at !== null, 'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null ? $operator->twoFactorQrCodeSvg() : null, 'passwordConfirmed' => $this->passwordRecentlyConfirmed(), ]); } }