Skip to content

Adding Two Factor Auth Feature #84

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 31 commits into
base: main
Choose a base branch
from
Open

Adding Two Factor Auth Feature #84

wants to merge 31 commits into from

Conversation

tnylea
Copy link
Contributor

@tnylea tnylea commented Apr 17, 2025

This PR adds Two Factor Authentication to this starter kit.

On the user settings page, a new tab called Two-Factor Auth will be added (we can rename this tab if desired).

CleanShot 2025-04-17 at 15 24 07@2x

When the user clicks that tab, they will see the following page which notifies them that 2FA is currently Disabled.

CleanShot 2025-04-17 at 15 24 21@2x

Clicking the Enable button will open a modal with a QR code and an alpha-numeric code. The user can enter this into their 2FA application (Google Authenticator).

CleanShot 2025-04-17 at 15 13 14@2x

Next, after clicking Continue, they’ll be prompted to enter the authentication code from their authenticator app.

CleanShot 2025-04-17 at 15 13 21@2x

If the user enters the correct code the modal will close and they will see the following screen.

CleanShot 2025-04-17 at 15 12 32@2x

This shows the user that 2FA is now Enabled and provides them with Recovery codes. Now, when the user logs in to their account, they will see an additional authentication page where they are prompted for their 2FA code.

CleanShot 2025-04-17 at 15 14 03@2x

The user may also click the Use Recovery Codes button, and they will be presented with an input to enter one of their recovery codes. A recovery code can only be used once. After the user enters the recovery code, it will no longer be valid. If the user adds all the codes to the input, the logic will strip out the first code and verify it.

The 2FA challenge page utilizes the auth layout, so it will look good on any layout the developer chooses.

CleanShot 2025-04-17 at 15 14 30@2x

CleanShot 2025-04-17 at 15 22 47@2x

Other Starter Kit 2FA Feature PR's

React: github.com/laravel/react-starter-kit/pull/101
Vue: github.com/laravel/vue-starter-kit/pull/120

@tnylea tnylea marked this pull request as ready for review April 17, 2025 19:43
@lucasjose501
Copy link
Contributor

I love this implementation, I did something similar in my project, and while I was thinking about security, I think there are two things to consider:

  1. Make the database secret and recovery codes encrypted, someone with access to the database would be able to bypass the login with that information.
  2. Ask for password confirmation before enabling/disabling, and showing recovery codes.

I'm not an expert in security, this is just something that crossed my mind.

@tnylea
Copy link
Contributor Author

tnylea commented Apr 22, 2025

Screenshot 2025-04-22 at 8 59 01 otp maxlength not working

This has been fixed in the latest commit. Thanks for letting me know 👏

@jsmit99
Copy link

jsmit99 commented Apr 23, 2025

@tnylea great work, would love to see it getting in there.

Btw (might be offtopic a little) but does anyone has any suggestion on what best way to implement this in existing project (based on laravel livewire starter kit) is?

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.

@lucasjose501
Copy link
Contributor

@tnylea great work, would love to see it getting in there.

Btw (might be offtopic a little) but does anyone has any suggestion on what best way to implement this in existing project (based on laravel livewire starter kit) is?

The best way is probably waiting for the PR to merge, so everything is reviewed and ready to use, and then look at the changed files and copy/merge into your project by hand.

Copy link

@joetannenbaum joetannenbaum left a comment

Choose a reason for hiding this comment

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

Haven't reviewed the inner workings of the code just yet, but have some nits before I dive in (see comments).

First, love the screen visuals, design part looks great.

Some other notes (all notes refer to the images below them):

This looks like a button, is there another way to style this to make it look more like a status or indicator?

CleanShot 2025-04-24 at 11 05 39@2x

This looks great, couple of things:

  • The lock icon should be closed? Unless open in standard in this case?
  • Would love a “copy” and/or “download” button
  • We’re mixing zinc (body bg color) with stone and the grays feel off, maybe we lock in on zinc? Not a designer but it’s triggering my spidey sense.

CleanShot 2025-04-24 at 11 08 34@2x

Punched in some random numbers on the 2FA screen after login to see what the error looks like and got this (also happens on correct code entry afterwards).

CleanShot 2025-04-24 at 11 16 47@2x

public function __invoke($user): void
{
// Get the remember preference from the session (default to false if not set)
$remember = Session::get('login.remember', false);

Choose a reason for hiding this comment

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

Any reason to not use Session::pull since you're doing it below anyway?

* @param mixed $user
* @return void
*/
public function __invoke($user): Collection

Choose a reason for hiding this comment

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

We're not using this argument, remove?


namespace App\Actions\TwoFactorAuth;

use App\Models\User;

Choose a reason for hiding this comment

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

Unused import

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.

Unused import

}
}
}
let that = this;

