mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update verification handling
This commit is contained in:
@@ -113,8 +113,7 @@ class ApiController extends BasePageController
|
||||
// Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2)
|
||||
$userCacheKey = 'api_user:'.md5((string) $apiKey);
|
||||
$res = Cache::remember($userCacheKey, 300, function () use ($apiKey) {
|
||||
return User::query()
|
||||
->where('api_token', $apiKey)
|
||||
return User::verifiedApiTokenQuery($apiKey)
|
||||
->with('role')
|
||||
->first();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ class ApiInformController extends Controller
|
||||
$releaseObName = $request->has('relo') && ! empty($request->input('relo')) ? $request->input('relo') : '';
|
||||
$releasePrName = $request->has('relp') && ! empty($request->input('relp')) ? $request->input('relp') : '';
|
||||
$apiToken = $request->has('api_token') && ! empty($request->input('api_token')) ? $request->input('api_token') : '';
|
||||
$user = User::query()->where('api_token', $request->input('api_token'))->first();
|
||||
$user = User::findVerifiedByApiToken((string) $request->input('api_token'));
|
||||
if (! $user) {
|
||||
return response()->json(['message' => 'Indexer inform error, wrong api key!'], 404);
|
||||
}
|
||||
|
||||
@@ -57,8 +57,7 @@ class ApiV2Controller extends BasePageController
|
||||
$userCacheKey = 'api_user:'.md5((string) $apiToken);
|
||||
|
||||
return Cache::remember($userCacheKey, 300, function () use ($apiToken) {
|
||||
return User::query()
|
||||
->where('api_token', $apiToken)
|
||||
return User::verifiedApiTokenQuery((string) $apiToken)
|
||||
->with('role')
|
||||
->first();
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ class FailedReleasesController extends BasePageController
|
||||
return response('Bad request, please supply all parameters!', 400)->withHeaders(['X-DNZB-RCode' => 400, 'X-DNZB-RText' => 'Bad request, please supply all parameters!']);
|
||||
}
|
||||
|
||||
$res = User::findByRssToken($request->input('api_token'));
|
||||
$res = User::findVerifiedByApiToken((string) $request->input('api_token'));
|
||||
if ($res === null) {
|
||||
return response('Unauthorised, wrong rss key!', 401)->withHeaders(['X-DNZB-RCode' => 401, 'X-DNZB-RText' => 'Unauthorised, wrong rss key!']);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class GetNzbController extends BasePageController
|
||||
return showApiError(200);
|
||||
}
|
||||
|
||||
$user = User::findByRssToken($request->input('r'));
|
||||
$user = User::findVerifiedByApiToken((string) $request->input('r'));
|
||||
if (! $user) {
|
||||
return showApiError(100);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ class RssController extends BasePageController
|
||||
return response()->json(['error' => 'API key is required for viewing the RSS!'], 403);
|
||||
}
|
||||
|
||||
$res = User::findByRssToken($request->input('api_token'));
|
||||
$res = User::findVerifiedByApiToken((string) $request->input('api_token'));
|
||||
|
||||
if ($res === null) {
|
||||
return response()->json(['error' => 'Invalid RSS token'], 403);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAuthenticatedUsersAreVerified
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
|
||||
if ($request->expectsJson()) {
|
||||
abort(403, 'Your email address is not verified.');
|
||||
}
|
||||
|
||||
return redirect()->route($redirectToRoute ?? 'verification.notice');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThrottleApiRequestsByToken
|
||||
{
|
||||
private const int DEFAULT_RATE_LIMIT = 60;
|
||||
|
||||
private const int DECAY_SECONDS = 60;
|
||||
|
||||
public function __construct(private readonly RateLimiter $limiter) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $this->resolveUser($request);
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$maxAttempts = max(1, (int) ($user->rate_limit ?: self::DEFAULT_RATE_LIMIT));
|
||||
$rateLimitKey = $this->rateLimitKey($user->id);
|
||||
|
||||
if ($this->limiter->tooManyAttempts($rateLimitKey, $maxAttempts)) {
|
||||
return $this->buildTooManyRequestsResponse($rateLimitKey, $maxAttempts);
|
||||
}
|
||||
|
||||
$this->limiter->hit($rateLimitKey, self::DECAY_SECONDS);
|
||||
|
||||
$response = $next($request);
|
||||
$remainingAttempts = max(0, $maxAttempts - $this->limiter->attempts($rateLimitKey));
|
||||
|
||||
$response->headers->set('X-RateLimit-Limit', (string) $maxAttempts);
|
||||
$response->headers->set('X-RateLimit-Remaining', (string) $remainingAttempts);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function resolveUser(Request $request): ?User
|
||||
{
|
||||
$apiToken = $request->input('api_token');
|
||||
|
||||
if (! is_string($apiToken) || $apiToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Cache::remember('api_rate_limit_user:'.md5($apiToken), 300, static function () use ($apiToken) {
|
||||
return User::verifiedApiTokenQuery($apiToken)
|
||||
->select(['id', 'api_token', 'rate_limit'])
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
private function rateLimitKey(int $userId): string
|
||||
{
|
||||
return 'api-rate-limit:user:'.$userId;
|
||||
}
|
||||
|
||||
private function buildTooManyRequestsResponse(string $rateLimitKey, int $maxAttempts): JsonResponse
|
||||
{
|
||||
$retryAfter = max(1, $this->limiter->availableIn($rateLimitKey));
|
||||
|
||||
return response()->json([
|
||||
'error' => 'API rate limit exceeded.',
|
||||
'retry_after' => $retryAfter,
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
'X-RateLimit-Limit' => (string) $maxAttempts,
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -754,6 +754,24 @@ final class User extends Authenticatable implements MustVerifyEmailContract
|
||||
return static::whereApiToken($token)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a query for a verified user resolved by API/RSS token.
|
||||
*/
|
||||
public static function verifiedApiTokenQuery(string $token): Builder // @phpstan-ignore missingType.generics
|
||||
{
|
||||
return static::query()
|
||||
->verified()
|
||||
->whereApiToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a verified user by API/RSS token.
|
||||
*/
|
||||
public static function findVerifiedByApiToken(string $token): ?static
|
||||
{
|
||||
return static::verifiedApiTokenQuery($token)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by password reset GUID.
|
||||
*/
|
||||
@@ -1665,7 +1683,9 @@ final class User extends Authenticatable implements MustVerifyEmailContract
|
||||
public static function getCategoryExclusionForApi(Request $request): array
|
||||
{
|
||||
$apiToken = $request->input('api_token') ?? $request->input('apikey');
|
||||
$user = static::findByRssToken($apiToken);
|
||||
$user = is_string($apiToken) && $apiToken !== ''
|
||||
? static::findVerifiedByApiToken($apiToken)
|
||||
: null;
|
||||
|
||||
return $user ? static::getCategoryExclusionById($user->id) : [];
|
||||
}
|
||||
|
||||
+4
-2
@@ -3,14 +3,15 @@
|
||||
use App\Http\Middleware\BlockAbusiveServices;
|
||||
use App\Http\Middleware\ClearanceMiddleware;
|
||||
use App\Http\Middleware\ContentSecurityPolicy;
|
||||
use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified;
|
||||
use App\Http\Middleware\ForceJsonOnAPI;
|
||||
use App\Http\Middleware\Google2FAMiddleware;
|
||||
use App\Http\Middleware\NoCacheForAuthenticatedUsers;
|
||||
use App\Http\Middleware\SetUserTimezone;
|
||||
use App\Http\Middleware\ThrottleApiRequestsByToken;
|
||||
use App\Http\Middleware\TrustedDevice2FAMiddleware;
|
||||
use App\Http\Middleware\TrustProxies as AppTrustProxies;
|
||||
use Creativeorange\Gravatar\GravatarServiceProvider;
|
||||
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -84,7 +85,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'2fa' => Google2FAMiddleware::class,
|
||||
'bindings' => SubstituteBindings::class,
|
||||
'clearance' => ClearanceMiddleware::class,
|
||||
'isVerified' => EnsureEmailIsVerified::class,
|
||||
'apiRateLimit' => ThrottleApiRequestsByToken::class,
|
||||
'isVerified' => EnsureAuthenticatedUsersAreVerified::class,
|
||||
'permission' => PermissionMiddleware::class,
|
||||
'role' => RoleMiddleware::class,
|
||||
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ Route::prefix('v2')->group(function () {
|
||||
Route::get('capabilities', [ApiV2Controller::class, 'capabilities']);
|
||||
});
|
||||
|
||||
Route::prefix('v2')->middleware(['auth:api', 'throttle:rate_limit,1'])->group(function () {
|
||||
Route::prefix('v2')->middleware('apiRateLimit')->group(function () {
|
||||
Route::get('movies', [ApiV2Controller::class, 'movie']);
|
||||
Route::get('search', [ApiV2Controller::class, 'apiSearch']);
|
||||
Route::get('tv', [ApiV2Controller::class, 'tv']);
|
||||
@@ -32,7 +32,7 @@ Route::prefix('v2')->middleware(['auth:api', 'throttle:rate_limit,1'])->group(fu
|
||||
Route::get('details', [ApiV2Controller::class, 'details']);
|
||||
});
|
||||
|
||||
Route::prefix('inform')->middleware('auth:api')->group(function () {
|
||||
Route::prefix('inform')->group(function () {
|
||||
Route::get('release', [ApiInformController::class, 'release']);
|
||||
});
|
||||
|
||||
|
||||
+3
-7
@@ -115,13 +115,7 @@ Route::get('api/search/autocomplete', [SearchSuggestController::class, 'autocomp
|
||||
Route::get('api/search/suggest', [SearchSuggestController::class, 'suggest'])->name('api.search.suggest');
|
||||
Route::get('api/search/assist', [SearchSuggestController::class, 'searchAssist'])->name('api.search.assist');
|
||||
|
||||
// NZB downloads authenticate inside the controller via session or RSS/API token,
|
||||
// so they must remain reachable outside the verified-only web group.
|
||||
Route::match(['GET', 'POST'], 'getnzb/{guid}', [GetNzbController::class, 'getNzb'])->name('getnzb.guid');
|
||||
Route::match(['GET', 'POST'], 'getnzb', [GetNzbController::class, 'getNzb'])->name('getnzb');
|
||||
|
||||
// Contact form must remain publicly reachable for guests.
|
||||
Route::match(['GET', 'POST'], 'contact-us', [ContactUsController::class, 'showContactForm'])->name('contact-us');
|
||||
Route::get('contact-us', [ContactUsController::class, 'showContactForm'])->name('contact-us');
|
||||
Route::post('contact-us', [ContactUsController::class, 'contact']);
|
||||
|
||||
Route::middleware('isVerified')->group(function () {
|
||||
@@ -142,6 +136,8 @@ Route::middleware('isVerified')->group(function () {
|
||||
});
|
||||
|
||||
Route::match(['GET', 'POST'], 'details/{guid}', [DetailsController::class, 'show'])->name('details');
|
||||
Route::match(['GET', 'POST'], 'getnzb/{guid}', [GetNzbController::class, 'getNzb'])->name('getnzb.guid');
|
||||
Route::match(['GET', 'POST'], 'getnzb', [GetNzbController::class, 'getNzb'])->name('getnzb');
|
||||
Route::match(['GET', 'POST'], 'rsshelp', [RssController::class, 'showRssDesc'])->name('rsshelp');
|
||||
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
|
||||
Route::match(['GET', 'POST'], 'apihelp', [ApiHelpController::class, 'index'])->name('apihelp');
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use PDO;
|
||||
use ReflectionClass;
|
||||
@@ -73,6 +74,7 @@ class NzbAndRssAccessTest extends TestCase
|
||||
Cache::flush();
|
||||
|
||||
$this->createSchema();
|
||||
$this->registerTestRoutes();
|
||||
$this->resetGlobalComposerState();
|
||||
}
|
||||
|
||||
@@ -109,6 +111,77 @@ class NzbAndRssAccessTest extends TestCase
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_api_v2_getnzb_without_api_token_returns_json_error_instead_of_login_redirect(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v2/getnzb?id=test-guid');
|
||||
|
||||
$response->assertForbidden();
|
||||
$response->assertJson([
|
||||
'error' => 'Missing or invalid API key',
|
||||
]);
|
||||
$response->assertDontSee('name="login"', false);
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_getnzb_with_unverified_token_returns_api_credentials_error(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'username' => 'unverified-nzb-user',
|
||||
'email' => 'unverified-nzb@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'unverified-nzb-token',
|
||||
'verified' => 0,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->get('/getnzb?r=unverified-nzb-token&id=test-guid');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('<error code="100" description="Incorrect user credentials"/>', false);
|
||||
$response->assertDontSee('name="login"', false);
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_legacy_api_rejects_unverified_users(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'username' => 'unverified-api-user',
|
||||
'email' => 'unverified-api@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'unverified-api-token',
|
||||
'verified' => 0,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->get('/api/v1/api?t=search&apikey=unverified-api-token');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('<error code="100" description="Incorrect user credentials (wrong API key)"/>', false);
|
||||
$response->assertDontSee('name="login"', false);
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_api_v2_rejects_unverified_users(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'username' => 'unverified-api-v2-user',
|
||||
'email' => 'unverified-api-v2@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'unverified-api-v2-token',
|
||||
'verified' => 0,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v2/search?api_token=unverified-api-v2-token&id=test');
|
||||
|
||||
$response->assertForbidden();
|
||||
$response->assertJson([
|
||||
'error' => 'Missing or invalid API key',
|
||||
]);
|
||||
$response->assertDontSee('name="login"', false);
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_rss_feed_without_api_token_returns_403_error_instead_of_login_redirect(): void
|
||||
{
|
||||
$response = $this->get('/rss/full-feed');
|
||||
@@ -121,6 +194,27 @@ class NzbAndRssAccessTest extends TestCase
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_rss_feed_rejects_unverified_users(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
'username' => 'unverified-rss-user',
|
||||
'email' => 'unverified-rss@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'unverified-rss-token',
|
||||
'verified' => 0,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->get('/rss/full-feed?api_token=unverified-rss-token');
|
||||
|
||||
$response->assertForbidden();
|
||||
$response->assertJson([
|
||||
'error' => 'Invalid RSS token',
|
||||
]);
|
||||
$response->assertDontSee('name="login"', false);
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_contact_form_is_publicly_accessible_to_guests(): void
|
||||
{
|
||||
$response = $this->get('/contact-us');
|
||||
@@ -131,6 +225,54 @@ class NzbAndRssAccessTest extends TestCase
|
||||
$response->assertDontSee('<title>Login', false);
|
||||
}
|
||||
|
||||
public function test_api_v2_rate_limit_uses_each_users_configured_rate_limit(): void
|
||||
{
|
||||
DB::table('users')->insert([
|
||||
[
|
||||
'username' => 'low-limit-user',
|
||||
'email' => 'low@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'low-limit-token',
|
||||
'rate_limit' => 1,
|
||||
'verified' => 1,
|
||||
],
|
||||
[
|
||||
'username' => 'high-limit-user',
|
||||
'email' => 'high@example.test',
|
||||
'password' => 'secret',
|
||||
'api_token' => 'high-limit-token',
|
||||
'rate_limit' => 3,
|
||||
'verified' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=low-limit-token')
|
||||
->assertOk()
|
||||
->assertHeader('X-RateLimit-Limit', '1')
|
||||
->assertHeader('X-RateLimit-Remaining', '0');
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=low-limit-token')
|
||||
->assertStatus(429)
|
||||
->assertJsonPath('error', 'API rate limit exceeded.');
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=high-limit-token')
|
||||
->assertOk()
|
||||
->assertHeader('X-RateLimit-Limit', '3')
|
||||
->assertHeader('X-RateLimit-Remaining', '2');
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=high-limit-token')
|
||||
->assertOk()
|
||||
->assertHeader('X-RateLimit-Remaining', '1');
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=high-limit-token')
|
||||
->assertOk()
|
||||
->assertHeader('X-RateLimit-Remaining', '0');
|
||||
|
||||
$this->getJson('/api/test-rate-limit?api_token=high-limit-token')
|
||||
->assertStatus(429)
|
||||
->assertJsonPath('error', 'API rate limit exceeded.');
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -162,6 +304,30 @@ class NzbAndRssAccessTest extends TestCase
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('users')) {
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username')->unique();
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->string('api_token')->nullable()->index();
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function registerTestRoutes(): void
|
||||
{
|
||||
if (! Route::has('tests.api-rate-limit')) {
|
||||
Route::middleware('apiRateLimit')
|
||||
->get('/api/test-rate-limit', fn () => response()->json(['ok' => true]))
|
||||
->name('tests.api-rate-limit');
|
||||
}
|
||||
}
|
||||
|
||||
private function resetGlobalComposerState(): void
|
||||
|
||||
Reference in New Issue
Block a user