Skip to content

Commit 246cf1d

Browse files
driesvintstaylorotwellStyleCIBotJubeki
authored
[1.x] Confirmable 2FA (#358)
* first pass at confirmable 2fa * working on tests * additional tests * Apply fixes from StyleCI * dont interact with column in feature not enabled * adjust migration * Apply fixes from StyleCI * adjust tests * [2FA] Change to use timestamp for confirming 2fa (#359) * Change to use timestamp for confirming 2fa * Fix nullable column * Fix nullable column in tests * Add unsaved changes * update check and test Co-authored-by: Taylor Otwell <[email protected]> Co-authored-by: StyleCI Bot <[email protected]> Co-authored-by: Julius Kiekbusch <[email protected]>
1 parent e6e7406 commit 246cf1d

11 files changed

+280
-3
lines changed

database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use Illuminate\Database\Migrations\Migration;
44
use Illuminate\Database\Schema\Blueprint;
55
use Illuminate\Support\Facades\Schema;
6+
use Laravel\Fortify\Fortify;
67

78
return new class extends Migration
89
{
@@ -21,6 +22,12 @@ public function up()
2122
$table->text('two_factor_recovery_codes')
2223
->after('two_factor_secret')
2324
->nullable();
25+
26+
if (Fortify::confirmsTwoFactorAuthentication()) {
27+
$table->timestamp('two_factor_confirmed_at')
28+
->after('two_factor_recovery_codes')
29+
->nullable();
30+
}
2431
});
2532
}
2633

@@ -32,7 +39,12 @@ public function up()
3239
public function down()
3340
{
3441
Schema::table('users', function (Blueprint $table) {
35-
$table->dropColumn('two_factor_secret', 'two_factor_recovery_codes');
42+
$table->dropColumn([
43+
'two_factor_secret',
44+
'two_factor_recovery_codes',
45+
] + Fortify::confirmsTwoFactorAuthentication() ? [
46+
'two_factor_confirmed_at',
47+
] : []);
3648
});
3749
}
3850
};

routes/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
66
use Laravel\Fortify\Http\Controllers\ConfirmablePasswordController;
77
use Laravel\Fortify\Http\Controllers\ConfirmedPasswordStatusController;
8+
use Laravel\Fortify\Http\Controllers\ConfirmedTwoFactorAuthenticationController;
89
use Laravel\Fortify\Http\Controllers\EmailVerificationNotificationController;
910
use Laravel\Fortify\Http\Controllers\EmailVerificationPromptController;
1011
use Laravel\Fortify\Http\Controllers\NewPasswordController;
@@ -141,6 +142,10 @@
141142
->middleware($twoFactorMiddleware)
142143
->name('two-factor.enable');
143144

145+
Route::post('/user/confirmed-two-factor-authentication', [ConfirmedTwoFactorAuthenticationController::class, 'store'])
146+
->middleware($twoFactorMiddleware)
147+
->name('two-factor.confirm');
148+
144149
Route::delete('/user/two-factor-authentication', [TwoFactorAuthenticationController::class, 'destroy'])
145150
->middleware($twoFactorMiddleware)
146151
->name('two-factor.disable');
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Laravel\Fortify\Actions;
4+
5+
use Illuminate\Validation\ValidationException;
6+
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
7+
use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed;
8+
9+
class ConfirmTwoFactorAuthentication
10+
{
11+
/**
12+
* The two factor authentication provider.
13+
*
14+
* @var \Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider
15+
*/
16+
protected $provider;
17+
18+
/**
19+
* Create a new action instance.
20+
*
21+
* @param \Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider $provider
22+
* @return void
23+
*/
24+
public function __construct(TwoFactorAuthenticationProvider $provider)
25+
{
26+
$this->provider = $provider;
27+
}
28+
29+
/**
30+
* Confirm the two factor authentication configuration for the user.
31+
*
32+
* @param mixed $user
33+
* @param string $code
34+
* @return void
35+
*/
36+
public function __invoke($user, $code)
37+
{
38+
if (empty($user->two_factor_secret) ||
39+
! $this->provider->verify(decrypt($user->two_factor_secret), $code)) {
40+
throw ValidationException::withMessages([
41+
'code' => [__('The provided two factor authentication code was invalid.')],
42+
])->errorBag('confirmTwoFactorAuthentication');
43+
}
44+
45+
$user->forceFill([
46+
'two_factor_confirmed_at' => now(),
47+
])->save();
48+
49+
TwoFactorAuthenticationConfirmed::dispatch($user);
50+
}
51+
}

src/Actions/DisableTwoFactorAuthentication.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Laravel\Fortify\Actions;
44

55
use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled;
6+
use Laravel\Fortify\Fortify;
67

78
class DisableTwoFactorAuthentication
89
{
@@ -17,7 +18,9 @@ public function __invoke($user)
1718
$user->forceFill([
1819
'two_factor_secret' => null,
1920
'two_factor_recovery_codes' => null,
20-
])->save();
21+
] + (Fortify::confirmsTwoFactorAuthentication() ? [
22+
'two_factor_confirmed_at' => null,
23+
] : []))->save();
2124

