-
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Two-factor authentication (#23)
* feat: Adding 2fa * fix: Input validation * feat: added two-factor disable command * feat: Added tests and improved middleware * fix: Recovery code submission * refactor: Clean up controller * fix: Fixed 2fa code input * style: Improved ux for code prompt * style: Improved mfa manager * feat: Added mail notifications for critical actions * fix: Added banner for showing low backup codes * style: Improved ui for mfa manager * fix: An invalid form control is not focusable error
- Loading branch information
1 parent
b8d4cf0
commit 0d7127c
Showing
57 changed files
with
3,768 additions
and
101 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace App\Console\Commands; | ||
|
||
use App\Models\User; | ||
use Illuminate\Console\Command; | ||
|
||
class DisableTwoFactorAuth extends Command | ||
{ | ||
/** | ||
* The name and signature of the console command. | ||
* | ||
* @var string | ||
*/ | ||
protected $signature = 'vanguard:disable-two-factor | ||
{email : The email address of the user.}'; | ||
|
||
/** | ||
* The console command description. | ||
* | ||
* @var string | ||
*/ | ||
protected $description = 'This command will disable two-factor authentication for a user.'; | ||
|
||
/** | ||
* Execute the console command. | ||
*/ | ||
public function handle(): void | ||
{ | ||
$user = User::whereEmail($this->argument('email'))->first(); | ||
|
||
if (! $user) { | ||
$this->components->error("A user cannot be found with the email address '{$this->argument('email')}'"); | ||
|
||
return; | ||
} | ||
|
||
if (! $user->hasTwoFactorEnabled()) { | ||
$this->components->error("{$user->name} has not enabled two-factor authentication."); | ||
|
||
return; | ||
} | ||
|
||
$user->disableTwoFactorAuth(); | ||
|
||
$this->components->success("Disabled two-factor authentication for {$user->name}."); | ||
|
||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace App\Console\Commands; | ||
|
||
use App\Mail\User\TwoFactor\LongstandingTwoFactorFollowUpMail; | ||
use App\Models\User; | ||
use Illuminate\Console\Command; | ||
use Illuminate\Database\Eloquent\Collection; | ||
use Illuminate\Support\Facades\Log; | ||
use Illuminate\Support\Facades\Mail; | ||
use Symfony\Component\Console\Command\Command as CommandAlias; | ||
|
||
class NotifyUsersAboutOldBackupCodes extends Command | ||
{ | ||
/** | ||
* The console command name and signature. | ||
*/ | ||
protected $signature = 'vanguard:notify-old-backup-codes'; | ||
|
||
/** | ||
* The console command description. | ||
*/ | ||
protected $description = 'Notify users with outdated two-factor backup codes via email.'; | ||
|
||
/** | ||
* Execute the console command. | ||
*/ | ||
public function handle(): int | ||
{ | ||
$usersWithOldBackupCodes = $this->getUsersWithOldBackupCodes(); | ||
$emailCount = $this->notifyUsers($usersWithOldBackupCodes); | ||
|
||
$this->logNotificationResult($emailCount); | ||
|
||
return CommandAlias::SUCCESS; | ||
} | ||
|
||
/** | ||
* Get users with old backup codes. | ||
* | ||
* @return Collection<int, User> | ||
*/ | ||
private function getUsersWithOldBackupCodes(): Collection | ||
{ | ||
return User::withOutdatedBackupCodes()->get(); | ||
} | ||
|
||
/** | ||
* Send notification emails to users. | ||
* | ||
* @param Collection<int, User> $users | ||
*/ | ||
private function notifyUsers(Collection $users): int | ||
{ | ||
$emailCount = 0; | ||
|
||
$users->each(function (User $user) use (&$emailCount): void { | ||
Mail::to($user)->queue(new LongstandingTwoFactorFollowUpMail($user)); | ||
$emailCount++; | ||
}); | ||
|
||
return $emailCount; | ||
} | ||
|
||
/** | ||
* Log the notification result. | ||
*/ | ||
private function logNotificationResult(int $emailCount): void | ||
{ | ||
if ($emailCount > 0) { | ||
Log::info("Sent {$emailCount} users emails about their outdated backup codes."); | ||
} | ||
} | ||
} |
209 changes: 209 additions & 0 deletions
209
app/Http/Controllers/Auth/TwoFactorRequiredController.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace App\Http\Controllers\Auth; | ||
|
||
use App\Http\Controllers\Controller; | ||
use App\Mail\User\TwoFactor\BackupCodeConsumedMail; | ||
use App\Mail\User\TwoFactor\LowBackupCodesNoticeMail; | ||
use App\Mail\User\TwoFactor\NoBackupCodesRemainingNoticeMail; | ||
use Carbon\Carbon; | ||
use Illuminate\Contracts\Encryption\DecryptException; | ||
use Illuminate\Contracts\View\View; | ||
use Illuminate\Http\RedirectResponse; | ||
use Illuminate\Http\Request; | ||
use Illuminate\Support\Facades\Cookie; | ||
use Illuminate\Support\Facades\Hash; | ||
use Illuminate\Support\Facades\Mail; | ||
use Illuminate\Support\Facades\RateLimiter; | ||
|
||
class TwoFactorRequiredController extends Controller | ||
{ | ||
/** | ||
* Handle the two-factor authentication challenge. | ||
*/ | ||
public function __invoke(Request $request): View|RedirectResponse | ||
{ | ||
$user = $request->user(); | ||
|
||
if (! $user || ! $user->hasTwoFactorEnabled()) { | ||
return redirect()->route('overview'); | ||
} | ||
|
||
if ($this->hasValidTwoFactorCookie($request, $user)) { | ||
return redirect()->route('overview'); | ||
} | ||
|
||
return $request->isMethod('post') | ||
? $this->verifyTwoFactor($request) | ||
: view('auth.two-factor-challenge'); | ||
} | ||
|
||
/** | ||
* Check if the request has a valid two-factor cookie. | ||
*/ | ||
private function hasValidTwoFactorCookie(Request $request, mixed $user): bool | ||
{ | ||
$twoFactorCookie = $request->cookie('two_factor_verified'); | ||
|
||
if (! is_string($twoFactorCookie)) { | ||
return false; | ||
} | ||
|
||
try { | ||
$decryptedToken = decrypt($twoFactorCookie); | ||
|
||
return Hash::check($decryptedToken, $user->getAttribute('two_factor_verified_token')); | ||
} catch (DecryptException) { | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* Verify the submitted two-factor authentication code. | ||
*/ | ||
private function verifyTwoFactor(Request $request): RedirectResponse | ||
{ | ||
$validated = $request->validate([ | ||
'code' => ['required', 'string'], | ||
]); | ||
|
||
$user = $request->user(); | ||
|
||
if (! $user) { | ||
return back()->withErrors(['code' => 'User not authenticated.']); | ||
} | ||
|
||
if ($this->isRateLimited($user->id)) { | ||
return $this->rateLimitedResponse($user->id); | ||
} | ||
|
||
return $user->validateTwoFactorCode($validated['code']) | ||
? $this->handleSuccessfulVerification($user) | ||
: $this->handleFailedVerification($user->id); | ||
} | ||
|
||
/** | ||
* Generate a secure token for two-factor verification. | ||
*/ | ||
private function generateSecureToken(int $userId): string | ||
{ | ||
return hash_hmac('sha256', $userId . uniqid('', true), (string) config('app.key')); | ||
} | ||
|
||
/** | ||
* Check if the two-factor attempts are rate limited for the given user. | ||
*/ | ||
private function isRateLimited(int $userId): bool | ||
{ | ||
return RateLimiter::tooManyAttempts($this->getRateLimitKey($userId), 5); | ||
} | ||
|
||
/** | ||
* Get the rate limit key for a given user ID. | ||
*/ | ||
private function getRateLimitKey(int $userId): string | ||
{ | ||
return "two-factor-attempt:{$userId}"; | ||
} | ||
|
||
/** | ||
* Handle a successful two-factor verification. | ||
*/ | ||
private function handleSuccessfulVerification(mixed $user): RedirectResponse | ||
{ | ||
RateLimiter::clear($this->getRateLimitKey($user->id)); | ||
$token = $this->generateSecureToken($user->id); | ||
|
||
Cookie::queue('two_factor_verified', encrypt($token), 30 * 24 * 60, null, null, true, true, false, 'strict'); | ||
|
||
$user->update([ | ||
'two_factor_verified_token' => Hash::make($token), | ||
'last_two_factor_at' => now(), | ||
'last_two_factor_ip' => request()->ip(), | ||
]); | ||
|
||
$unusedCodeCount = $this->getUnusedRecoveryCodeCount($user); | ||
|
||
if ($this->wasRecoveryCodeUsed($user, request('code'))) { | ||
Mail::to($user)->queue(new BackupCodeConsumedMail($user)); | ||
} | ||
|
||
if ($unusedCodeCount === 0) { | ||
Mail::to($user)->queue(new NoBackupCodesRemainingNoticeMail($user)); | ||
|
||
return $this->redirectWithWarning('You have no unused recovery codes left. Please generate new ones immediately.'); | ||
} | ||
|
||
if ($unusedCodeCount <= 3) { | ||
Mail::to($user)->queue(new LowBackupCodesNoticeMail($user)); | ||
|
||
return $this->redirectWithWarning("You only have {$unusedCodeCount} unused recovery codes left. Consider generating new ones."); | ||
} | ||
|
||
return redirect()->intended(route('overview')); | ||
} | ||
|
||
/** | ||
* Get the count of unused recovery codes. | ||
*/ | ||
private function getUnusedRecoveryCodeCount(mixed $user): int | ||
{ | ||
$recoveryCodes = $user->getRecoveryCodes(); | ||
|
||
return $recoveryCodes->filter(fn ($code): bool => $code['used_at'] === null)->count(); | ||
} | ||
|
||
/** | ||
* Redirect with a warning message. | ||
*/ | ||
private function redirectWithWarning(string $message): RedirectResponse | ||
{ | ||
return redirect()->intended(route('overview'))->with('flash_message', [ | ||
'message' => $message, | ||
'type' => 'warning', | ||
'dismissible' => true, | ||
]); | ||
} | ||
|
||
/** | ||
* Handle a failed two-factor verification attempt. | ||
*/ | ||
private function handleFailedVerification(int $userId): RedirectResponse | ||
{ | ||
RateLimiter::hit($this->getRateLimitKey($userId)); | ||
sleep(random_int(1, 3)); // Mitigate timing attacks | ||
|
||
return back()->withErrors(['code' => 'The provided two-factor code or recovery code was invalid.']); | ||
} | ||
|
||
/** | ||
* Generate a response for when the user has been rate limited. | ||
*/ | ||
private function rateLimitedResponse(int $userId): RedirectResponse | ||
{ | ||
$seconds = RateLimiter::availableIn($this->getRateLimitKey($userId)); | ||
|
||
return back()->withErrors(['code' => "Too many attempts. Please try again in {$seconds} seconds."]); | ||
} | ||
|
||
/** | ||
* Check if the provided code was a recovery code that was just used. | ||
*/ | ||
private function wasRecoveryCodeUsed(mixed $user, ?string $code): bool | ||
{ | ||
if (! $code) { | ||
return false; | ||
} | ||
|
||
$recoveryCodes = $user->getRecoveryCodes(); | ||
$usedCode = $recoveryCodes->firstWhere('code', $code); | ||
|
||
if (! $usedCode || ! isset($usedCode['used_at'])) { | ||
return false; | ||
} | ||
|
||
return Carbon::parse($usedCode['used_at'])->isAfter(now()->subSeconds(5)); | ||
} | ||
} |
Oops, something went wrong.