Update verification handling

This commit is contained in:
DariusIII
2026-03-29 09:42:55 +02:00
parent e256778b95
commit 7809f54008
13 changed files with 326 additions and 20 deletions
+1 -2
View File
@@ -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);
}
+1 -2
View File
@@ -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!']);
}
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+166
View File
@@ -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