Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This Starter kit contains my starting point when developing a new Laravel projec
- ✅ **User Management**,
- ✅ **Role Management**,
- ✅ **Permissions Management**,
- ✅ **Two-Factor Authentication (2FA)**
- ✅ **Localization** options
- ✅ Separate **Dashboard for Super Admins**
- ✅ Updated for Laravel 12.0 **and** Livewire 3.0
Expand All @@ -36,6 +37,7 @@ Among other things, it also includes:
- [missing-livewire-assertions](https://github.com/christophrumpel/missing-livewire-assertions) for extra testing of Livewire components by [Christoph Rumpel](https://github.com/christophrumpel)
- [LivewireAlerts](https://github.com/jantinnerezo/livewire-alert) for SweetAlerts
- [Spatie Roles & Permissions](https://spatie.be/docs/laravel-permission/v5/introduction) for user roles and permissions
- [Google2FA](https://github.com/antonioribeiro/google2fa) for Two-Factor Authentication (TOTP)
- [Strict Eloquent Models](https://planetscale.com/blog/laravels-safety-mechanisms) for safety
- [Laravel Debugbar](https://github.com/barryvdh/laravel-debugbar) for debugging
- [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper) for IDE support
Expand Down Expand Up @@ -104,6 +106,20 @@ return [
];
```

# Features

## Two-Factor Authentication

This starter kit includes built-in support for Two-Factor Authentication (2FA) using TOTP (Time-based One-Time Passwords), compatible with apps like Google Authenticator, Authy, and 1Password.

Key features:
- QR code generation for easy setup
- Recovery codes for account recovery
- Seamless integration with the authentication flow
- User-friendly interface

For detailed information, see [Two-Factor Authentication Documentation](docs/TWO_FACTOR_AUTHENTICATION.md).

# Developing

## Check for code style issues
Expand Down
16 changes: 15 additions & 1 deletion app/Livewire/Auth/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Login extends Component
/**
* Handle an incoming authentication request.
*/
public function login(): void
public function login()
{
$this->validate();

Expand All @@ -42,6 +42,20 @@ public function login(): void
]);
}

$user = Auth::user();

// Check if user has 2FA enabled
if ($user->hasTwoFactorEnabled()) {
Auth::logout();

session()->put([
'login.id' => $user->id,
'login.remember' => $this->remember,
]);

return $this->redirect(route('two-factor.challenge'), navigate: true);
}

RateLimiter::clear($this->throttleKey());
Session::regenerate();

Expand Down
127 changes: 127 additions & 0 deletions app/Livewire/Auth/TwoFactorChallenge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

namespace App\Livewire\Auth;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FA\Google2FA;

#[Layout('components.layouts.auth')]
class TwoFactorChallenge extends Component
{
#[Validate('required|string')]
public string $code = '';

public bool $recovery = false;

/**
* Challenge the user for their two-factor authentication code.
*/
public function challenge(): void
{
$this->validate();

$this->ensureIsNotRateLimited();

$user = Auth::getProvider()->retrieveById(
session('login.id')
);

if ($this->recovery) {
$this->challengeUsingRecoveryCode($user);
} else {
$this->challengeUsingCode($user);
}

RateLimiter::clear($this->throttleKey());

Auth::login($user, session('login.remember', false));

session()->forget(['login.id', 'login.remember']);
session()->regenerate();

$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}

/**
* Challenge using the authenticator app code.
*/
protected function challengeUsingCode($user): void
{
$google2fa = new Google2FA;
$secret = decrypt($user->two_factor_secret);

if (! $google2fa->verifyKey($secret, $this->code)) {
RateLimiter::hit($this->throttleKey());

throw ValidationException::withMessages([
'code' => __('The provided two-factor authentication code was invalid.'),
]);
}
}

/**
* Challenge using a recovery code.
*/
protected function challengeUsingRecoveryCode($user): void
{
$recoveryCodes = $user->recoveryCodes();

if (! in_array($this->code, $recoveryCodes)) {
RateLimiter::hit($this->throttleKey());

throw ValidationException::withMessages([
'code' => __('The provided recovery code was invalid.'),
]);
}

$user->replaceRecoveryCode($this->code);
}

/**
* Toggle between code and recovery mode.
*/
public function toggleRecovery(): void
{
$this->recovery = ! $this->recovery;
$this->code = '';
$this->resetErrorBag();
}

/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}

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

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

/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate('two-factor|'.request()->ip());
}

public function render()
{
return view('livewire.auth.two-factor-challenge');
}
}
176 changes: 176 additions & 0 deletions app/Livewire/Settings/TwoFactor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php

namespace App\Livewire\Settings;

use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FA\Google2FA;

#[Layout('components.layouts.app.frontend')]
class TwoFactor extends Component
{
public bool $showingQrCode = false;

public bool $showingRecoveryCodes = false;

public string $code = '';

public array $recoveryCodes = [];

/**
* Enable two-factor authentication.
*/
public function enableTwoFactorAuthentication(): void
{
$user = auth()->user();
$google2fa = new Google2FA;

// Generate a new secret
$secret = $google2fa->generateSecretKey();

// Store it temporarily
$user->two_factor_secret = encrypt($secret);
$user->save();

$this->showingQrCode = true;
$this->showingRecoveryCodes = false;
}

/**
* Confirm two-factor authentication.
*/
public function confirmTwoFactorAuthentication(): void
{
$this->validate([
'code' => 'required|string',
]);

$user = auth()->user();
$google2fa = new Google2FA;

$secret = decrypt($user->two_factor_secret);

if (! $google2fa->verifyKey($secret, $this->code)) {
$this->addError('code', 'The provided code was invalid.');

return;
}

// Mark 2FA as confirmed
$user->two_factor_confirmed_at = now();

// Generate recovery codes
$recoveryCodes = Collection::times(8, function () {
return strtoupper(bin2hex(random_bytes(5)));
})->all();

$user->two_factor_recovery_codes = encrypt(json_encode($recoveryCodes));
$user->save();

$this->recoveryCodes = $recoveryCodes;
$this->showingQrCode = false;
$this->showingRecoveryCodes = true;
$this->code = '';

$this->dispatch('alert', [
'type' => 'success',
'message' => 'Two-factor authentication has been enabled.',
]);
}

/**
* Show recovery codes.
*/
public function showRecoveryCodes(): void
{
$this->recoveryCodes = auth()->user()->recoveryCodes();
$this->showingRecoveryCodes = true;
}

/**
* Regenerate recovery codes.
*/
public function regenerateRecoveryCodes(): void
{
$user = auth()->user();

$recoveryCodes = Collection::times(8, function () {
return strtoupper(bin2hex(random_bytes(5)));
})->all();

$user->two_factor_recovery_codes = encrypt(json_encode($recoveryCodes));
$user->save();

$this->recoveryCodes = $recoveryCodes;
$this->showingRecoveryCodes = true;

$this->dispatch('alert', [
'type' => 'success',
'message' => 'New recovery codes have been generated.',
]);
}

/**
* Disable two-factor authentication.
*/
public function disableTwoFactorAuthentication(): void
{
$user = auth()->user();

$user->two_factor_secret = null;
$user->two_factor_recovery_codes = null;
$user->two_factor_confirmed_at = null;
$user->save();

$this->showingQrCode = false;
$this->showingRecoveryCodes = false;

$this->dispatch('alert', [
'type' => 'success',
'message' => 'Two-factor authentication has been disabled.',
]);
}

/**
* Get the QR code URL for the user.
*/
public function getQrCodeUrlProperty(): ?string
{
if (! $this->showingQrCode) {
return null;
}

$user = auth()->user();
$google2fa = new Google2FA;

$secret = decrypt($user->two_factor_secret);

$qrCodeUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);

return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data='.urlencode($qrCodeUrl);
}

/**
* Get the manual entry secret.
*/
public function getManualEntrySecretProperty(): ?string
{
if (! $this->showingQrCode) {
return null;
}

$user = auth()->user();

return decrypt($user->two_factor_secret);
}

public function render()
{
return view('livewire.settings.two-factor');
}
}
Loading