2225
TwoFactorAuthenticationDisabled::dispatch($user);
2326
}

src/Actions/RedirectIfTwoFactorAuthenticatable.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ public function handle($request, $next)
5050
{
5151
$user = $this->validateCredentials($request);
5252

53+
if (Fortify::confirmsTwoFactorAuthentication()) {
54+
if (optional($user)->two_factor_secret &&
55+
! is_null(optional($user)->two_factor_confirmed_at) &&
56+
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
57+
return $this->twoFactorChallengeResponse($request, $user);
58+
} else {
59+
return $next($request);
60+
}
61+
}
62+
5363
if (optional($user)->two_factor_secret &&
5464
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
5565
return $this->twoFactorChallengeResponse($request, $user);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Laravel\Fortify\Events;
4+
5+
class TwoFactorAuthenticationConfirmed extends TwoFactorAuthenticationEvent
6+
{
7+
//
8+
}

src/Fortify.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,17 @@ public static function resetUserPasswordsUsing(string $callback)
283283
app()->singleton(ResetsUserPasswords::class, $callback);
284284
}
285285

286+
/**
287+
* Determine if Fortify is confirming two factor authentication configurations.
288+
*
289+
* @return bool
290+
*/
291+
public static function confirmsTwoFactorAuthentication()
292+
{
293+
return Features::enabled(Features::twoFactorAuthentication()) &&
294+
Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
295+
}
296+
286297
/**
287298
* Configure Fortify to not register its routes.
288299
*
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Laravel\Fortify\Http\Controllers;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Routing\Controller;
8+
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
9+
10+
class ConfirmedTwoFactorAuthenticationController extends Controller
11+
{
12+
/**
13+
* Enable two factor authentication for the user.
14+
*
15+
* @param \Illuminate\Http\Request $request
16+
* @param \Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication $confirm
17+
* @return \Symfony\Component\HttpFoundation\Response
18+
*/
19+
public function store(Request $request, ConfirmTwoFactorAuthentication $confirm)
20+
{
21+
$confirm($request->user(), $request->input('code'));
22+
23+
return $request->wantsJson()
24+
? new JsonResponse('', 200)
25+
: back()->with('status', 'two-factor-authentication-confirmed');
26+
}
27+
}

src/TwoFactorAuthenticatable.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ trait TwoFactorAuthenticatable
1919
*/
2020
public function hasEnabledTwoFactorAuthentication()
2121
{
22+
if (Fortify::confirmsTwoFactorAuthentication()) {
23+
return ! is_null($this->two_factor_secret) &&
24+
! is_null($this->two_factor_confirmed_at);
25+
}
26+
2227
return ! is_null($this->two_factor_secret);
2328
}
2429

tests/AuthenticatedSessionControllerTest.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,70 @@ public function test_user_is_redirected_to_challenge_when_using_two_factor_authe
7979
Event::assertDispatched(TwoFactorAuthenticationChallenged::class);
8080
}
8181

