こんにちはjunです。Laravel製のシステムにてWebマニュアルを作成していた時、「あれ?マニュルはログインユーザーのみ見れる様にするけど、画像などはどうすればいいんだ?」という事態がありました。
Laravelはルートを定義し、その際に認証を設けることができます。ただし静的な画像(今回の様なあらかじめセットしておくマニュアル画像など)を配置する場合はpublic配下または、storage/public配下に置くことが多いと思います。しかしそれらのディレクトリは名の通りいかなるアクセスに対してリクエストを許可しています。
そのため
を実装したい時は単純にpublic配下に置くことはできません。この場合Laravelではコントローラーを使用して、アセットのリクエストに対して一度認証のロジックをかける必要があります。普段Laravelを使用していると、特定のURLとそのビューに対する認証はルートを定義するだけで簡単に設定できます。しかしビュー以外のアセットファイルの場合はWebサーバーとLaravelの仕組みを少し理解している必要ががります。今回はその様な保護したアセットルートの設定方法を解説しようと思います。
Laravelで構築されたURLで指定のビューやファイルをレスポンスとして返す時2通りの処理方法があります。
「2」の方はよくわかると思います。例えば以下の様なルートを定義した時
Route::get('/test', function () {
return view('welcome');
});
https://example.com/testというURLにアクセスするとview('welcome')で定義したHTML(画面)がレスポンスとして表示されます。一方「1」の方はというと例えばpublic配下に置いたcss,jsなど静的なファイルがあげられます。例えばhttps://example.com/css/style.cssの場合、webサーバーはpublic/css/style.cssがあればそれをレスポンスとして返します。
両者の違いはwebサーバーだけで完結しているか、PHP(Laravel)も動かしているかです。これはpublic配下の.htaccessを見ると理解できます。
<IfModule mod_rewrite.c>
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
一部省略していますが、重要なのはこの箇所です。これは「もし、リクエストしたディレクトリおよびファイルがない場合、index.phpを実行する」という意味です。つまりLaravelが置かれたwebサーバーでは、まず「リクエストされたファイルが静的に置かれているかをチェック」そしてもしない場合は「index.phpを実行してLaravelが動的にルートに対するレスポンスを作成する」ということが行われています。
Apache側で静的ファイルに対するアクセスを設定していない場合、基本的にリクエストに合致するファイルがある場合はレスポンスしてしまいます。今回の様な保護したファイル、つまりLaravelの認証などを通してファイルを返すためには、独自のルートを定義してレスポンスする必要があります。
storage/app/public 配下に配置し、そのstorageファイルへのリクエストは上記のwebサーバーのみで処理できます。storageディレクトリがpublicとは別なのになぜできるのか?それはシンボリックリンクを張っているからです。構築時にphp artisan storage:linkというおまじないを唱えたと思います。これはpublic配下にstorage/app/publicに連絡するstorageというシンボリックリンクを配置する処理を行っています。
実際にpublic配下にstorageというものがあり、Vscodeでは矢印マークが加わっているのが分かります。ls -lを実行してみるとstorage -> /var/www/html/storage/app/publicという風に表示されます(私の環境の場合)。ディレクトリとして離れていても、シンボリックリンクを貼ることでstorage/app/public配下をwebサーバーが走査することができる様にしています。
今回のような保護したアセットファイルルートを設定するために
上記のプログラムを作成します。今回は「ログインしたユーザーが見れるwebマニュアルの画像」ということなので、resources配下にファイルを置いておくことにします。一応後でstorageディレクトリに保護ファイルを配置・取得する方法も記述します。
まずは専用のディレクトリを作成します。今回は静的に置いておくのでresources/protectedという保護アセットファイルディレクトリを作っておきます。リクエストがあった場合はこのディレクトリからファイルを取得します。
それではルートを定義します。routes/web.phpにて以下の様なルートを設定。
Route::group(['middleware' => 'auth'], function () {
Route::get('/protected/{path?}', function (Request $request,$path='') {
// 後で書きます..
})->where('path', '.*');
});
authミドルウェアでグルーピングをしてprotected配下のルートを保護します。
{path?}は任意の記述を意味します。つまり/protected/secret.jpg,/protected/manual/private.pngなどのルートにをキャッチすることができます。そして{path?}パラメータはコールバック(コントローラー)に第二引数として使用できます。先程の例のパスの場合、$pathはsecret.jpg,manual/private.pngとなります。この値は後でファイルの取得に使用します。ちなみに今回はルートに処理内容を記述しますが、プロジェクトによっては複雑な認証処理を実装する場合はコントローラーに記述しても大丈夫です。
ではファイルの取得の処理を記述します。
use Illuminate\Support\Facades\File;
Route::group(['middleware' => 'auth'], function () {
Route::get('/protected/{path?}', function (Request $request,$path='') {
if($path==='') abort(404);
$rp = resource_path('protected/'.$path);
if(File::exists($rp)){
return response()->file($rp);
}else{
abort(404);
}
})->where('path', '.*');
});
最初に$pathがない場合は404にアボートします。そして何かしらファイルが指定された場合はresource_path('protected/'.$path)にてresources/protected配下のファイルパスを取得します。
file_get_contents()は使用せずLaravelのresource_path()やstorage_pathを使用し、さらにFileファサード、file()メソッドを使用しましょう。file_get_contents()は../といった記述は文字列でなく、パスとして認識してしまい想定しないディレクトリのファイルにがブラウザを通じて取得される可能性があります。このような脆弱性をディレクトリトラバーサルといいます。Laravelのファイル取得系のメソッドはその辺は対策済みなので、基本的にはLaravelのメソッドを使用しましょう。
ファイルパスを作成したらFile::exists() を使用してファイルが存在するかをチェックします。存在しないファイルをfile()パスで使用するとFileNotFoundExceptionが発生してしまいます。例外処理でやってもいいのですが、responseメソッドを呼んでいるので念のためあらかじめチェックしておきます。
ファイルがある場合は response()->file();を使用して対象のresources/protected配下のファイルをレスポンスとして返します。ない場合は404へアボートします。file()メソッドを使用することで拡張子からcontent-typeを設定してくれるそうでCSVだろうがHTMLでもMP4でも問題なくレスポンスしてくれます。
ルート自体はLaravelのミドルウェアを使用することでファイルを保護し、任意のファイルパスを使用して認証が通ればファイルを取得することができる様になります。
resourcesは基本的に開発者が静的にファイルを置く場合に使用します。ユーザーが自由にアップロードして、保護しながら呼び出したい時はstorageディレクトリを使用します。ファイルの取得と保護は上記とほぼ同じですが、storageの場合は少しcconfigの設定を行います。
config/filesystem.phpにて以下の様に保護storageディレクトリを定義します。
'protected' => [
'driver' => 'local',
'root' => storage_path('app/protected'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'private',
],
こうすることでStorage::disk('protected')->path()を用いて対象ファイルパスを取得することができる様になります。ファイルストレージはローカルでなくS3など外部のものを使用することもあるので、この様に設定ファイルで定義しておくといいです。storageにprotectedディレクトリを作成した後、あとはルートを定義してStorage::disk('protected')->path()を用いてリクエストされたファイルパスを取得し、存在チェックをしてレスポンスで返せばOKです。
今回は簡単なauthミドルウェアですが、権限のロジックを組み込むことで所有者のリクエストのみに見せたり、特定の人のみに見せるといった芸当ができそうです。ただしwebサーバーの静的な配信でなく、ファイルの取得にPHPを動かすことになるので大量配信の場合はパフォーマンスはちょっと心配かもしません。