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 8 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
24 changes: 24 additions & 0 deletions app/Actions/TwoFactorAuth/CompleteTwoFactorAuthentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?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
{
// Log the user in
Auth::login($user);

// Clear the session that is used to determine if the user can visit the 2fa challenge page
Session::forget('login.id');
}
}
21 changes: 21 additions & 0 deletions app/Actions/TwoFactorAuth/ConfirmTwoFactorAuthentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Actions\TwoFactorAuth;

class ConfirmTwoFactorAuthentication
{
/**
* Confirm two factor authentication for the user.
*
* @param mixed $user
* @return bool
*/
public function __invoke($user)
{
$user->forceFill([
'two_factor_confirmed_at' => now(),
])->save();

return true;
}
}
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;

Choose a reason for hiding this comment

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

We can remove this import (use App\Models\User;) because it is not used in the class

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];

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

namespace App\Actions\TwoFactorAuth;

use Illuminate\Support\Facades\Session;

class GetTwoFactorAuthenticatableUser
{
/**
* Get the user that is in the process of two-factor authentication.
*
* @return mixed|null The user model instance or null if not found
*/
public function __invoke()
{
$userId = Session::get('login.id');

if (!$userId) {
return null;
}

// Get the user model from auth config
$userModel = app(config('auth.providers.users.model'));

return $userModel::find($userId);
}
}
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 $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;
}
}
}
13 changes: 12 additions & 1 deletion app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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;
Expand All @@ -29,8 +30,18 @@ public function create(Request $request): Response
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
// Find user by email
$user = User::where('email', $request->email)->first();

// If user exists, password is correct, and 2FA is enabled, redirect to challenge
if ($user && $user->two_factor_confirmed_at && \Illuminate\Support\Facades\Hash::check($request->password, $user->password)) {
$request->session()->put('login.id', $user->getKey());
// Optionally clear any previous errors
return redirect()->route('two-factor.challenge');
}

// Proceed with normal authentication (this will handle errors and login)
$request->authenticate();
$request->session()->regenerate();

return redirect()->intended(route('dashboard', absolute: false));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Actions\TwoFactorAuth\CompleteTwoFactorAuthentication;
use App\Actions\TwoFactorAuth\ProcessRecoveryCode;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;


class TwoFactorAuthenticatedSessionController extends Controller
{
/**
* Display the two factor authentication challenge view.
*
* @param \Illuminate\Http\Request $request
* @return \Inertia\Response
*/
public function create(Request $request)
{
if (! $request->session()->has('login.id')) {
return redirect()->route('login');
}

return Inertia::render('auth/two-factor-challenge');
}

/**
* 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',
]);

$userId = $request->session()->get('login.id');
$user = User::find($userId);

if (! $user) {
return redirect()->route('login');
}

// Handle TOTP code
if ($request->filled('code')) {
$secret = decrypt($user->two_factor_secret);
$valid = app(\App\Actions\TwoFactorAuth\VerifyTwoFactorCode::class)($secret, $request->code);
if ($valid) {
app(CompleteTwoFactorAuthentication::class)($user);
return redirect()->intended(route('dashboard', absolute: false));
}
return back()->withErrors(['code' => __('The provided two factor authentication code was invalid.')]);
}

// Handle recovery code
if ($request->filled('recovery_code')) {
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
$provided = $request->recovery_code;
$match = collect($recoveryCodes)->first(function ($code) use ($provided) {
return hash_equals($code, $provided);
});
if (! $match) {
return back()->withErrors(['recovery_code' => __('The provided two factor authentication recovery code was invalid.')]);
}
// Remove used recovery code using the ProcessRecoveryCode action
$updatedCodes = app(ProcessRecoveryCode::class)($recoveryCodes, $match);
if ($updatedCodes === false) {
return back()->withErrors(['recovery_code' => __('The provided two factor authentication recovery code was invalid.')]);
}
$user->two_factor_recovery_codes = encrypt(json_encode($updatedCodes));
$user->save();
// Complete the authentication process
app(CompleteTwoFactorAuthentication::class)($user);

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

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

Loading
Loading