Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/storage/*.key
/storage/pail
/vendor
.DS_Store
.env
.env.backup
.env.production
Expand Down
27 changes: 27 additions & 0 deletions app/Actions/TwoFactorAuth/CompleteTwoFactorAuthentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Actions\TwoFactorAuth;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;

class CompleteTwoFactorAuthentication
{
/**
* Complete the two-factor authentication process.
*
* @param mixed $user The user to authenticate
* @return void
*/
public function __invoke($user): void
{
// Get the remember preference from the session (default to false if not set)
$remember = Session::get('login.remember', false);

// Log the user in with the remember preference
Auth::login($user, $remember);

// Clear the session variables used for the 2FA challenge
Session::forget(['login.id', 'login.remember']);
}
}
26 changes: 26 additions & 0 deletions app/Actions/TwoFactorAuth/DisableTwoFactorAuthentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Actions\TwoFactorAuth;

use App\Models\User;

class DisableTwoFactorAuthentication
{
/**
* Disable two factor authentication for the user.
*
* @return void
*/
public function __invoke($user)
{
if (! is_null($user->two_factor_secret) ||
! is_null($user->two_factor_recovery_codes) ||
! is_null($user->two_factor_confirmed_at)) {
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
}
}
}
27 changes: 27 additions & 0 deletions app/Actions/TwoFactorAuth/GenerateNewRecoveryCodes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Actions\TwoFactorAuth;

use Illuminate\Support\Collection;
use Illuminate\Support\Str;

class GenerateNewRecoveryCodes
{
/**
* Generate new recovery codes for the user.
*
* @param mixed $user
* @return void
*/
public function __invoke($user): Collection
{
return Collection::times(8, function () {
return $this->generate();
});
}

public function generate()
{
return Str::random(10).'-'.Str::random(10);
}
}
54 changes: 54 additions & 0 deletions app/Actions/TwoFactorAuth/GenerateQrCodeAndSecretKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Actions\TwoFactorAuth;

use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use App\Models\User;
use PragmaRX\Google2FA\Google2FA;

class GenerateQrCodeAndSecretKey
{
public string $companyName;

/**
* Generate new recovery codes for the user.
*
* @return array{string, string}
*/
public function __invoke($user): array
{
// Create a new Google2FA instance with explicit configuration
$google2fa = new Google2FA();
$google2fa->setOneTimePasswordLength(6);

// Generate a standard 16-character secret key
$secret_key = $google2fa->generateSecretKey(16);

// Set company name from config
$this->companyName = config('app.name', 'Laravel');

// Generate the QR code URL
$g2faUrl = $google2fa->getQRCodeUrl(
$this->companyName,
$user->email,
$secret_key
);

// Create the QR code image
$writer = new Writer(
new ImageRenderer(
new RendererStyle(400),
new SvgImageBackEnd()
)
);

// Generate the QR code as a base64 encoded SVG
$qrcode_image = base64_encode($writer->writeString($g2faUrl));

return [$qrcode_image, $secret_key];

}
}
34 changes: 34 additions & 0 deletions app/Actions/TwoFactorAuth/ProcessRecoveryCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Actions\TwoFactorAuth;

class ProcessRecoveryCode
{
/**
* Verify a recovery code and remove it from the list if valid.
*
* @param array $recoveryCodes The array of recovery codes
* @param string $submittedCode The code submitted by the user
* @return array|false Returns the updated array of recovery codes if valid, or false if invalid
*/
public function __invoke(array $recoveryCodes, string $submittedCode)
{
// Clean the submitted code
$submittedCode = trim($submittedCode);

// If the user has entered multiple codes, only validate the first one
$submittedCode = explode(" ", $submittedCode)[0];

// Check if the code is valid
if (!in_array($submittedCode, $recoveryCodes)) {
return false;
}

// Remove the used recovery code from the list
$updatedCodes = array_values(array_filter($recoveryCodes, function($code) use ($submittedCode) {
return !hash_equals($code, $submittedCode);
}));

return $updatedCodes;
}
}
32 changes: 32 additions & 0 deletions app/Actions/TwoFactorAuth/VerifyTwoFactorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace App\Actions\TwoFactorAuth;

use PragmaRX\Google2FA\Google2FA;

