mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Fix couple of security issues
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user