Improve 2fa verification experience

This commit is contained in:
DariusIII
2025-06-09 16:06:37 +02:00
parent ec8b22f10a
commit 0556c25aba
6 changed files with 358 additions and 35 deletions
@@ -6,6 +6,7 @@ use App\Events\UserLoggedIn;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginLoginRequest;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\RedirectResponse;
@@ -48,6 +49,9 @@ class LoginController extends Controller
$this->middleware('guest')->except('logout');
}
/**
* @throws AuthenticationException
*/
public function login(LoginLoginRequest $request): \Illuminate\Foundation\Application|\Illuminate\Routing\Redirector|Application|RedirectResponse
{
$validator = Validator::make($request->all(), [
@@ -92,6 +96,36 @@ class LoginController extends Controller
// Check if the user has 2FA enabled
if ($user->passwordSecurity && $user->passwordSecurity->google2fa_enable) {
// Check for trusted device cookie before redirecting to 2FA
$trustedCookie = $request->cookie('2fa_trusted_device');
if ($trustedCookie) {
try {
$cookieData = json_decode($trustedCookie, true);
// Validate the cookie data
if (json_last_error() === JSON_ERROR_NONE &&
isset($cookieData['user_id'], $cookieData['token'], $cookieData['expires_at']) &&
(int)$cookieData['user_id'] === (int)$user->id &&
time() <= $cookieData['expires_at']) {
// Cookie is valid - mark 2FA as passed
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
// Skip 2FA - proceed with login
Auth::logoutOtherDevices($request->input('password'));
$this->clearLoginAttempts($request);
return redirect()->intended($this->redirectPath())->with('info', 'You have been logged in');
}
} catch (\Exception $e) {
\Log::error('Login - Error processing trusted device cookie', [
'error' => $e->getMessage()
]);
}
}
// No valid trusted device cookie, proceed with 2FA verification
// Store intended URL for redirecting after 2FA verification
$request->session()->put('url.intended', $this->redirectPath());
Auth::logout();
@@ -139,11 +173,63 @@ class LoginController extends Controller
public function logout(Request $request): \Illuminate\Routing\Redirector|RedirectResponse
{
// Save 2FA trusted device cookie value before logout
$trustedDeviceCookie = $request->cookie('2fa_trusted_device');
// Perform standard logout
$this->guard()->logout();
$request->session()->invalidate();
$request->session()->regenerate();
// If there was a trusted device cookie, preserve it by re-creating it
if ($trustedDeviceCookie) {
try {
// Parse the cookie to get the original data including expiration time
$cookieData = json_decode($trustedDeviceCookie, true);
if (isset($cookieData['expires_at'])) {
// Calculate remaining minutes until expiration
$remainingSeconds = max(0, $cookieData['expires_at'] - time());
$remainingMinutes = (int) ceil($remainingSeconds / 60);
\Log::info('Logout - Cookie Expiration', [
'expires_at' => $cookieData['expires_at'],
'current_time' => time(),
'remaining_seconds' => $remainingSeconds,
'remaining_minutes' => $remainingMinutes
]);
// Only preserve the cookie if it hasn't expired yet
if ($remainingMinutes > 0) {
// Create a cookie with proper settings for persistence
$cookie = cookie(
'2fa_trusted_device', // name
$trustedDeviceCookie, // value
$remainingMinutes, // minutes remaining
'/', // path
config('session.domain'), // use session domain config
config('session.secure'), // use session secure config
false, // httpOnly
false, // raw
config('session.same_site', 'lax') // use session same_site config
);
// Queue the cookie to be sent with the response
cookie()->queue($cookie);
} else {
\Log::warning('Logout - Cookie Already Expired');
}
} else {
\Log::warning('Logout - Cookie Missing Expiration Data');
}
} catch (\Exception $e) {
\Log::error('Logout - Error Processing Cookie', [
'error' => $e->getMessage()
]);
}
}
return redirect()->to('login')->with('message', 'You have been logged out successfully');
}
}
@@ -158,39 +158,56 @@ class PasswordSecurityController extends Controller
// Store the timestamp for determining how long the 2FA session is valid
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
// If the user has checked "trust this device", create a trust token
if ($request->has('trust_device') && $request->input('trust_device') == 1) {
// Generate a unique token for this device
$token = hash('sha256', $user->id.uniqid().time());
// Store the token with an expiry time of 30 days
$expiresAt = now()->addDays(30)->timestamp;
// Store the trusted device token in a cookie
cookie()->queue(
'2fa_trusted_device',
json_encode([
'user_id' => $user->id,
'token' => $token,
'expires_at' => $expiresAt,
]),
60 * 24 * 30 // 30 days in minutes
);
// Store the token in the database if you want to manage/revoke trusted devices
// This would require creating a new table for trusted devices
// We're using cookies only for this implementation
}
// Clean up the temporary session variable
$request->session()->forget('2fa:user:id');
// Determine where to redirect after successful verification
$redirectUrl = $request->session()->pull('url.intended', '/');
return redirect()->to($redirectUrl)
// Create the redirect response
$redirect = redirect()->to($redirectUrl)
->with('message', 'Two-factor authentication verified successfully.')
->with('message_type', 'success');
// If the user has checked "trust this device", create a trust token
if ($request->has('trust_device') && $request->input('trust_device') == 1) {
// Generate a unique token for this device
$token = hash('sha256', $user->id.uniqid().time());
// Store the token with an expiry time of 30 days
$expiresAt = time() + (60 * 60 * 24 * 30); // 30 days in seconds
// Create the cookie data
$cookieData = [
'user_id' => $user->id,
'token' => $token,
'expires_at' => $expiresAt,
];
$cookieValue = json_encode($cookieData);
// Use PHP's native setcookie function as the primary method
setcookie(
'2fa_trusted_device',
$cookieValue,
[
'expires' => $expiresAt,
'path' => '/',
'domain' => '',
'secure' => request()->secure(),
'httponly' => false,
'samesite' => 'Lax'
]
);
// Also attach the cookie to the Laravel response as a backup approach
$redirect->withCookie(
cookie('2fa_trusted_device', $cookieValue, 43200, '/', null, null, false)
);
}
return $redirect;
}
/**
@@ -224,7 +241,9 @@ class PasswordSecurityController extends Controller
app('smarty.view')->assign(compact('meta_title', 'meta_keywords', 'meta_description', 'user'));
return app('smarty.view')->display($theme.'/2fa_verify.tpl');
// Create a response with the rendered content instead of directly outputting
$content = app('smarty.view')->fetch($theme.'/2fa_verify.tpl');
return response($content);
}
/**
@@ -14,6 +14,36 @@ class Google2FAMiddleware
*/
public function handle(Request $request, Closure $next): Response
{
// Direct check for trusted device cookie
$trustedCookie = $request->cookie('2fa_trusted_device');
if ($trustedCookie && auth()->check()) {
try {
$cookieData = json_decode($trustedCookie, true);
if (json_last_error() === JSON_ERROR_NONE) {
// Check if all required fields exist
if (isset($cookieData['user_id'], $cookieData['token'], $cookieData['expires_at'])) {
// Check if cookie user ID matches authenticated user
if ((int)$cookieData['user_id'] === (int)auth()->id()) {
// Check if cookie is not expired
if (time() <= $cookieData['expires_at']) {
// Set the session variables for 2FA authentication
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
// IMMEDIATELY allow the request to proceed - bypass all other checks
return $next($request);
}
}
}
}
} catch (\Exception $e) {
// Silently handle any exceptions
}
}
// Continue with normal 2FA flow if we reach this point
$authenticator = app(Google2FAAuthenticator::class)->boot($request);
if ($authenticator->isAuthenticated()) {
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TrustedDevice2FAMiddleware
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next): mixed
{
// Check for trusted device cookie on incoming request
$trustedCookie = $request->cookie('2fa_trusted_device');
if ($trustedCookie && auth()->check()) {
try {
$cookieData = json_decode($trustedCookie, true);
// If cookie data is valid and user matches
if (json_last_error() === JSON_ERROR_NONE &&
isset($cookieData['user_id'], $cookieData['token'], $cookieData['expires_at']) &&
(int)$cookieData['user_id'] === (int)auth()->id() &&
time() <= $cookieData['expires_at']) {
// Mark this user's session as having passed 2FA
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
}
} catch (\Exception $e) {
Log::error('TrustedDevice2FAMiddleware - Error processing cookie', [
'error' => $e->getMessage()
]);
}
}
// Process the request
$response = $next($request);
// Check if we need to set a trusted device cookie
if ($request->session()->has('2fa_trusted_device_pending')) {
$cookieData = $request->session()->pull('2fa_trusted_device_pending');
// Ensure cookie data is properly formatted
$cookieValue = json_encode($cookieData, JSON_UNESCAPED_SLASHES);
try {
// Create a cookie instance with proper settings for persistence
$cookie = cookie(
'2fa_trusted_device', // name
$cookieValue, // value
60 * 24 * 30, // minutes (30 days)
'/', // path
null, // domain (null = current domain)
null, // secure (auto)
false, // httpOnly - allow JS access
false, // raw
'lax' // sameSite
);
// Add cookie to the response
$response->headers->setCookie($cookie);
// Backup approach - also set directly in PHP
$expiry = time() + (60 * 60 * 24 * 30); // 30 days
@setcookie('2fa_trusted_device', $cookieValue, [
'expires' => $expiry,
'path' => '/',
'domain' => '',
'secure' => $request->secure(),
'httponly' => false,
'samesite' => 'Lax'
]);
// Keep in session for backup access
$request->session()->put('2fa_trusted_device_value', $cookieValue);
} catch (\Exception $e) {
Log::error('TrustedDevice2FAMiddleware - Error setting cookie', [
'error' => $e->getMessage()
]);
}
}
return $response;
}
}
+98 -4
View File
@@ -7,6 +7,60 @@ use PragmaRX\Google2FALaravel\Support\Authenticator;
class Google2FAAuthenticator extends Authenticator
{
/**
* Check if the user is authenticated for 2FA
*/
public function isAuthenticated()
{
// First check - directly check for the cookie before any other logic
$cookie = request()->cookie('2fa_trusted_device');
if ($cookie && $this->checkCookieValidity($cookie)) {
// Force the session to be marked as 2FA authenticated
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
// Successful authentication with cookie
return true;
}
return parent::isAuthenticated();
}
/**
* Directly validate the cookie without any output or logging
*/
private function checkCookieValidity($cookie)
{
try {
$data = @json_decode($cookie, true);
if (!is_array($data)) {
return false;
}
// Validate all required fields
if (!isset($data['user_id'], $data['token'], $data['expires_at'])) {
return false;
}
// Ensure the user ID matches
if ((int)$data['user_id'] !== (int)$this->getUser()->id) {
return false;
}
// Ensure the token is not expired
if (time() > $data['expires_at']) {
return false;
}
// All checks passed - this is a valid cookie
return true;
} catch (\Exception $e) {
return false;
}
}
protected function canPassWithoutCheckingOTP(): bool
{
if (! $this->getUser()->passwordSecurity) {
@@ -26,22 +80,28 @@ class Google2FAAuthenticator extends Authenticator
*/
protected function isDeviceTrusted(): bool
{
try {
$cookie = request()->cookie('2fa_trusted_device');
$user = $this->getUser();
if (! $cookie) {
return false;
}
try {
$data = json_decode($cookie, true);
$data = @json_decode($cookie, true);
// Check if cookie contains the required data
// Check for JSON decode errors silently
if (json_last_error() !== JSON_ERROR_NONE) {
return false;
}
// Validate the required fields silently
if (! isset($data['user_id'], $data['token'], $data['expires_at'])) {
return false;
}
// Check if the token belongs to the current user
if ((int) $data['user_id'] !== (int) $this->getUser()->id) {
if ((int) $data['user_id'] !== (int) $user->id) {
return false;
}
@@ -53,6 +113,7 @@ class Google2FAAuthenticator extends Authenticator
// Device is trusted and token is valid
return true;
} catch (\Exception $e) {
// Silently handle any exceptions
return false;
}
}
@@ -72,4 +133,37 @@ class Google2FAAuthenticator extends Authenticator
return $secret;
}
/**
* Override the parent isEnabled method to force-disable 2FA when a trusted device is detected
*/
public function isEnabled()
{
// Check for trusted device cookie
$trustedCookie = request()->cookie('2fa_trusted_device');
if ($trustedCookie && auth()->check()) {
try {
$cookieData = json_decode($trustedCookie, true);
if (json_last_error() === JSON_ERROR_NONE &&
isset($cookieData['user_id'], $cookieData['token'], $cookieData['expires_at']) &&
(int)$cookieData['user_id'] === (int)auth()->id() &&
time() <= $cookieData['expires_at']) {
// If we have a valid cookie, force-disable 2FA
session([config('google2fa.session_var') => true]);
session([config('google2fa.session_var').'.auth.passed_at' => time()]);
// Completely disable 2FA for this request
return false;
}
} catch (\Exception $e) {
// Silently handle any exceptions
}
}
// Otherwise, use the parent implementation
return parent::isEnabled();
}
}
+1
View File
@@ -46,6 +46,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web([
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\TrustedDevice2FAMiddleware::class, // Add our new trusted device middleware
]);
$middleware->throttleApi('60,1');