From 832fb5fb592fbd8e31e4914cb57d78f474a2c345 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Thu, 11 Jun 2026 10:26:29 +0200 Subject: [PATCH] Fix couple of security issues --- app/Http/Controllers/Api/ApiController.php | 90 +++--- app/Http/Controllers/Api/ApiV2Controller.php | 10 +- .../Auth/ForgotPasswordController.php | 13 +- app/Http/Controllers/Auth/LoginController.php | 190 +++++++------ .../PasswordSecurityController.php | 70 ++--- app/Http/Controllers/RssController.php | 45 ++- .../Middleware/TrustedDevice2FAMiddleware.php | 53 ++-- app/Models/TrustedDevice.php | 103 +++++++ app/Observers/UserApiCacheObserver.php | 50 ++++ app/Providers/UserServiceProvider.php | 2 + bootstrap/app.php | 4 - ...10_000000_create_trusted_devices_table.php | 32 +++ routes/rss.php | 30 +- routes/web.php | 8 +- .../Auth/PasskeyAuthenticationTest.php | 9 +- .../Auth/RememberMeAuthenticationTest.php | 261 ++++++++++++++++++ tests/Feature/NzbAndRssAccessTest.php | 194 +++++++++++++ 17 files changed, 875 insertions(+), 289 deletions(-) create mode 100644 app/Models/TrustedDevice.php create mode 100644 app/Observers/UserApiCacheObserver.php create mode 100644 database/migrations/2026_06_10_000000_create_trusted_devices_table.php create mode 100644 tests/Feature/Auth/RememberMeAuthenticationTest.php diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 0295334db..13fed5ceb 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -183,7 +183,10 @@ class ApiController extends BasePageController switch ($function) { // Search releases. case 's': - $this->verifyEmptyParameter($request, 'q'); + $emptyParameterError = $this->verifyEmptyParameter($request, 'q'); + if ($emptyParameterError !== null) { + return $emptyParameterError; + } $maxAge = $this->maxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -222,20 +225,15 @@ class ApiController extends BasePageController $minSize ); } - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); // Search tv releases. case 'tv': - $this->verifyEmptyParameter($request, 'q'); - $this->verifyEmptyParameter($request, 'vid'); - $this->verifyEmptyParameter($request, 'tvdbid'); - $this->verifyEmptyParameter($request, 'traktid'); - $this->verifyEmptyParameter($request, 'rid'); - $this->verifyEmptyParameter($request, 'tvmazeid'); - $this->verifyEmptyParameter($request, 'imdbid'); - $this->verifyEmptyParameter($request, 'tmdbid'); - $this->verifyEmptyParameter($request, 'season'); - $this->verifyEmptyParameter($request, 'ep'); + foreach (['q', 'vid', 'tvdbid', 'traktid', 'rid', 'tvmazeid', 'imdbid', 'tmdbid', 'season', 'ep'] as $parameter) { + $emptyParameterError = $this->verifyEmptyParameter($request, $parameter); + if ($emptyParameterError !== null) { + return $emptyParameterError; + } + } $maxAge = $this->maxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -265,8 +263,7 @@ class ApiController extends BasePageController $minSize ); - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); } $siteIdArr = [ @@ -304,15 +301,16 @@ class ApiController extends BasePageController $sort ); - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); // Search movie releases. case 'm': - $this->verifyEmptyParameter($request, 'q'); - $this->verifyEmptyParameter($request, 'imdbid'); - $this->verifyEmptyParameter($request, 'tmdbid'); - $this->verifyEmptyParameter($request, 'traktid'); + foreach (['q', 'imdbid', 'tmdbid', 'traktid'] as $parameter) { + $emptyParameterError = $this->verifyEmptyParameter($request, $parameter); + if ($emptyParameterError !== null) { + return $emptyParameterError; + } + } $maxAge = $this->maxAge($request); if (! is_int($maxAge)) { return $maxAge; @@ -341,8 +339,7 @@ class ApiController extends BasePageController -1, $minSize ); - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); } $imdbId = $request->has('imdbid') && $request->filled('imdbid') @@ -372,8 +369,7 @@ class ApiController extends BasePageController } ); - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); case 'music': if ($request->has('q') && ! $request->filled('q')) { @@ -421,8 +417,7 @@ class ApiController extends BasePageController $sort ); } - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); case 'book': if ($request->has('q') && ! $request->filled('q')) { @@ -470,8 +465,7 @@ class ApiController extends BasePageController $sort ); } - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); case 'anime': $q = (string) ($request->input('q') ?? ''); @@ -500,16 +494,18 @@ class ApiController extends BasePageController $anilist, $sort ); - $this->output($relData, $params, $outputXML, $offset, 'api'); - break; + return $this->output($relData, $params, $outputXML, $offset, 'api'); // Get NZB. case 'g': - $this->verifyEmptyParameter($request, 'g'); + $emptyParameterError = $this->verifyEmptyParameter($request, 'g'); + if ($emptyParameterError !== null) { + return $emptyParameterError; + } UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { - return redirect(url('/getnzb?r='.$apiKey.'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : ''))); + return redirect(url('/getnzb?r='.rawurlencode((string) $apiKey).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : ''))); } return showApiError(300, 'No such item (the guid you provided has no release in our database)'); @@ -523,8 +519,7 @@ class ApiController extends BasePageController UserRequest::addApiRequest($uid, $request->getRequestUri()); $data = Release::getByGuidForApi($request->input('id')); - $this->output($data, $params, $outputXML, $offset, 'api'); - break; + return $this->output($data, $params, $outputXML, $offset, 'api'); // Get an NFO file for an individual release. case 'n': @@ -638,19 +633,17 @@ class ApiController extends BasePageController return showApiError(603, 'Failed to write file to disk'); - break; // Capabilities request. case 'c': - $this->output([], $params, $outputXML, $offset, 'caps'); - break; + return $this->output([], $params, $outputXML, $offset, 'caps'); } } /** * @param array $params * @param array $headers - * @return Response|void + * @return Response * * @throws \Exception */ @@ -668,29 +661,24 @@ class ApiController extends BasePageController $xmlResponse = new XML_Response($options); if ($xml) { - // Generate XML response $response = $xmlResponse->returnXML(); - header('Content-type: text/xml'); + $contentType = 'text/xml'; } else { - // Build JSON directly from array (avoids expensive XML->xml_to_array->json_encode path) $arrayData = $xmlResponse->returnArray(); if ($arrayData === false) { return showApiError(201); } $response = json_encode($arrayData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - header('Content-type: application/json'); + $contentType = 'application/json'; } if ($response === false) { return showApiError(201); - } else { - foreach ($headers as $name => $value) { - header($name.': '.$value); - } - - header('Content-Length: '.\strlen($response)); - echo $response; - exit; } + + return response($response, 200, array_merge([ + 'Content-type' => $contentType, + 'Content-Length' => (string) \strlen($response), + ], $headers)); } /** diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 2acec2e13..acefcd3b0 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -65,6 +65,7 @@ class ApiV2Controller extends BasePageController $user = Cache::remember($userCacheKey, 300, function () use ($apiToken) { return User::query() ->whereApiToken((string) $apiToken) + ->with('role') ->first(); }); @@ -78,6 +79,13 @@ class ApiV2Controller extends BasePageController $user->loadMissing('role'); + $userStats = $this->api->getCachedUserStats($user->id); + $thisRequests = (int) ($userStats->api_count ?? 0); + $maxRequests = (int) $user->role->apirequests; + if ($thisRequests > $maxRequests) { + return apiJsonError(500, 'Request limit reached'); + } + return $user; } @@ -605,7 +613,7 @@ class ApiV2Controller extends BasePageController UserRequest::addApiRequest($user->id, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { - return redirect('/getnzb?r='.$request->input('api_token').'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')); + return redirect('/getnzb?r='.rawurlencode((string) $request->input('api_token')).'&id='.rawurlencode((string) $request->input('id')).(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')); } return response()->json(['data' => 'No such item (the guid you provided has no release in our database)'], 404); diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index 16a215463..d222c8b3f 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -67,22 +67,17 @@ class ForgotPasswordController extends Controller } } - // Check users exists and send an email + // Check whether the user exists, but always return the same success message + // to avoid account/API-key enumeration. $ret = ! empty($rssToken) ? User::findByRssToken($rssToken) : User::findByEmail($email); if ($ret === null) { - return redirect() - ->route('forgottenpassword') - ->withErrors(['error' => 'The email or apikey are not recognised.']) - ->withInput($request->except(CaptchaHelper::getResponseFieldName())); + return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!'); } // Check if user is soft deleted $user = User::withTrashed()->find($ret['id']); if ($user && $user->trashed()) { - return redirect() - ->route('forgottenpassword') - ->withErrors(['error' => 'This account has been deactivated.']) - ->withInput($request->except(CaptchaHelper::getResponseFieldName())); + return redirect()->route('forgottenpassword')->with('success', 'Password reset email has been sent!'); } // Generate a forgottenpassword guid, store it in the user table diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 3f56dcbe5..f38bb9f59 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Auth; use App\Events\UserLoggedIn; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginLoginRequest; +use App\Models\TrustedDevice; use App\Models\User; use App\Services\PasswordBreachService; use App\Support\Auth\AuthenticatesUsers; @@ -46,6 +47,8 @@ class LoginController extends Controller */ protected string $redirectTo = '/'; + private const string GENERIC_LOGIN_FAILURE = 'Username or email and password combination used does not match our records!'; + /** * Get the login username to be used (form field name; value may be email or username). */ @@ -88,110 +91,70 @@ class LoginController extends Controller } if ($validator->passes()) { - $user = User::query() - ->where(function ($query) use ($request) { - $query->where('username', $request->input('username')) - ->orWhere('email', $request->input('username')); - }) - ->withTrashed() - ->first(); - if ($user !== null) { - // Check if user is soft deleted - if ($user->trashed()) { - $request->session()->flash('error', 'This account has been deactivated. Please contact us through contact form to have your account reactivated.'); + $rememberMe = $request->has('rememberme') && $request->input('rememberme') === 'on'; - return redirect()->to('login'); - } + if (! Auth::attempt($request->only($login_type, 'password'), $rememberMe)) { + $this->incrementLoginAttempts($request); + Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); + $request->session()->flash('error', self::GENERIC_LOGIN_FAILURE); - $rememberMe = $request->has('rememberme') && $request->input('rememberme') === 'on'; + return redirect()->to('login'); + } - if (! $user->hasVerifiedEmail()) { - $request->session()->flash('warning', 'You have not verified your email address!'); + /** @var User $user */ + $user = Auth::user(); - return redirect()->to('login'); - } + if ($user->is_disabled || ! $user->hasVerifiedEmail()) { + Auth::logout(); + $this->incrementLoginAttempts($request); + Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); + $request->session()->flash('error', self::GENERIC_LOGIN_FAILURE); - if (Auth::attempt($request->only($login_type, 'password'), $rememberMe)) { - // Regenerate session ID to prevent session fixation attacks - // This ensures no session data from a previous user can leak - $request->session()->regenerate(); + return redirect()->to('login'); + } - $userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : ''; - event(new UserLoggedIn($user, $userIp)); + $request->session()->regenerate(); - // 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); + $userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : ''; + event(new UserLoggedIn($user, $userIp)); - // 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']) { + $passwordBreached = $this->isPasswordBreached((string) $request->input('password')); - // Cookie is valid - mark 2FA as passed - session([config('google2fa.session_var') => true]); - session([config('google2fa.session_var').'.auth.passed_at' => time()]); + if ($user->passwordSecurity && $user->passwordSecurity->google2fa_enable) { + if ($this->trustedDeviceCookieIsValid($request, $user)) { + 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->rotateSessionTokenForCurrentSession($request, $user); - $this->clearLoginAttempts($request); - - // Check for password breach - $redirect = redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); - - return $this->checkPasswordBreachAndRedirect($request->input('password'), $redirect); - } - } 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()); - - // Store rememberme preference in the session for 2FA flow - $request->session()->put('2fa:remember', $rememberMe); - - // Store password hash for breach check after 2FA (we hash it to avoid storing plain text) - $request->session()->put('2fa:password_check', $request->input('password')); - - Auth::logout(); - - // Store user ID in the session for 2FA verification - $request->session()->put('2fa:user:id', $user->id); - - return redirect()->route('2fa.verify'); - } - - Auth::logoutOtherDevices($request->input('password')); + Auth::logoutOtherDevices((string) $request->input('password')); $this->rotateSessionTokenForCurrentSession($request, $user); $this->clearLoginAttempts($request); - // Check for password breach - $redirect = redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'); - - return $this->checkPasswordBreachAndRedirect($request->input('password'), $redirect); + return $this->appendPasswordBreachWarning( + redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'), + $passwordBreached + ); } - $this->incrementLoginAttempts($request); - Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); - $request->session()->flash('error', 'Username or email and password combination used does not match our records!'); - } else { - $this->incrementLoginAttempts($request); - Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); - $request->session()->flash('error', 'Username or email used do not match our records!'); + Auth::logoutOtherDevices((string) $request->input('password')); + $request->session()->put('url.intended', $this->redirectPath()); + $request->session()->put('2fa:remember', $rememberMe); + $request->session()->put('2fa:password_breached', $passwordBreached); + + Auth::logout(); + $request->session()->put('2fa:user:id', $user->id); + + return redirect()->route('2fa.verify'); } - return redirect()->to('login'); + Auth::logoutOtherDevices((string) $request->input('password')); + $this->rotateSessionTokenForCurrentSession($request, $user); + $this->clearLoginAttempts($request); + + return $this->appendPasswordBreachWarning( + redirect()->intended($this->redirectPath())->with('info', 'You have been logged in'), + $passwordBreached + ); + } $this->incrementLoginAttempts($request); @@ -217,10 +180,8 @@ class LoginController extends Controller $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'])) { @@ -237,15 +198,14 @@ class LoginController extends Controller // 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 + '2fa_trusted_device', + $trustedDeviceCookie, + $remainingMinutes, + '/', config('session.domain'), // use session domain config config('session.secure'), // use session secure config - false, // httpOnly + true, false, // raw config('session.same_site', 'lax') // use session same_site config ); @@ -272,12 +232,17 @@ class LoginController extends Controller * Check if the password has been compromised in a data breach and add a warning if so. */ protected function checkPasswordBreachAndRedirect(string $password, RedirectResponse $redirect): RedirectResponse + { + return $this->appendPasswordBreachWarning($redirect, $this->isPasswordBreached($password)); + } + + protected function isPasswordBreached(string $password): bool { try { $breachService = app(PasswordBreachService::class); if ($breachService->isPasswordBreached($password)) { - return $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); + return true; } } catch (\Exception $e) { Log::error('Password breach check failed during login', [ @@ -285,9 +250,42 @@ class LoginController extends Controller ]); } + return false; + } + + protected function appendPasswordBreachWarning(RedirectResponse $redirect, bool $passwordBreached): RedirectResponse + { + if ($passwordBreached) { + return $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); + } + return $redirect; } + private function trustedDeviceCookieIsValid(Request $request, User $user): bool + { + $trustedCookie = $request->cookie('2fa_trusted_device'); + if (! is_string($trustedCookie) || $trustedCookie === '') { + return false; + } + + try { + $cookieData = json_decode($trustedCookie, true); + + return json_last_error() === JSON_ERROR_NONE + && isset($cookieData['user_id'], $cookieData['token'], $cookieData['expires_at']) + && (int) $cookieData['user_id'] === (int) $user->id + && time() <= (int) $cookieData['expires_at'] + && TrustedDevice::findValidForUser((int) $user->id, (string) $cookieData['token']) !== null; + } catch (\Exception $e) { + Log::error('Login - Error processing trusted device cookie', [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + private function rotateSessionTokenForCurrentSession(Request $request, User $user): void { $newSessionToken = Str::random(60); diff --git a/app/Http/Controllers/PasswordSecurityController.php b/app/Http/Controllers/PasswordSecurityController.php index 78eeaa06c..364413ed5 100644 --- a/app/Http/Controllers/PasswordSecurityController.php +++ b/app/Http/Controllers/PasswordSecurityController.php @@ -6,8 +6,8 @@ namespace App\Http\Controllers; use App\Http\Requests\Disable2faPasswordSecurityRequest; use App\Models\PasswordSecurity; +use App\Models\TrustedDevice; use App\Models\User; -use App\Services\PasswordBreachService; use Illuminate\Auth\Events\OtherDeviceLogout; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -196,12 +196,7 @@ 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()]); - // Clean up the temporary session variables - $passwordToCheck = $request->session()->get('2fa:password_check'); - - if (is_string($passwordToCheck) && $passwordToCheck !== '') { - Auth::logoutOtherDevices($passwordToCheck); - } + $passwordBreached = (bool) $request->session()->get('2fa:password_breached', false); $newSessionToken = Str::random(60); $user->forceFill([ @@ -210,7 +205,7 @@ class PasswordSecurityController extends Controller $request->session()->put('session_token_web', $newSessionToken); event(new OtherDeviceLogout(Auth::getDefaultDriver(), $user)); - $request->session()->forget(['2fa:user:id', '2fa:remember', '2fa:password_check']); + $request->session()->forget(['2fa:user:id', '2fa:remember', '2fa:password_breached']); // Determine where to redirect after successful verification $redirectUrl = $request->session()->pull('url.intended', '/'); @@ -219,56 +214,35 @@ class PasswordSecurityController extends Controller $redirect = redirect()->to($redirectUrl) ->with('success', 'Two-factor authentication verified successfully.'); - // Check for password breach if we have the password stored - if ($passwordToCheck) { - try { - $breachService = app(PasswordBreachService::class); - if ($breachService->isPasswordBreached($passwordToCheck)) { - $redirect = $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); - } - } catch (\Exception $e) { - Log::error('Password breach check failed during 2FA verification', [ - 'error' => $e->getMessage(), - ]); - } + if ($passwordBreached) { + $redirect = $redirect->with('warning', 'Security Alert: Your password has been found in a data breach. We strongly recommend changing it immediately in your account settings.'); } // If the user has checked "trust this device", create a trust token if ($request->has('trust_device') && $request->input('trust_device') == 1) { + $trustedDevice = TrustedDevice::issueForUser($user, $request->ip(), $request->userAgent()); + $expiresAt = $trustedDevice['device']->expires_at->getTimestamp(); - // 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, + 'token' => $trustedDevice['plain'], '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) - ); + $cookieValue = json_encode($cookieData, JSON_UNESCAPED_SLASHES); + if ($cookieValue !== false) { + $redirect->withCookie(cookie( + '2fa_trusted_device', + $cookieValue, + 60 * 24 * 30, + '/', + config('session.domain'), + config('session.secure'), + true, + false, + config('session.same_site', 'lax') + )); + } } return $redirect; diff --git a/app/Http/Controllers/RssController.php b/app/Http/Controllers/RssController.php index b26f6ef4b..7c7bccee3 100644 --- a/app/Http/Controllers/RssController.php +++ b/app/Http/Controllers/RssController.php @@ -12,7 +12,7 @@ use App\Models\UserDownload; use App\Models\UserRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; +use Illuminate\Http\Response; use Illuminate\Support\Carbon; class RssController extends BasePageController @@ -26,8 +26,7 @@ class RssController extends BasePageController } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function myMoviesRss(Request $request) @@ -41,12 +40,12 @@ class RssController extends BasePageController $userNum = $request->has('num') && is_numeric($request->input('num')) ? abs((int) $request->input('num')) : 0; $relData = $this->rss->getMyMoviesRss($userNum, $user['user_id'], User::getCategoryExclusionById($user['user_id'])); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function myShowsRss(Request $request) @@ -61,12 +60,12 @@ class RssController extends BasePageController $outputXML = ! ($request->has('o') && $request->input('o') === 'json'); $relData = $this->rss->getShowsRss($userNum, $user['user_id'], User::getCategoryExclusionById($user['user_id']), $userAirDate); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function fullFeedRss(Request $request) @@ -78,8 +77,8 @@ class RssController extends BasePageController [$userShow, $userAnidb, $userAirDate, $userNum, $userLimit, $outputXML] = $this->parseCommonRssParams($request); - $relData = $this->rss->getRss(Arr::wrap(0), $userShow, $userAnidb, $user['user_id'], $userAirDate, $userLimit, $userNum); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + $relData = $this->rss->getRss([0], $userShow, $userAnidb, $user['user_id'], $userAirDate, $userLimit, $userNum); + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** @@ -110,8 +109,7 @@ class RssController extends BasePageController } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function cartRss(Request $request) @@ -126,7 +124,8 @@ class RssController extends BasePageController [$userShow, $userAnidb, $userAirDate, $userNum, $userLimit, $outputXML] = $this->parseCommonRssParams($request); $relData = $this->rss->getRss([-2], $userShow, $userAnidb, $user['user_id'], $userAirDate, $userLimit, $userNum); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss', $this->cartRssNoCacheHeaders()); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss', $this->cartRssNoCacheHeaders()); } /** @@ -146,8 +145,7 @@ class RssController extends BasePageController } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function categoryFeedRss(Request $request) @@ -165,12 +163,12 @@ class RssController extends BasePageController [$userShow, $userAnidb, $userAirDate, $userNum, $userLimit, $outputXML] = $this->parseCommonRssParams($request); $relData = $this->rss->getRss($categoryId, $userShow, $userAnidb, $user['user_id'], $userAirDate, $userLimit, $userNum); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function trendingMoviesRss(Request $request) @@ -182,12 +180,12 @@ class RssController extends BasePageController $outputXML = ! ($request->has('o') && $request->input('o') === 'json'); $relData = $this->rss->getTrendingMoviesRss(); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** - * @return JsonResponse|void - * + * @return JsonResponse|Response * @throws \Throwable */ public function trendingShowsRss(Request $request) @@ -199,7 +197,8 @@ class RssController extends BasePageController $outputXML = ! ($request->has('o') && $request->input('o') === 'json'); $relData = $this->rss->getTrendingShowsRss(); - $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); + + return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss'); } /** diff --git a/app/Http/Middleware/TrustedDevice2FAMiddleware.php b/app/Http/Middleware/TrustedDevice2FAMiddleware.php index cdb2dcc68..a4c092bf7 100644 --- a/app/Http/Middleware/TrustedDevice2FAMiddleware.php +++ b/app/Http/Middleware/TrustedDevice2FAMiddleware.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Models\TrustedDevice; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -15,20 +16,18 @@ class TrustedDevice2FAMiddleware */ 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']) { + time() <= (int) $cookieData['expires_at'] && + TrustedDevice::findValidForUser((int) auth()->id(), (string) $cookieData['token']) !== null) { - // Mark this user's session as having passed 2FA session([config('google2fa.session_var') => true]); session([config('google2fa.session_var').'.auth.passed_at' => time()]); } @@ -39,46 +38,26 @@ class TrustedDevice2FAMiddleware } } - // 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); + if ($cookieValue !== false) { + $response->headers->setCookie(cookie( + '2fa_trusted_device', + $cookieValue, + 60 * 24 * 30, + '/', + config('session.domain'), + config('session.secure'), + true, + false, + config('session.same_site', 'lax') + )); + } } catch (\Exception $e) { Log::error('TrustedDevice2FAMiddleware - Error setting cookie', [ 'error' => $e->getMessage(), diff --git a/app/Models/TrustedDevice.php b/app/Models/TrustedDevice.php new file mode 100644 index 000000000..d0203522d --- /dev/null +++ b/app/Models/TrustedDevice.php @@ -0,0 +1,103 @@ + 'datetime', + 'last_used_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return array{plain: string, device: self} + */ + public static function issueForUser(User $user, ?string $ipAddress = null, ?string $userAgent = null): array + { + $plainToken = Str::random(64); + $now = now(); + + DB::insert( + 'insert into trusted_devices (user_id, token_hash, expires_at, ip_address, user_agent, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)', + [ + $user->id, + self::hashToken($plainToken), + $now->copy()->addDays(30)->toDateTimeString(), + $ipAddress, + $userAgent !== null ? Str::limit($userAgent, 500, '') : null, + $now->toDateTimeString(), + $now->toDateTimeString(), + ] + ); + + $deviceId = (int) DB::getPdo()->lastInsertId(); + + /** @var self $device */ + $device = self::query()->findOrFail($deviceId); + + return ['plain' => $plainToken, 'device' => $device]; + } + + public static function findValidForUser(int $userId, string $plainToken): ?self + { + if ($plainToken === '') { + return null; + } + + /** @var self|null $device */ + $device = self::query() + ->where('user_id', $userId) + ->where('token_hash', self::hashToken($plainToken)) + ->where('expires_at', '>', now()) + ->first(); + + if ($device !== null) { + $device->forceFill(['last_used_at' => now()])->save(); + } + + return $device; + } + + public static function hashToken(string $plainToken): string + { + return hash_hmac('sha256', $plainToken, (string) config('app.key')); + } +} + diff --git a/app/Observers/UserApiCacheObserver.php b/app/Observers/UserApiCacheObserver.php new file mode 100644 index 000000000..1ff432285 --- /dev/null +++ b/app/Observers/UserApiCacheObserver.php @@ -0,0 +1,50 @@ +wasChanged(['api_token', 'roles_id', 'rate_limit', 'verified', 'email_verified_at', 'deleted_at'])) { + return; + } + + $this->flushTokenCaches($user, $user->getOriginal('api_token')); + $this->flushTokenCaches($user, $user->api_token); + } + + public function deleted(User $user): void + { + $this->flushTokenCaches($user, $user->api_token); + } + + public function restored(User $user): void + { + $this->flushTokenCaches($user, $user->api_token); + } + + public function forceDeleted(User $user): void + { + $this->flushTokenCaches($user, $user->api_token); + } + + private function flushTokenCaches(User $user, mixed $apiToken): void + { + if (! is_string($apiToken) || $apiToken === '') { + return; + } + + $tokenHash = md5($apiToken); + + Cache::forget('api_user:'.$tokenHash); + Cache::forget('api_rate_limit_user:'.$tokenHash); + Cache::forget('api_user_stats:'.$user->id); + } +} + diff --git a/app/Providers/UserServiceProvider.php b/app/Providers/UserServiceProvider.php index 84983695e..0df5bc950 100644 --- a/app/Providers/UserServiceProvider.php +++ b/app/Providers/UserServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Providers; use App\Models\User; +use App\Observers\UserApiCacheObserver; use App\Observers\UserActivityObserver; use App\Observers\UserServiceObserver; use Illuminate\Support\ServiceProvider; @@ -26,5 +27,6 @@ class UserServiceProvider extends ServiceProvider { User::observe(UserServiceObserver::class); User::observe(UserActivityObserver::class); + User::observe(UserApiCacheObserver::class); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 86e70538c..6a95e2684 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -53,11 +53,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'failed', 'admin/*', 'btcpay/webhook', - 'logout', - 'register', 'contact-us', - 'forgottenpassword', - 'login', 'cart', 'cart/*', ]); diff --git a/database/migrations/2026_06_10_000000_create_trusted_devices_table.php b/database/migrations/2026_06_10_000000_create_trusted_devices_table.php new file mode 100644 index 000000000..15174df84 --- /dev/null +++ b/database/migrations/2026_06_10_000000_create_trusted_devices_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expires_at')->index(); + $table->timestamp('last_used_at')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('trusted_devices'); + } +}; + diff --git a/routes/rss.php b/routes/rss.php index 27ff3ab6d..57688ae47 100644 --- a/routes/rss.php +++ b/routes/rss.php @@ -13,17 +13,19 @@ use App\Http\Controllers\RssController; -Route::get('mymovies', [RssController::class, 'myMoviesRss']); -Route::post('mymovies', [RssController::class, 'myMoviesRss']); -Route::get('myshows', [RssController::class, 'myShowsRss']); -Route::post('myshows', [RssController::class, 'myShowsRss']); -Route::get('full-feed', [RssController::class, 'fullFeedRss']); -Route::post('full-feed', [RssController::class, 'fullFeedRss']); -Route::get('cart', [RssController::class, 'cartRss']); -Route::post('cart', [RssController::class, 'cartRss']); -Route::get('category', [RssController::class, 'categoryFeedRss']); -Route::post('category', [RssController::class, 'categoryFeedRss']); -Route::get('trending-movies', [RssController::class, 'trendingMoviesRss']); -Route::post('trending-movies', [RssController::class, 'trendingMoviesRss']); -Route::get('trending-shows', [RssController::class, 'trendingShowsRss']); -Route::post('trending-shows', [RssController::class, 'trendingShowsRss']); +Route::middleware('apiRateLimit')->group(function (): void { + Route::get('mymovies', [RssController::class, 'myMoviesRss']); + Route::post('mymovies', [RssController::class, 'myMoviesRss']); + Route::get('myshows', [RssController::class, 'myShowsRss']); + Route::post('myshows', [RssController::class, 'myShowsRss']); + Route::get('full-feed', [RssController::class, 'fullFeedRss']); + Route::post('full-feed', [RssController::class, 'fullFeedRss']); + Route::get('cart', [RssController::class, 'cartRss']); + Route::post('cart', [RssController::class, 'cartRss']); + Route::get('category', [RssController::class, 'categoryFeedRss']); + Route::post('category', [RssController::class, 'categoryFeedRss']); + Route::get('trending-movies', [RssController::class, 'trendingMoviesRss']); + Route::post('trending-movies', [RssController::class, 'trendingMoviesRss']); + Route::get('trending-shows', [RssController::class, 'trendingShowsRss']); + Route::post('trending-shows', [RssController::class, 'trendingShowsRss']); +}); diff --git a/routes/web.php b/routes/web.php index e52b747c1..de28c2162 100644 --- a/routes/web.php +++ b/routes/web.php @@ -103,14 +103,16 @@ Route::get('email/verify', [EmailVerificationController::class, 'show'])->middle Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware(['auth', 'throttle:6,1'])->name('verification.send'); Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); -Route::match(['GET', 'POST'], 'forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth']); -Route::match(['GET', 'POST'], 'password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']); +Route::get('forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth']); +Route::post('forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']); +Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request')->withoutMiddleware(['auth']); +Route::post('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->middleware('throttle:6,1')->withoutMiddleware(['auth']); Route::match(['GET', 'POST'], 'terms-and-conditions', [TermsController::class, 'terms'])->name('terms-and-conditions'); Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class, 'privacyPolicy'])->name('privacy-policy'); Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); Route::post('login', [LoginController::class, 'login'])->name('login.post'); -Route::match(['GET', 'POST'], 'logout', [LoginController::class, 'logout'])->name('logout'); +Route::post('logout', [LoginController::class, 'logout'])->name('logout'); Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class) ->name('passkeys.authentication_options'); Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login'); diff --git a/tests/Feature/Auth/PasskeyAuthenticationTest.php b/tests/Feature/Auth/PasskeyAuthenticationTest.php index 1233bc7e8..c54c08331 100644 --- a/tests/Feature/Auth/PasskeyAuthenticationTest.php +++ b/tests/Feature/Auth/PasskeyAuthenticationTest.php @@ -8,6 +8,7 @@ use App\Events\UserLoggedIn; use App\Http\Middleware\Google2FAMiddleware; use App\Models\User; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; @@ -65,6 +66,7 @@ class PasskeyAuthenticationTest extends TestCase ]); $response->assertRedirect('/'); + $response->assertCookieMissing(Auth::guard()->getRecallerName()); $this->assertAuthenticatedAs($user); $this->assertTrue((bool) session(config('google2fa.session_var'))); Event::assertDispatched(UserLoggedIn::class); @@ -88,14 +90,15 @@ class PasskeyAuthenticationTest extends TestCase FakeFindPasskeyAction::$passkey = $passkey; config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class); - $this + $response = $this ->withSession(['passkey-authentication-options' => '{}']) ->post(route('passkeys.login'), [ 'start_authentication_response' => json_encode(['id' => 'credential-3'], JSON_THROW_ON_ERROR), 'remember' => true, - ]) - ->assertRedirect('/'); + ]); + $response->assertRedirect('/'); + $response->assertCookie(Auth::guard()->getRecallerName()); $this->assertAuthenticatedAs($user); $this->assertNotNull($user->fresh()?->remember_token); } diff --git a/tests/Feature/Auth/RememberMeAuthenticationTest.php b/tests/Feature/Auth/RememberMeAuthenticationTest.php new file mode 100644 index 000000000..0d2b4fa4a --- /dev/null +++ b/tests/Feature/Auth/RememberMeAuthenticationTest.php @@ -0,0 +1,261 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + 'session.driver' => 'array', + 'google2fa.session_var' => 'google2fa', + ]); + + DB::purge(); + DB::reconnect(); + + $this->createSchema(); + $this->seedSettings(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $this->app->instance(PasswordBreachService::class, new class extends PasswordBreachService + { + public function isPasswordBreached(string $password): bool + { + return false; + } + }); + } + + public function test_password_login_with_remember_me_queues_recaller_cookie(): void + { + Event::fake([UserLoggedIn::class]); + $user = $this->createUser('remember-password@example.test'); + + $response = $this->post(route('login'), [ + 'username' => $user->email, + 'password' => 'password', + 'rememberme' => 'on', + ]); + + $response->assertRedirect('/'); + $response->assertCookie($this->recallerCookieName()); + $this->assertAuthenticatedAs($user); + } + + public function test_password_login_without_remember_me_does_not_queue_recaller_cookie(): void + { + Event::fake([UserLoggedIn::class]); + $user = $this->createUser('session-password@example.test'); + + $response = $this->post(route('login'), [ + 'username' => $user->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/'); + $response->assertCookieMissing($this->recallerCookieName()); + $this->assertAuthenticatedAs($user); + } + + public function test_two_factor_login_preserves_remember_me_until_otp_success(): void + { + Event::fake([UserLoggedIn::class]); + $user = $this->createUser('remember-2fa@example.test'); + $secret = Google2FA::generateSecretKey(); + PasswordSecurity::query()->create([ + 'user_id' => $user->id, + 'google2fa_enable' => 1, + 'google2fa_secret' => $secret, + ]); + + $loginResponse = $this->post(route('login'), [ + 'username' => $user->email, + 'password' => 'password', + 'rememberme' => 'on', + ]); + + $loginResponse->assertRedirect(route('2fa.verify')); + $loginResponse->assertCookieMissing($this->recallerCookieName()); + $this->assertGuest(); + $this->assertTrue((bool) session('2fa:remember')); + $this->assertSame($user->id, session('2fa:user:id')); + + $verifyResponse = $this->post(route('2fa.post'), [ + 'one_time_password' => Google2FA::getCurrentOtp($secret), + ]); + + $verifyResponse->assertRedirect('/'); + $verifyResponse->assertCookie($this->recallerCookieName()); + $this->assertAuthenticatedAs($user); + $this->assertTrue((bool) session(config('google2fa.session_var'))); + $this->assertNull(session('2fa:remember')); + } + + public function test_two_factor_login_without_remember_me_does_not_queue_recaller_cookie_after_otp_success(): void + { + Event::fake([UserLoggedIn::class]); + $user = $this->createUser('session-2fa@example.test'); + $secret = Google2FA::generateSecretKey(); + PasswordSecurity::query()->create([ + 'user_id' => $user->id, + 'google2fa_enable' => 1, + 'google2fa_secret' => $secret, + ]); + + $this->post(route('login'), [ + 'username' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('2fa.verify')); + + $verifyResponse = $this->post(route('2fa.post'), [ + 'one_time_password' => Google2FA::getCurrentOtp($secret), + ]); + + $verifyResponse->assertRedirect('/'); + $verifyResponse->assertCookieMissing($this->recallerCookieName()); + $this->assertAuthenticatedAs($user); + } + + private function recallerCookieName(): string + { + return Auth::guard()->getRecallerName(); + } + + protected function createSchema(): void + { + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->integer('rate_limit')->default(60); + $table->boolean('isdefault')->default(false); + $table->unsignedInteger('defaultinvites')->default(0); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->integer('rate_limit')->default(60); + $table->string('api_token')->nullable(); + $table->boolean('verified')->default(true); + $table->boolean('can_post')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('lastlogin')->nullable(); + $table->string('host')->nullable(); + $table->rememberToken(); + $table->string('session_token', 60)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('password_securities', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('user_id'); + $table->boolean('google2fa_enable')->default(false); + $table->string('google2fa_secret')->nullable(); + $table->timestamps(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('user_activities', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('username'); + $table->string('activity_type', 50); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + protected function seedSettings(): void + { + DB::table('settings')->insert([ + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ['name' => 'categorizeforeign', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ]); + } + + protected function createUser(string $email): User + { + $role = Role::query()->firstOrCreate( + ['name' => 'User', 'guard_name' => 'web'], + ['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1] + ); + + $user = User::query()->create([ + 'username' => 'user_'.md5($email), + 'email' => $email, + 'password' => bcrypt('password'), + 'roles_id' => $role->id, + 'rate_limit' => 60, + 'api_token' => md5($email), + 'verified' => true, + 'email_verified_at' => now(), + 'lastlogin' => now(), + ]); + + $user->assignRole($role); + + return $user->fresh(); + } +} + diff --git a/tests/Feature/NzbAndRssAccessTest.php b/tests/Feature/NzbAndRssAccessTest.php index 7a44b731d..71ad88671 100644 --- a/tests/Feature/NzbAndRssAccessTest.php +++ b/tests/Feature/NzbAndRssAccessTest.php @@ -4,10 +4,15 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Http\Middleware\TrustedDevice2FAMiddleware; +use App\Http\Controllers\Api\RSS; +use App\Models\TrustedDevice; use App\Models\User; use App\View\Composers\GlobalDataComposer; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Http\Request; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Route; @@ -269,6 +274,47 @@ class NzbAndRssAccessTest extends TestCase $response->assertDontSee('Login', false); } + public function test_authenticated_rss_feed_returns_output_response_body(): void + { + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'User', + 'guard_name' => 'web', + 'apirequests' => 100, + 'downloadrequests' => 10, + ]); + + DB::table('users')->insert([ + 'username' => 'valid-rss-user', + 'email' => 'valid-rss@example.test', + 'password' => 'secret', + 'roles_id' => 1, + 'api_token' => 'valid-rss-token', + 'rate_limit' => 60, + 'verified' => 1, + 'email_verified_at' => now(), + ]); + + $this->app->instance(RSS::class, new class extends RSS + { + public function __construct() {} + + public function getRss(mixed $cat, mixed $videosId, mixed $aniDbID, int $userID = 0, int $airDate = -1, int $limit = 100, int $offset = 0) + { + return []; + } + + public function output(mixed $data, array $params, bool $xml, int $offset, string $type = '', array $headers = []) + { + return response('<rss>ok</rss>', 200, array_merge(['Content-type' => 'text/xml'], $headers)); + } + }); + + $this->get('/rss/full-feed?api_token=valid-rss-token') + ->assertOk() + ->assertSee('<rss>ok</rss>', false); + } + public function test_contact_form_is_publicly_accessible_to_guests(): void { $response = $this->get('/contact-us'); @@ -348,6 +394,101 @@ class NzbAndRssAccessTest extends TestCase ->assertJsonPath('error', 'Request limit reached'); } + public function test_api_v2_enforces_daily_role_request_quota(): void + { + DB::table('roles')->insert([ + 'id' => 10, + 'name' => 'Limited', + 'guard_name' => 'web', + 'apirequests' => 1, + 'downloadrequests' => 10, + ]); + + $userId = DB::table('users')->insertGetId([ + 'username' => 'quota-v2-user', + 'email' => 'quota-v2@example.test', + 'password' => 'secret', + 'roles_id' => 10, + 'api_token' => 'quota-v2-token', + 'rate_limit' => 60, + 'verified' => 1, + 'email_verified_at' => now(), + ]); + + DB::table('user_requests')->insert([ + ['users_id' => $userId, 'request' => '/api/v2/search?api_token=quota-v2-token&id=one', 'timestamp' => now()->subMinutes(10)], + ['users_id' => $userId, 'request' => '/api/v2/search?api_token=quota-v2-token&id=two', 'timestamp' => now()->subMinutes(5)], + ]); + + $this->getJson('/api/v2/search?api_token=quota-v2-token&id=test') + ->assertStatus(429) + ->assertHeader('X-NNTmux', 'API ERROR [500] Request limit reached') + ->assertJsonPath('error', 'Request limit reached'); + } + + public function test_forged_trusted_device_cookie_does_not_pass_2fa_without_stored_token(): void + { + config(['google2fa.session_var' => 'google2fa']); + + $userId = DB::table('users')->insertGetId([ + 'username' => 'forged-2fa-cookie-user', + 'email' => 'forged-2fa-cookie@example.test', + 'password' => 'secret', + 'api_token' => 'forged-2fa-cookie-token', + 'verified' => 1, + 'email_verified_at' => now(), + ]); + $user = User::query()->findOrFail($userId); + Auth::login($user); + + $forgedCookie = json_encode([ + 'user_id' => $user->id, + 'token' => 'client-forged-token-only', + 'expires_at' => time() + 3600, + ], JSON_THROW_ON_ERROR); + + $request = Request::create('/trusted-device-check', 'GET', [], [ + '2fa_trusted_device' => $forgedCookie, + ]); + $request->setLaravelSession(app('session.store')); + + (new TrustedDevice2FAMiddleware())->handle($request, fn () => response('ok')); + + $this->assertFalse((bool) $request->session()->get('google2fa', false)); + } + + public function test_stored_trusted_device_cookie_passes_2fa(): void + { + config(['google2fa.session_var' => 'google2fa']); + + $userId = DB::table('users')->insertGetId([ + 'username' => 'stored-2fa-cookie-user', + 'email' => 'stored-2fa-cookie@example.test', + 'password' => 'secret', + 'api_token' => 'stored-2fa-cookie-token', + 'verified' => 1, + 'email_verified_at' => now(), + ]); + $user = User::query()->findOrFail($userId); + Auth::login($user); + + $trustedDevice = TrustedDevice::issueForUser($user, '127.0.0.1', 'Feature Test'); + $cookieValue = json_encode([ + 'user_id' => $user->id, + 'token' => $trustedDevice['plain'], + 'expires_at' => $trustedDevice['device']->expires_at->getTimestamp(), + ], JSON_THROW_ON_ERROR); + + $request = Request::create('/trusted-device-check', 'GET', [], [ + '2fa_trusted_device' => $cookieValue, + ]); + $request->setLaravelSession(app('session.store')); + + (new TrustedDevice2FAMiddleware())->handle($request, fn () => response('ok')); + + $this->assertTrue((bool) $request->session()->get('google2fa', false)); + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { @@ -390,11 +531,64 @@ class NzbAndRssAccessTest extends TestCase $table->string('api_token')->nullable()->index(); $table->integer('rate_limit')->default(60); $table->boolean('verified')->default(true); + $table->timestamp('apiaccess')->nullable(); + $table->string('host')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); } + + if (! Schema::hasTable('roles')) { + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->integer('apirequests')->default(0); + $table->integer('downloadrequests')->default(0); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('model_has_roles')) { + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + + $table->primary(['role_id', 'model_id', 'model_type']); + }); + } + + if (! Schema::hasTable('user_requests')) { + Schema::create('user_requests', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->text('request')->nullable(); + $table->timestamp('timestamp')->nullable(); + }); + } + + if (! Schema::hasTable('user_downloads')) { + Schema::create('user_downloads', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->timestamp('timestamp')->nullable(); + }); + } + + if (! Schema::hasTable('trusted_devices')) { + Schema::create('trusted_devices', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('user_id'); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expires_at'); + $table->timestamp('last_used_at')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->timestamps(); + }); + } } private function registerTestRoutes(): void