82+
public function test_user_is_not_redirected_to_challenge_when_using_two_factor_authentication_that_has_not_been_confirmed_and_confirmation_is_enabled()
83+
{
84+
Event::fake();
85+
86+
app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class);
87+
app('config')->set('fortify.features', [
88+
Features::registration(),
89+
Features::twoFactorAuthentication(['confirm' => true]),
90+
]);
91+
92+
$this->loadLaravelMigrations(['--database' => 'testbench']);
93+
94+
Schema::table('users', function ($table) {
95+
$table->text('two_factor_secret')->nullable();
96+
});
97+
98+
TestTwoFactorAuthenticationSessionUser::forceCreate([
99+
'name' => 'Taylor Otwell',
100+
'email' => '[email protected]',
101+
'password' => bcrypt('secret'),
102+
'two_factor_secret' => 'test-secret',
103+
]);
104+
105+
$response = $this->withoutExceptionHandling()->post('/login', [
106+
'email' => '[email protected]',
107+
'password' => 'secret',
108+
]);
109+
110+
$response->assertRedirect('/home');
111+
}
112+
113+
public function test_user_is_redirected_to_challenge_when_using_two_factor_authentication_that_has_been_confirmed_and_confirmation_is_enabled()
114+
{
115+
Event::fake();
116+
117+
app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class);
118+
app('config')->set('fortify.features', [
119+
Features::registration(),
120+
Features::twoFactorAuthentication(['confirm' => true]),
121+
]);
122+
123+
$this->loadLaravelMigrations(['--database' => 'testbench']);
124+
125+
Schema::table('users', function ($table) {
126+
$table->text('two_factor_secret')->nullable();
127+
$table->timestamp('two_factor_confirmed_at')->nullable();
128+
});
129+
130+
TestTwoFactorAuthenticationSessionUser::forceCreate([
131+
'name' => 'Taylor Otwell',
132+
'email' => '[email protected]',
133+
'password' => bcrypt('secret'),
134+
'two_factor_secret' => 'test-secret',
135+
'two_factor_confirmed_at' => now(),
136+
]);
137+
138+
$response = $this->withoutExceptionHandling()->post('/login', [
139+
'email' => '[email protected]',
140+
'password' => 'secret',
141+
]);
142+
143+
$response->assertRedirect('/two-factor-challenge');
144+
}
145+
82146
public function test_user_can_authenticate_when_two_factor_challenge_is_disabled()
83147
{
84148
app('config')->set('auth.providers.users.model', TestTwoFactorAuthenticationSessionUser::class);

tests/TwoFactorAuthenticationControllerTest.php

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
use Illuminate\Foundation\Auth\User;
66
use Illuminate\Support\Facades\Event;
7+
use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed;
78
use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled;
89
use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled;
10+
use Laravel\Fortify\Features;
911
use Laravel\Fortify\FortifyServiceProvider;
1012
use Laravel\Fortify\TwoFactorAuthenticatable;
13+
use PragmaRX\Google2FA\Google2FA;
1114

1215
class TwoFactorAuthenticationControllerTest extends OrchestraTestCase
1316
{
@@ -32,14 +35,92 @@ public function test_two_factor_authentication_can_be_enabled()
3235

3336
Event::assertDispatched(TwoFactorAuthenticationEnabled::class);
3437

35-
$user->fresh();
38+
$user = $user->fresh();
3639

3740
$this->assertNotNull($user->two_factor_secret);
3841
$this->assertNotNull($user->two_factor_recovery_codes);
42+
$this->assertNull($user->two_factor_confirmed_at);
3943
$this->assertIsArray(json_decode(decrypt($user->two_factor_recovery_codes), true));
4044
$this->assertNotNull($user->twoFactorQrCodeSvg());
4145
}
4246

47+
public function test_two_factor_authentication_can_be_confirmed()
48+
{
49+
Event::fake();
50+
51+
app('config')->set('fortify.features', [
52+
Features::twoFactorAuthentication(['confirm' => true]),
53+
]);
54+
55+
$this->loadLaravelMigrations(['--database' => 'testbench']);
56+
$this->artisan('migrate', ['--database' => 'testbench'])->run();
57+
58+
$tfaEngine = app(Google2FA::class);
59+
$userSecret = $tfaEngine->generateSecretKey();
60+
$validOtp = $tfaEngine->getCurrentOtp($userSecret);
61+
62+
$user = TestTwoFactorAuthenticationUser::forceCreate([
63+
'name' => 'Taylor Otwell',
64+
'email' => '[email protected]',
65+
'password' => bcrypt('secret'),
66+
'two_factor_secret' => encrypt($userSecret),
67+
'two_factor_confirmed_at' => null,
68+
]);
69+
70+
$response = $this->withoutExceptionHandling()->actingAs($user)->postJson(
71+
'/user/confirmed-two-factor-authentication', ['code' => $validOtp],
72+
);
73+
74+
$response->assertStatus(200);
75+
76+
Event::assertDispatched(TwoFactorAuthenticationConfirmed::class);
77+
78+
$user = $user->fresh();
79+
80+
$this->assertNotNull($user->two_factor_confirmed_at);
81+
$this->assertTrue($user->hasEnabledTwoFactorAuthentication());
82+
83+
// Ensure two factor authentication not considered enabled if not confirmed...
84+
$user->forceFill(['two_factor_confirmed_at' => null])->save();
85+
86+
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
87+
}
88+
89+
public function test_two_factor_authentication_can_not_be_confirmed_with_invalid_code()
90+
{
91+
Event::fake();
92+
93+
app('config')->set('fortify.features', [
94+
Features::twoFactorAuthentication(['confirm' => true]),
95+
]);
96+
97+
$this->loadLaravelMigrations(['--database' => 'testbench']);
98+
$this->artisan('migrate', ['--database' => 'testbench'])->run();
99+
100+
$tfaEngine = app(Google2FA::class);
101+
$userSecret = $tfaEngine->generateSecretKey();
102+
103+
$user = TestTwoFactorAuthenticationUser::forceCreate([
104+
'name' => 'Taylor Otwell',
105+
'email' => '[email protected]',
106+
'password' => bcrypt('secret'),
107+
'two_factor_secret' => encrypt($userSecret),
108+
'two_factor_confirmed_at' => null,
109+
]);
110+
111+
$response = $this->withExceptionHandling()->actingAs($user)->postJson(
112+
'/user/confirmed-two-factor-authentication', ['code' => 'invalid-otp'],
113+
);
114+
115+
$response->assertStatus(422);
116+
117+
Event::assertNotDispatched(TwoFactorAuthenticationConfirmed::class);
118+
119+
$user = $user->fresh();
120+
121+
$this->assertNull($user->two_factor_confirmed_at);
122+
}
123+
43124
public function test_two_factor_authentication_can_be_disabled()
44125
{
45126
Event::fake();

0 commit comments

Comments
 (0)