Choose a reason for hiding this comment

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

If you use an arrow function for the setTimeout, don't you avoid having to set this variable?

let focusLastInput = (paste.length <= this.total_digits) ? paste.length : this.total_digits;
this.$refs['input' + focusLastInput].focus();
if (paste.length >= this.total_digits) {
let that = this;

Choose a reason for hiding this comment

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

See arrow function comment above

@@ -29,20 +32,56 @@ public function login(): void
$this->ensureIsNotRateLimited();
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
// Get the user model from auth config in a single line
$userAttemptingLogin = User::where('email', $this->email)->first();

Choose a reason for hiding this comment

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

What if the user isn't coming from User model? Can we tap into the auth user resolver?

RateLimiter::clear($this->throttleKey($user));
// Redirect to the intended page
return $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);

Choose a reason for hiding this comment

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

If you're returning here, no need for an else statement below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So, the ProcessRecoveryCode::class will return false if the entered recovery code does not match. If it returns false, we need to display a message to the user, which occurs in the else condition.

However, feel free to elaborate on your thinking; perhaps there's something I'm missing.

Thanks!

use App\Actions\TwoFactorAuth\GenerateQrCodeAndSecretKey;
use App\Actions\TwoFactorAuth\GenerateNewRecoveryCodes;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Hash;

Choose a reason for hiding this comment

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

Unused import

@valorin
Copy link

valorin commented Apr 28, 2025

My reply to the discussion about not using Auth::attempt() is hidden (at least for me) under multiple collapsed sections, so I'm duplicating it here so it's not missed:


I'm not saying you need to use Auth::attempt() here - it makes sense to use Auth::login() given the situation. However running Auth::login() on it's own isn't enough.

During a successful login Auth::attempt() fires the Attempting event, and triggers password rehashing. Both of these are missing and will not occur when a user has 2FA enabled. The Attempting event is an important event for security logging/monitoring, as it gives visibility on login attempts, and rehashing is important to ensure user password hashes are kept updated.

Also, I'm pretty sure the new logic will also fail to trigger Failed when a login is failed, which is also an incredibly important event for security logging/monitoring.

My suggestion is to either replicate those events and rehashing within the starter kit logic, or create a methods on the Guard that will fire the events and perform rehashing, but also support the 2-step process needed here. I can see it being useful for others, so something in the framework could be useful. Also would avoid duplicating across the different starter kits.

As for Fortify not using Auth::attempt(), it's probably wrong too. I haven't audited it's code, so don't know the mechanics. All I can tell you is that this specific PR is missing functionality.

*/
protected function throttleKey(User $user): string
{
return Str::transliterate($user->id . '|2fa|' . request()->ip());
Copy link

Choose a reason for hiding this comment

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

I suggest dropping the IP and limiting purely based on User ID. Otherwise, it would be possible for an attacker to rotate their IP to bypass the limit.

@iamsandakelum
Copy link

iamsandakelum commented Apr 30, 2025

There's a critical error when attempting a two factor verification, on rate limitation code.

It's the "ensureIsNotRateLimited" function at \resources\views\livewire\auth\two-factor-challenge.blade.php

Fix would be this:

protected function ensureIsNotRateLimited(): void
{
    $user = app(GetTwoFactorAuthenticatableUser::class)();

    if (! RateLimiter::tooManyAttempts($this->throttleKey($user), 5)) {
        return;
    }

    event(new Lockout(request()));

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

    throw ValidationException::withMessages([
        'auth_code' => __('auth.throttle', [
            'seconds' => $seconds,
            'minutes' => ceil($seconds / 60),
        ]),
    ]);
}

Fixes #84 (review)

@iamsandakelum
Copy link

I think it would be wise to confirm the 2FA code once again before allowing to disable the 2FA. At the moment, just a button click disables it off.

@tnylea
Copy link
Contributor Author

tnylea commented Apr 30, 2025

Thanks for the review, @joetannenbaum; I've implemented all the recommendations, as well as the security recommendations from @valorin (I appreciate it!).

I've also made a few additional security updates, pointed out in this comment: laravel/vue-starter-kit#120 (comment), from the Vue PR.

@iamsandakelum, thank you for pointing out the issue above. I've gone ahead and fixed it.

@tnylea tnylea requested a review from joetannenbaum April 30, 2025 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.