Skip to content

Commit

Permalink
[2.x] Confirm 2FA when enabling (#992)
Browse files Browse the repository at this point in the history
* Confirm 2FA in Livewire

* Bump fortify

* formatting

* inertia support

* user profile tests

* refactoring

* fix spacing

Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
driesvints and taylorotwell committed Mar 16, 2022
1 parent 709cffa commit de8020c
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 18 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"ext-json": "*",
"illuminate/support": "^8.37|^9.0",
"jenssegers/agent": "^2.6",
"laravel/fortify": "^1.9"
"laravel/fortify": "^1.11.1"
},
"require-dev": {
"inertiajs/inertia-laravel": "^0.5.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Laravel\Jetstream\Http\Controllers\Inertia\Concerns;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;

trait ConfirmsTwoFactorAuthentication
{
/**
* Validate the two factor authentication state for the request.
*
* @param \Illuminate\Http\Request
* @return void
*/
protected function validateTwoFactorAuthenticationState(Request $request)
{
if (! Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
return;
}

$currentTime = time();

// Notate totally disabled state in session...
if ($this->twoFactorAuthenticationDisabled($request)) {
$request->session()->put('two_factor_empty_at', $currentTime);
}

// If was previously totally disabled this session but is now confirming, notate time...
if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) {
$request->session()->put('two_factor_confirming_at', $currentTime);
}

// If the profile is reloaded and is not confirmed but was previously in confirming state, disable...
if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) {
app(DisableTwoFactorAuthentication::class)(Auth::user());

$request->session()->put('two_factor_empty_at', $currentTime);
$request->session()->remove('two_factor_confirming_at');
}
}

/**
* Determine if two factor authenticatoin is totally disabled.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function twoFactorAuthenticationDisabled(Request $request)
{
return is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at);
}

/**
* Determine if two factor authentication is just now being confirmed within the last request cycle.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request)
{
return ! is_null($request->user()->two_factor_secret) &&
is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->has('two_factor_empty_at') &&
is_null($request->session()->get('two_factor_confirming_at'));
}

/**
* Determine if two factor authentication was never totally confirmed once confirmation started.
*
* @param \Illuminate\Http\Request $request
* @param int $currentTime
* @return bool
*/
protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, $currentTime)
{
return is_null($request->user()->two_factor_confirmed_at) &&
$request->session()->get('two_factor_confirming_at', 0) != $currentTime;
}
}
8 changes: 8 additions & 0 deletions src/Http/Controllers/Inertia/UserProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Jenssegers\Agent\Agent;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;

class UserProfileController extends Controller
{
use Concerns\ConfirmsTwoFactorAuthentication;

/**
* Show the general profile settings screen.
*
Expand All @@ -19,7 +24,10 @@ class UserProfileController extends Controller
*/
public function show(Request $request)
{
$this->validateTwoFactorAuthenticationState($request);

return Jetstream::inertia()->render($request, 'Profile/Show', [
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request)->all(),
]);
}
Expand Down
56 changes: 56 additions & 0 deletions src/Http/Livewire/TwoFactorAuthenticationForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Jetstream\Http\Livewire;

use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
Expand All @@ -21,13 +22,40 @@ class TwoFactorAuthenticationForm extends Component
*/
public $showingQrCode = false;

/**
* Indicates if the two factor authentication confirmation input and button are being displayed.
*
* @var bool
*/
public $showingConfirmation = false;

/**
* Indicates if two factor authentication recovery codes are being displayed.
*
* @var bool
*/
public $showingRecoveryCodes = false;

/**
* The OTP code for confirming two factor authentication.
*
* @var string|null
*/
public $code;

/**
* Mount the component.
*
* @return void
*/
public function mount()
{
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm') &&
is_null(Auth::user()->two_factor_confirmed_at)) {
app(DisableTwoFactorAuthentication::class)(Auth::user());
}
}

/**
* Enable two factor authentication for the user.
*
Expand All @@ -43,6 +71,30 @@ public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $ena
$enable(Auth::user());

$this->showingQrCode = true;

if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
$this->showingConfirmation = true;
} else {
$this->showingRecoveryCodes = true;
}
}

/**
* Confirm two factor authentication for the user.
*
* @param \Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication $confirm
* @return void
*/
public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm)
{
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}

$confirm(Auth::user(), $this->code);

$this->showingQrCode = false;
$this->showingConfirmation = false;
$this->showingRecoveryCodes = true;
}

Expand Down Expand Up @@ -90,6 +142,10 @@ public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $d
}

$disable(Auth::user());

$this->showingQrCode = false;
$this->showingConfirmation = false;
$this->showingRecoveryCodes = false;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
</template>

