Skip to content

Adding Two Factor Auth Feature #101

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 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ef8a672
Adding 2fa initial commit
tnylea Apr 18, 2025
347eda0
fixing the 2fa codes from regenerating before submitted
tnylea Apr 18, 2025
cefb3df
Adding updates to 2fa
tnylea Apr 18, 2025
23fd86c
fixing auth and 2fa func
tnylea Apr 18, 2025
cb61623
Getting tests to pass
tnylea Apr 18, 2025
3273e45
Adding a bit of refactoring
tnylea Apr 18, 2025
8155592
updating button variant
tnylea Apr 18, 2025
5ef472e
Making a few more clean-ups
tnylea Apr 18, 2025
82407a9
cleanup
tnylea Apr 21, 2025
44e2088
Adding some more cleanup and refactor
tnylea Apr 21, 2025
2fe16e3
removing unneccessary file
tnylea Apr 21, 2025
e5c8b58
renaming and re-organizing
tnylea Apr 21, 2025
adb4c73
adding 2fa challenge controller
tnylea Apr 21, 2025
f061b9f
Adding a bit more refactor
tnylea Apr 21, 2025
41c2262
Adding a bit more refactor
tnylea Apr 21, 2025
0caff76
Cleaning up a bit more
tnylea Apr 21, 2025
d6ae006
Adding more refactor
tnylea Apr 21, 2025
198b280
Adding more refactor
tnylea Apr 21, 2025
4508698
before adding to a hook
tnylea Apr 21, 2025
e2bae26
moving the use-two-factor-auth to a hook so it can be re-used
tnylea Apr 21, 2025
14df3c9
removing component that is not being used
tnylea Apr 21, 2025
bda39b3
making a few more updates
tnylea Apr 22, 2025
31cc0d9
cleaning up the 2fa controller
tnylea Apr 22, 2025
98dc512
restructuring
tnylea Apr 22, 2025
7ccd4ca
removing unneccessary usecallback
tnylea Apr 22, 2025
c1a8184
removing unneccessary package and updating test
tnylea Apr 22, 2025
dea801e
Adding security fixes, rate limiting and more
tnylea Apr 22, 2025
7a0da38
A bit more cleanup
tnylea Apr 22, 2025
e605988
Adding updates to the 2fa hook
tnylea Apr 22, 2025
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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Generate new recovery codes for the user.
* 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;
}
}
}
17 changes: 16 additions & 1 deletion 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,8 +31,21 @@ public function create(Request $request): Response
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$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();

return redirect()->intended(route('dashboard', absolute: false));
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