mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Improve 2fa verification experience
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user