feat(access): serve-time file access control middleware
Add an opt-in FileAccessControl middleware that resolves the warehouse file and enforces File::canBeAccessedBy() (default public, so existing consumers are unaffected) when files.access_control.enabled is set. Resolution is delegated to a configurable files.warehouse.resolver (ResolvesWarehouseFiles) and the resolved file is stashed on the request so the controller reuses it. Exposed as the `files.access` route-middleware alias and auto-attached to the package warehouse route. Adds the FileAccessControl unit suite.
This commit is contained in:
parent
2ee680cba1
commit
20b1c91e6e
|
|
@ -83,6 +83,17 @@ return [
|
|||
'enabled' => true,
|
||||
'prefix' => 'warehouse',
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
| Resolver used by the access-control middleware to find the file for a
|
||||
| warehouse request. A class implementing
|
||||
| Blax\Files\Contracts\ResolvesWarehouseFiles (or any invokable). When
|
||||
| null, the package WarehouseService is used. Host apps with a custom
|
||||
| lookup flow (encrypted ids, client/server assets, …) point this at
|
||||
| their own resolver so the middleware reuses it. Keep this a class
|
||||
| string — closures here would break `config:cache`.
|
||||
*/
|
||||
'resolver' => env('FILES_WAREHOUSE_RESOLVER'),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
@ -123,13 +134,16 @@ return [
|
|||
| Access Control
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When laravel-roles is installed, files can be protected via access
|
||||
| checks. Set 'enabled' to false to serve all files publicly.
|
||||
| When enabled, the FileAccessControl middleware enforces the File model's
|
||||
| canBeAccessedBy() decision on every warehouse request and 403s anyone who
|
||||
| is not allowed. Default false = serve all files publicly (backwards
|
||||
| compatible). Override canBeAccessedBy() on your File model to define the
|
||||
| per-file policy.
|
||||
|
|
||||
*/
|
||||
|
||||
'access_control' => [
|
||||
'enabled' => false,
|
||||
'enabled' => env('FILES_ACCESS_CONTROL', false),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use Blax\Files\Http\Controllers\FileUploadController;
|
||||
use Blax\Files\Http\Controllers\WarehouseController;
|
||||
use Blax\Files\Http\Middleware\FileAccessControl;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|
|
@ -11,7 +12,15 @@ use Illuminate\Support\Facades\Route;
|
|||
*/
|
||||
|
||||
if (config('files.warehouse.enabled', true)) {
|
||||
Route::middleware(config('files.warehouse.middleware', ['web']))
|
||||
// The access-control middleware is always attached; it no-ops unless
|
||||
// `files.access_control.enabled` is true, so this stays a public file
|
||||
// server by default while letting consumers opt into per-file guards.
|
||||
$warehouseMiddleware = array_merge(
|
||||
(array) config('files.warehouse.middleware', ['web']),
|
||||
[FileAccessControl::class],
|
||||
);
|
||||
|
||||
Route::middleware($warehouseMiddleware)
|
||||
->get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class)
|
||||
->name('files.warehouse')
|
||||
->where('identifier', '[\/\w\.\-\=&@]*');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Files\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Resolves the warehouse file for an incoming request.
|
||||
*
|
||||
* A host application binds an implementation at `files.warehouse.resolver`
|
||||
* so the access-control middleware can resolve files using the app's own
|
||||
* lookup flow (encrypted ids, client/server assets, raw paths, …) without
|
||||
* the package needing to know about it. Returning null means "not found".
|
||||
*/
|
||||
interface ResolvesWarehouseFiles
|
||||
{
|
||||
public function resolve(Request $request): ?Model;
|
||||
}
|
||||
|
|
@ -17,10 +17,25 @@ class FilesServiceProvider extends \Illuminate\Support\ServiceProvider
|
|||
$this->offerPublishing();
|
||||
$this->registerMigrations();
|
||||
$this->registerModelBindings();
|
||||
$this->registerMiddleware();
|
||||
$this->registerRoutes();
|
||||
$this->registerCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the serve-time access-control middleware under the `files.access`
|
||||
* alias so host apps that wire their own warehouse route can guard it with
|
||||
* `->middleware('files.access')`. The package's own route attaches the
|
||||
* middleware class directly (see routes/files.php).
|
||||
*/
|
||||
protected function registerMiddleware(): void
|
||||
{
|
||||
$this->app['router']->aliasMiddleware(
|
||||
'files.access',
|
||||
Http\Middleware\FileAccessControl::class,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-load the package's migrations so fresh installs work without
|
||||
* publishing. Disabled via `files.run_migrations = false` for projects
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace Blax\Files\Http\Controllers;
|
|||
|
||||
use Blax\Files\Events\FileAccessed;
|
||||
use Blax\Files\Events\FileNotFound;
|
||||
use Blax\Files\Models\File;
|
||||
use Blax\Files\Http\Middleware\FileAccessControl;
|
||||
use Blax\Files\Services\WarehouseService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
|
@ -15,39 +15,20 @@ class WarehouseController extends Controller
|
|||
{
|
||||
$identifier ??= $request->get('id');
|
||||
|
||||
$file = WarehouseService::searchFile($request, $identifier);
|
||||
// Reuse the file the access-control middleware already resolved (and
|
||||
// authorized) when it ran, to avoid a second lookup. Falls back to a
|
||||
// fresh search when access control is disabled (middleware no-ops).
|
||||
$file = $request->attributes->has(FileAccessControl::ATTRIBUTE)
|
||||
? $request->attributes->get(FileAccessControl::ATTRIBUTE)
|
||||
: WarehouseService::searchFile($request, $identifier);
|
||||
|
||||
if (! $file) {
|
||||
FileNotFound::dispatch($identifier, $request);
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Access control check (optional, via laravel-roles)
|
||||
if (config('files.access_control.enabled') && $file->exists) {
|
||||
$this->checkAccess($request, $file);
|
||||
}
|
||||
|
||||
FileAccessed::dispatch($file, $request);
|
||||
|
||||
return $file->respond($request);
|
||||
}
|
||||
|
||||
protected function checkAccess(Request $request, File $file): void
|
||||
{
|
||||
// If laravel-roles is installed, check HasAccess
|
||||
if (
|
||||
trait_exists(\Blax\Roles\Traits\HasAccess::class)
|
||||
&& method_exists($file, 'hasAccess')
|
||||
) {
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Authentication required.');
|
||||
}
|
||||
|
||||
if (! $user->hasAccess($file)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Files\Http\Middleware;
|
||||
|
||||
use Blax\Files\Contracts\ResolvesWarehouseFiles;
|
||||
use Blax\Files\Services\WarehouseService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Serve-time access control for warehouse files.
|
||||
*
|
||||
* When `files.access_control.enabled` is true, this middleware resolves the
|
||||
* file for the incoming warehouse request and aborts with 403 unless the
|
||||
* current user may access it — as decided by the File model's
|
||||
* `canBeAccessedBy()` hook (which defaults to "public" on the base model, so
|
||||
* existing consumers are unaffected until they override it).
|
||||
*
|
||||
* The resolved file is stashed on the request as `files.warehouse_file` so
|
||||
* the downstream controller can reuse it instead of resolving a second time.
|
||||
*
|
||||
* File resolution is delegated to the resolver configured at
|
||||
* `files.warehouse.resolver` (a class implementing {@see ResolvesWarehouseFiles}
|
||||
* or any invokable), falling back to the package WarehouseService. This lets a
|
||||
* host app layer in its own lookup without the package depending on it.
|
||||
*/
|
||||
class FileAccessControl
|
||||
{
|
||||
public const ATTRIBUTE = 'files.warehouse_file';
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('files.access_control.enabled')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$file = $this->resolveFile($request);
|
||||
|
||||
// Always set the attribute when enabled so the controller can tell
|
||||
// "middleware ran and resolved null (404)" from "middleware did not run".
|
||||
$request->attributes->set(self::ATTRIBUTE, $file);
|
||||
|
||||
if ($file && $file->exists && method_exists($file, 'canBeAccessedBy')) {
|
||||
if (! $file->canBeAccessedBy($request->user())) {
|
||||
abort(403, 'You are not allowed to access this file.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function resolveFile(Request $request)
|
||||
{
|
||||
$resolver = config('files.warehouse.resolver');
|
||||
|
||||
if ($resolver) {
|
||||
$instance = is_string($resolver) ? app($resolver) : $resolver;
|
||||
|
||||
if ($instance instanceof ResolvesWarehouseFiles) {
|
||||
return $instance->resolve($request);
|
||||
}
|
||||
|
||||
if (is_callable($instance)) {
|
||||
return $instance($request);
|
||||
}
|
||||
}
|
||||
|
||||
return WarehouseService::searchFile(
|
||||
$request,
|
||||
$request->route('identifier') ?? $request->route('encrypted_id') ?? $request->get('id'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +156,24 @@ class File extends Model
|
|||
return url("{$prefix}/{$this->id}");
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Access Control
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decide whether the given user (null = guest) may fetch this file.
|
||||
*
|
||||
* The base model treats every file as public so existing consumers are
|
||||
* unaffected. Apps that need serve-time privacy override this (and enable
|
||||
* `files.access_control.enabled`) to gate avatars, private uploads, etc.
|
||||
*/
|
||||
public function canBeAccessedBy(?\Illuminate\Contracts\Auth\Authenticatable $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| File Content Operations
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Files\Tests\Unit;
|
||||
|
||||
use Blax\Files\Contracts\ResolvesWarehouseFiles;
|
||||
use Blax\Files\Http\Middleware\FileAccessControl;
|
||||
use Blax\Files\Models\File;
|
||||
use Blax\Files\Tests\TestCase;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class FileAccessControlTest extends TestCase
|
||||
{
|
||||
/** A file whose access is gated: only user #1 (or any user, see flag) may read. */
|
||||
protected function gatedFile(bool $ownerOnly = true): File
|
||||
{
|
||||
$file = new class extends File
|
||||
{
|
||||
public bool $ownerOnly = true;
|
||||
|
||||
public function canBeAccessedBy(?Authenticatable $user): bool
|
||||
{
|
||||
if (! $this->ownerOnly) {
|
||||
return $user !== null; // authenticated-level
|
||||
}
|
||||
|
||||
return $user !== null && (int) $user->getAuthIdentifier() === 1; // owner-level
|
||||
}
|
||||
};
|
||||
$file->ownerOnly = $ownerOnly;
|
||||
$file->exists = true; // simulate a persisted file without touching the DB
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
protected function withResolver(File $file): void
|
||||
{
|
||||
config(['files.warehouse.resolver' => new class($file) implements ResolvesWarehouseFiles
|
||||
{
|
||||
public function __construct(private File $file) {}
|
||||
|
||||
public function resolve(Request $request): ?Model
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
}]);
|
||||
}
|
||||
|
||||
protected function pass(FileAccessControl $mw, Request $request): mixed
|
||||
{
|
||||
return $mw->handle($request, fn ($req) => response('served'));
|
||||
}
|
||||
|
||||
public function test_disabled_serves_everything_without_checking(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => false]);
|
||||
// Resolver returns a file that would deny everyone; must be ignored.
|
||||
$this->withResolver($this->gatedFile());
|
||||
|
||||
$response = $this->pass(new FileAccessControl, Request::create('/warehouse/x'));
|
||||
|
||||
$this->assertEquals('served', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_enabled_serves_public_base_file(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => true]);
|
||||
$public = new File(['name' => 'pub']);
|
||||
$public->exists = true; // base File::canBeAccessedBy() returns true
|
||||
$this->withResolver($public);
|
||||
|
||||
$response = $this->pass(new FileAccessControl, Request::create('/warehouse/x'));
|
||||
|
||||
$this->assertEquals('served', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_enabled_403s_guest_on_gated_file(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => true]);
|
||||
$this->withResolver($this->gatedFile());
|
||||
|
||||
try {
|
||||
$this->pass(new FileAccessControl, Request::create('/warehouse/x'));
|
||||
$this->fail('Expected a 403 HttpException for a guest on a gated file.');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertEquals(403, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_enabled_allows_owner_on_gated_file(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => true]);
|
||||
$this->withResolver($this->gatedFile());
|
||||
|
||||
$request = Request::create('/warehouse/x');
|
||||
$request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User
|
||||
{
|
||||
public function getAuthIdentifier()
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
$response = $this->pass(new FileAccessControl, $request);
|
||||
|
||||
$this->assertEquals('served', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_enabled_403s_non_owner_on_owner_only_file(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => true]);
|
||||
$this->withResolver($this->gatedFile(ownerOnly: true));
|
||||
|
||||
$request = Request::create('/warehouse/x');
|
||||
$request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User
|
||||
{
|
||||
public function getAuthIdentifier()
|
||||
{
|
||||
return 999;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$this->pass(new FileAccessControl, $request);
|
||||
$this->fail('Expected a 403 for a non-owner user on an owner-only file.');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertEquals(403, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_resolved_file_is_stashed_on_request(): void
|
||||
{
|
||||
config(['files.access_control.enabled' => true]);
|
||||
$public = new File(['name' => 'pub']);
|
||||
$public->exists = true;
|
||||
$this->withResolver($public);
|
||||
|
||||
$request = Request::create('/warehouse/x');
|
||||
$this->pass(new FileAccessControl, $request);
|
||||
|
||||
$this->assertTrue($request->attributes->has(FileAccessControl::ATTRIBUTE));
|
||||
$this->assertSame($public, $request->attributes->get(FileAccessControl::ATTRIBUTE));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue