Fix couple of security issues

This commit is contained in:
DariusIII
2026-06-11 10:26:29 +02:00
parent 3dfbd8442f
commit 832fb5fb59
17 changed files with 875 additions and 289 deletions
+39 -51
View File
@@ -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<string, mixed> $params
* @param array<string, string> $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));
}
/**
+9 -1
View File
@@ -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);
@@ -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
+94 -96
View File
@@ -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);
@@ -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;
+22 -23
View File
@@ -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');
}
/**
@@ -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(),
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* @property int $id
* @property int $user_id
* @property string $token_hash
* @property Carbon $expires_at
* @property Carbon|null $last_used_at
* @property string|null $ip_address
* @property string|null $user_agent
*/
class TrustedDevice extends Model
{
protected $fillable = [
'user_id',
'token_hash',
'expires_at',
'last_used_at',
'ip_address',
'user_agent',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
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'));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class UserApiCacheObserver
{
public function updated(User $user): void
{
if (! $user->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);
}
}
+2
View File
@@ -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);
}
}