Skip to content

Commit a58d7a5

Browse files
committed
Adding 2fa feature for Vue Starter
1 parent 55cc169 commit a58d7a5

31 files changed

+10350
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/storage/*.key
88
/storage/pail
99
/vendor
10+
.DS_Store
1011
.env
1112
.env.backup
1213
.env.production
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
use Illuminate\Support\Facades\Auth;
6+
use Illuminate\Support\Facades\Session;
7+
8+
class CompleteTwoFactorAuthentication
9+
{
10+
/**
11+
* Complete the two-factor authentication process.
12+
*
13+
* @param mixed $user The user to authenticate
14+
* @return void
15+
*/
16+
public function __invoke($user): void
17+
{
18+
// Get the remember preference from the session (default to false if not set)
19+
$remember = Session::get('login.remember', false);
20+
21+
// Log the user in with the remember preference
22+
Auth::login($user, $remember);
23+
24+
// Clear the session variables used for the 2FA challenge
25+
Session::forget(['login.id', 'login.remember']);
26+
}
27+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
use App\Models\User;
6+
7+
class DisableTwoFactorAuthentication
8+
{
9+
/**
10+
* Disable two factor authentication for the user.
11+
*
12+
* @return void
13+
*/
14+
public function __invoke($user)
15+
{
16+
if (! is_null($user->two_factor_secret) ||
17+
! is_null($user->two_factor_recovery_codes) ||
18+
! is_null($user->two_factor_confirmed_at)) {
19+
$user->forceFill([
20+
'two_factor_secret' => null,
21+
'two_factor_recovery_codes' => null,
22+
'two_factor_confirmed_at' => null,
23+
])->save();
24+
}
25+
}
26+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
use Illuminate\Support\Collection;
6+
use Illuminate\Support\Str;
7+
8+
class GenerateNewRecoveryCodes
9+
{
10+
/**
11+
* Generate new recovery codes for the user.
12+
*
13+
* @param mixed $user
14+
* @return void
15+
*/
16+
public function __invoke($user): Collection
17+
{
18+
return Collection::times(8, function () {
19+
return $this->generate();
20+
});
21+
}
22+
23+
public function generate()
24+
{
25+
return Str::random(10).'-'.Str::random(10);
26+
}
27+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
6+
use BaconQrCode\Renderer\ImageRenderer;
7+
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
8+
use BaconQrCode\Writer;
9+
use App\Models\User;
10+
use PragmaRX\Google2FA\Google2FA;
11+
12+
class GenerateQrCodeAndSecretKey
13+
{
14+
public string $companyName;
15+
16+
/**
17+
* Generate new recovery codes for the user.
18+
*
19+
* @return array{string, string}
20+
*/
21+
public function __invoke($user): array
22+
{
23+
// Create a new Google2FA instance with explicit configuration
24+
$google2fa = new Google2FA();
25+
$google2fa->setOneTimePasswordLength(6);
26+
27+
// Generate a standard 16-character secret key
28+
$secret_key = $google2fa->generateSecretKey(16);
29+
30+
// Set company name from config
31+
$this->companyName = config('app.name', 'Laravel');
32+
33+
// Generate the QR code URL
34+
$g2faUrl = $google2fa->getQRCodeUrl(
35+
$this->companyName,
36+
$user->email,
37+
$secret_key
38+
);
39+
40+
// Create the QR code image
41+
$writer = new Writer(
42+
new ImageRenderer(
43+
new RendererStyle(400),
44+
new SvgImageBackEnd()
45+
)
46+
);
47+
48+
// Generate the QR code as a base64 encoded SVG
49+
$qrcode_image = base64_encode($writer->writeString($g2faUrl));
50+
51+
return [$qrcode_image, $secret_key];
52+
53+
}
54+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
class ProcessRecoveryCode
6+
{
7+
/**
8+
* Verify a recovery code and remove it from the list if valid.
9+
*
10+
* @param array $recoveryCodes The array of recovery codes
11+
* @param string $submittedCode The code submitted by the user
12+
* @return array|false Returns the updated array of recovery codes if valid, or false if invalid
13+
*/
14+
public function __invoke(array $recoveryCodes, string $submittedCode)
15+
{
16+
// Clean the submitted code
17+
$submittedCode = trim($submittedCode);
18+
19+
// If the user has entered multiple codes, only validate the first one
20+
$submittedCode = explode(" ", $submittedCode)[0];
21+
22+
// Check if the code is valid
23+
if (!in_array($submittedCode, $recoveryCodes)) {
24+
return false;
25+
}
26+
27+
// Remove the used recovery code from the list
28+
$updatedCodes = array_values(array_filter($recoveryCodes, function($code) use ($submittedCode) {
29+
return !hash_equals($code, $submittedCode);
30+
}));
31+
32+
return $updatedCodes;
33+
}
34+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Actions\TwoFactorAuth;
4+
5+
use PragmaRX\Google2FA\Google2FA;
6+
7+
class VerifyTwoFactorCode
8+
{
9+
/**
10+
* Verify a two-factor authentication code.
11+
*
12+
* @param string $secret The decrypted secret key
13+
* @param string $code The code to verify
14+
* @return bool
15+
*/
16+
public function __invoke(string $secret, string $code): bool
17+
{
18+
// Clean the code (remove spaces and non-numeric characters)
19+
$code = preg_replace('/[^0-9]/', '', $code);
20+
21+
// Create a new Google2FA instance with explicit configuration
22+
$google2fa = new Google2FA();
23+
$google2fa->setWindow(8); // Allow for some time drift
24+
$google2fa->setOneTimePasswordLength(6); // Ensure 6-digit codes
25+
26+
try {
27+
return $google2fa->verify($code, $secret);
28+
} catch (\Exception $e) {
29+
return false;
30+
}
31+
}
32+
}

app/Http/Controllers/Auth/AuthenticatedSessionController.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Http\Requests\Auth\LoginRequest;
7+
use App\Models\User;
78
use Illuminate\Http\RedirectResponse;
89
use Illuminate\Http\Request;
910
use Illuminate\Support\Facades\Auth;
11+
use Illuminate\Support\Facades\Hash;
1012
use Illuminate\Support\Facades\Route;
1113
use Inertia\Inertia;
1214
use Inertia\Response;
@@ -29,6 +31,20 @@ public function create(Request $request): Response
2931
*/
3032
public function store(LoginRequest $request): RedirectResponse
3133
{
34+
$user = User::where('email', $request->email)->first();
35+
36+
// If this user exists, password is correct, and 2FA is enabled, we want to redirect to the 2FA challenge
37+
if ($user && $user->two_factor_confirmed_at && Hash::check($request->password, $user->password)) {
38+
// Store the user ID and remember preference in the session
39+
$request->session()->put([
40+
'login.id' => $user->getKey(),
41+
'login.remember' => $request->boolean('remember')
42+
]);
43+
44+
return redirect()->route('two-factor.challenge');
45+
}
46+
47+
// Otherwise, proceed with normal authentication
3248
$request->authenticate();
3349

3450
$request->session()->regenerate();
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Actions\TwoFactorAuth\CompleteTwoFactorAuthentication;
6+
use App\Actions\TwoFactorAuth\ProcessRecoveryCode;
7+
use App\Actions\TwoFactorAuth\VerifyTwoFactorCode;
8+
use App\Http\Controllers\Controller;
9+
use App\Models\User;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\RateLimiter;
12+
use Illuminate\Validation\ValidationException;
13+
use Illuminate\Support\Str;
14+
15+
class TwoFactorAuthChallengeController extends Controller
16+
{
17+
/**
18+
* Attempt to authenticate a new session using the two factor authentication code.
19+
*
20+
* @param \Illuminate\Http\Request $request
21+
* @return mixed
22+
*/
23+
public function store(Request $request)
24+
{
25+
$request->validate([
26+
'code' => 'nullable|string',
27+
'recovery_code' => 'nullable|string',
28+
]);
29+
30+
// If we made it here, user is available via the EnsureTwoFactorChallengeSession middleware
31+
$user = $request->two_factor_auth_user;
32+
33+
// Ensure the 2FA challenge is not rate limited
34+
$this->ensureIsNotRateLimited($user);
35+
36+
// Handle one-time password (OTP) code
37+
if ($request->filled('code')) {
38+
return $this->authenticateUsingCode($request, $user);
39+
}
40+
41+
// Handle recovery code
42+
if ($request->filled('recovery_code')) {
43+
return $this->authenticateUsingRecoveryCode($request, $user);
44+
}
45+
46+
return back()->withErrors(['code' => __('Please provide a valid two factor code.')]);
47+
}
48+
49+
/**
50+
* Authenticate using a one-time password (OTP).
51+
*
52+
* @param \Illuminate\Http\Request $request
53+
* @param \App\Models\User $user
54+
* @return \Illuminate\Http\Response
55+
*/
56+
protected function authenticateUsingCode(Request $request, User $user)
57+
{
58+
$secret = decrypt($user->two_factor_secret);
59+
$valid = app(VerifyTwoFactorCode::class)($secret, $request->code);
60+
61+
if ($valid) {
62+
app(CompleteTwoFactorAuthentication::class)($user);
63+
RateLimiter::clear($this->throttleKey($user));
64+
return redirect()->intended(route('dashboard', absolute: false));
65+
}
66+
67+
RateLimiter::hit($this->throttleKey($user));
68+
return back()->withErrors(['code' => __('The provided two factor authentication code was invalid.')]);
69+
}
70+
71+
/**
72+
* Authenticate using a recovery code.
73+
*
74+
* @param \Illuminate\Http\Request $request
75+
* @param \App\Models\User $user
76+
* @return \Illuminate\Http\Response
77+
*/
78+
protected function authenticateUsingRecoveryCode(Request $request, User $user)
79+
{
80+
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
81+
82+
// Process the recovery code - this handles validation and removing the used code
83+
$updatedCodes = app(ProcessRecoveryCode::class)($recoveryCodes, $request->recovery_code);
84+
85+
// If ProcessRecoveryCode returns false, the code was invalid
86+
if ($updatedCodes === false) {
87+
RateLimiter::hit($this->throttleKey($user));
88+
return back()->withErrors(['recovery_code' => __('The provided two factor authentication recovery code was invalid.')]);
89+
}
90+
91+
// Update the user's recovery codes, removing the used code
92+
$user->two_factor_recovery_codes = encrypt(json_encode($updatedCodes));
93+
$user->save();
94+
95+
// Complete the authentication process
96+
app(CompleteTwoFactorAuthentication::class)($user);
97+
98+
// Clear rate limiter after successful authentication
99+
RateLimiter::clear($this->throttleKey($user));
100+
101+
// Redirect to the intended page
102+
return redirect()->intended(route('dashboard', absolute: false));
103+
}
104+
105+
/**
106+
* Ensure the 2FA challenge is not rate limited.
107+
*
108+
* @param \App\Models\User $user
109+
* @return void
110+
*
111+
* @throws \Illuminate\Validation\ValidationException
112+
*/
113+
protected function ensureIsNotRateLimited(User $user): void
114+
{
115+
if (! RateLimiter::tooManyAttempts($this->throttleKey($user), 5)) {
116+
return;
117+
}
118+
119+
$seconds = RateLimiter::availableIn($this->throttleKey($user));
120+
121+
throw ValidationException::withMessages([
122+
'code' => __('Too many two factor authentication attempts. Please try again in :seconds seconds.', [
123+
'seconds' => $seconds,
124+
]),
125+
]);
126+
}
127+
128+
/**
129+
* Get the rate limiting throttle key for the given user.
130+
*
131+
* @param \App\Models\User $user
132+
* @return string
133+
*/
134+
protected function throttleKey(User $user): string
135+
{
136+
return Str::transliterate($user->id . '|2fa|' . request()->ip());
137+
}
138+
}
139+

0 commit comments

Comments
 (0)