class VerifyTwoFactorCode
{
/**
* Verify a two-factor authentication code.
*
* @param string $secret The decrypted secret key
* @param string $code The code to verify
* @return bool
*/
public function __invoke(string $secret, string $code): bool
{
// Clean the code (remove spaces and non-numeric characters)
$code = preg_replace('/[^0-9]/', '', $code);

// Create a new Google2FA instance with explicit configuration
$google2fa = new Google2FA();
$google2fa->setWindow(8); // Allow for some time drift
$google2fa->setOneTimePasswordLength(6); // Ensure 6-digit codes

try {
return $google2fa->verify($code, $secret);
} catch (\Exception $e) {
return false;
}
}
}
16 changes: 16 additions & 0 deletions app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
Expand All @@ -29,6 +31,20 @@ public function create(Request $request): Response
*/
public function store(LoginRequest $request): RedirectResponse
{
$user = User::where('email', $request->email)->first();

// If this user exists, password is correct, and 2FA is enabled, we want to redirect to the 2FA challenge
if ($user && $user->two_factor_confirmed_at && Hash::check($request->password, $user->password)) {
// Store the user ID and remember preference in the session
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => $request->boolean('remember')
]);

return redirect()->route('two-factor.challenge');
}

// Otherwise, proceed with normal authentication
$request->authenticate();

$request->session()->regenerate();
Expand Down
139 changes: 139 additions & 0 deletions app/Http/Controllers/Auth/TwoFactorAuthChallengeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Actions\TwoFactorAuth\CompleteTwoFactorAuthentication;
use App\Actions\TwoFactorAuth\ProcessRecoveryCode;
use App\Actions\TwoFactorAuth\VerifyTwoFactorCode;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;

class TwoFactorAuthChallengeController extends Controller
{
/**
* Attempt to authenticate a new session using the two factor authentication code.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function store(Request $request)
{
$request->validate([
'code' => 'nullable|string',
'recovery_code' => 'nullable|string',
]);

// If we made it here, user is available via the EnsureTwoFactorChallengeSession middleware
$user = $request->two_factor_auth_user;

// Ensure the 2FA challenge is not rate limited
$this->ensureIsNotRateLimited($user);

// Handle one-time password (OTP) code
if ($request->filled('code')) {
return $this->authenticateUsingCode($request, $user);
}

// Handle recovery code
if ($request->filled('recovery_code')) {
return $this->authenticateUsingRecoveryCode($request, $user);
}

return back()->withErrors(['code' => __('Please provide a valid two factor code.')]);
}

/**
* Authenticate using a one-time password (OTP).
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\User $user
* @return \Illuminate\Http\Response
*/
protected function authenticateUsingCode(Request $request, User $user)
{
$secret = decrypt($user->two_factor_secret);
$valid = app(VerifyTwoFactorCode::class)($secret, $request->code);

if ($valid) {
app(CompleteTwoFactorAuthentication::class)($user);
RateLimiter::clear($this->throttleKey($user));
return redirect()->intended(route('dashboard', absolute: false));
}

RateLimiter::hit($this->throttleKey($user));
return back()->withErrors(['code' => __('The provided two factor authentication code was invalid.')]);
}

/**
* Authenticate using a recovery code.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\User $user
* @return \Illuminate\Http\Response
*/
protected function authenticateUsingRecoveryCode(Request $request, User $user)
{
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);

// Process the recovery code - this handles validation and removing the used code
$updatedCodes = app(ProcessRecoveryCode::class)($recoveryCodes, $request->recovery_code);

// If ProcessRecoveryCode returns false, the code was invalid
if ($updatedCodes === false) {
RateLimiter::hit($this->throttleKey($user));
return back()->withErrors(['recovery_code' => __('The provided two factor authentication recovery code was invalid.')]);
}

// Update the user's recovery codes, removing the used code
$user->two_factor_recovery_codes = encrypt(json_encode($updatedCodes));
$user->save();

// Complete the authentication process
app(CompleteTwoFactorAuthentication::class)($user);

// Clear rate limiter after successful authentication
RateLimiter::clear($this->throttleKey($user));

// Redirect to the intended page
return redirect()->intended(route('dashboard', absolute: false));
}

/**
* Ensure the 2FA challenge is not rate limited.
*
* @param \App\Models\User $user
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function ensureIsNotRateLimited(User $user): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($user), 5)) {
return;
}

$seconds = RateLimiter::availableIn($this->throttleKey($user));

throw ValidationException::withMessages([
'code' => __('Too many two factor authentication attempts. Please try again in :seconds seconds.', [
'seconds' => $seconds,
]),
]);
}

/**
* Get the rate limiting throttle key for the given user.
*
* @param \App\Models\User $user
* @return string
*/
protected function throttleKey(User $user): string
{
return Str::transliterate($user->id . '|2fa|' . request()->ip());
}
}

Loading