From 0556c25abaa564455d30880b74338147dd228e1f Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 9 Jun 2025 16:06:37 +0200 Subject: [PATCH] Improve 2fa verification experience --- app/Http/Controllers/Auth/LoginController.php | 86 ++++++++++++++ .../PasswordSecurityController.php | 71 +++++++---- app/Http/Middleware/Google2FAMiddleware.php | 30 +++++ .../Middleware/TrustedDevice2FAMiddleware.php | 93 +++++++++++++++ app/Support/Google2FAAuthenticator.php | 112 ++++++++++++++++-- bootstrap/app.php | 1 + 6 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 app/Http/Middleware/TrustedDevice2FAMiddleware.php diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index e58a8b17c..548ff5cb6 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -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'); } } diff --git a/app/Http/Controllers/PasswordSecurityController.php b/app/Http/Controllers/PasswordSecurityController.php index 3728a5022..26011d7fb 100644 --- a/app/Http/Controllers/PasswordSecurityController.php +++ b/app/Http/Controllers/PasswordSecurityController.php @@ -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); } /** diff --git a/app/Http/Middleware/Google2FAMiddleware.php b/app/Http/Middleware/Google2FAMiddleware.php index 1d60f3a7f..04a82492d 100644 --- a/app/Http/Middleware/Google2FAMiddleware.php +++ b/app/Http/Middleware/Google2FAMiddleware.php @@ -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()) { diff --git a/app/Http/Middleware/TrustedDevice2FAMiddleware.php b/app/Http/Middleware/TrustedDevice2FAMiddleware.php new file mode 100644 index 000000000..bc6dd34f3 --- /dev/null +++ b/app/Http/Middleware/TrustedDevice2FAMiddleware.php @@ -0,0 +1,93 @@ +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; + } +} diff --git a/app/Support/Google2FAAuthenticator.php b/app/Support/Google2FAAuthenticator.php index 6e4098768..57f4d7604 100644 --- a/app/Support/Google2FAAuthenticator.php +++ b/app/Support/Google2FAAuthenticator.php @@ -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 { - $cookie = request()->cookie('2fa_trusted_device'); - - if (! $cookie) { - return false; - } - try { - $data = json_decode($cookie, true); + $cookie = request()->cookie('2fa_trusted_device'); + $user = $this->getUser(); - // Check if cookie contains the required data + if (! $cookie) { + return false; + } + + $data = @json_decode($cookie, true); + + // 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(); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index efe3bb62f..e6fca86e8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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');