Skip to content

Adding 2fa feature for Vue Starter #120

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 a QR code image and secret key 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