<template #content>
<h3 class="text-lg font-medium text-gray-900" v-if="twoFactorEnabled">
<h3 class="text-lg font-medium text-gray-900" v-if="twoFactorEnabled && ! confirming">
You have enabled two factor authentication.
</h3>

<h3 class="text-lg font-medium text-gray-900" v-else-if="confirming">
Finish enabling two factor authentication.
</h3>

<h3 class="text-lg font-medium text-gray-900" v-else>
You have not enabled two factor authentication.
</h3>
Expand All @@ -26,16 +30,34 @@
<div v-if="twoFactorEnabled">
<div v-if="qrCode">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
<p class="font-semibold" v-if="confirming">
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application and provide the generated OTP code.
</p>

<p v-else>
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application.
</p>
</div>

<div class="mt-4" v-html="qrCode">
</div>

<div class="mt-4" v-if="confirming">
<jet-label for="code" value="Code" />

<jet-input id="code" type="text" name="code"
class="block mt-1 w-1/2"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
v-model="confirmationForm.code"
@keyup.enter="confirmTwoFactorAuthentication" />

<jet-input-error :message="confirmationForm.errors.code" class="mt-2" />
</div>
</div>

<div v-if="recoveryCodes.length > 0">
<div v-if="recoveryCodes.length > 0 && ! confirming">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
Expand All @@ -60,23 +82,39 @@
</div>

<div v-else>
<jet-confirms-password @confirmed="confirmTwoFactorAuthentication">
<jet-button type="button" class="mr-3" :class="{ 'opacity-25': enabling }" :disabled="enabling" v-if="confirming">
Confirm
</jet-button>
</jet-confirms-password>

<jet-confirms-password @confirmed="regenerateRecoveryCodes">
<jet-secondary-button class="mr-3"
v-if="recoveryCodes.length > 0">
v-if="recoveryCodes.length > 0 && ! confirming">
Regenerate Recovery Codes
</jet-secondary-button>
</jet-confirms-password>

<jet-confirms-password @confirmed="showRecoveryCodes">
<jet-secondary-button class="mr-3" v-if="recoveryCodes.length === 0">
<jet-secondary-button class="mr-3" v-if="recoveryCodes.length === 0 && ! confirming">
Show Recovery Codes
</jet-secondary-button>
</jet-confirms-password>

<jet-confirms-password @confirmed="disableTwoFactorAuthentication">
<jet-secondary-button
:class="{ 'opacity-25': disabling }"
:disabled="disabling"
v-if="confirming">
Cancel
</jet-secondary-button>
</jet-confirms-password>

<jet-confirms-password @confirmed="disableTwoFactorAuthentication">
<jet-danger-button
:class="{ 'opacity-25': disabling }"
:disabled="disabling">
:disabled="disabling"
v-if="! confirming">
Disable
</jet-danger-button>
</jet-confirms-password>
Expand All @@ -92,6 +130,9 @@
import JetButton from '@/Jetstream/Button.vue'
import JetConfirmsPassword from '@/Jetstream/ConfirmsPassword.vue'
import JetDangerButton from '@/Jetstream/DangerButton.vue'
import JetInput from '@/Jetstream/Input.vue'
import JetInputError from '@/Jetstream/InputError.vue'
import JetLabel from '@/Jetstream/Label.vue'
import JetSecondaryButton from '@/Jetstream/SecondaryButton.vue'
export default defineComponent({
Expand All @@ -100,16 +141,26 @@
JetButton,
JetConfirmsPassword,
JetDangerButton,
JetInput,
JetInputError,
JetLabel,
JetSecondaryButton,
},
props: ['requiresConfirmation'],
data() {
return {
enabling: false,
confirming: false,
disabling: false,
qrCode: null,
recoveryCodes: [],
confirmationForm: this.$inertia.form({
code: '',
}),
}
},
Expand All @@ -123,7 +174,10 @@
this.showQrCode(),
this.showRecoveryCodes(),
]),
onFinish: () => (this.enabling = false),
onFinish: () => {
this.enabling = false
this.confirming = this.requiresConfirmation
}
})
},
Expand All @@ -141,6 +195,17 @@
})
},
confirmTwoFactorAuthentication() {
this.confirmationForm.post('/user/confirmed-two-factor-authentication', {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
this.confirming = false
this.qrCode = null
}
})
},
regenerateRecoveryCodes() {
axios.post('/user/two-factor-recovery-codes')
.then(response => {
Expand All @@ -153,7 +218,10 @@
this.$inertia.delete('/user/two-factor-authentication', {
preserveScroll: true,
onSuccess: () => (this.disabling = false),
onSuccess: () => {
this.disabling = false
this.confirming = false
}
})
},
},
Expand Down
Loading

0 comments on commit de8020c

Please sign in to comment.