はじめに
社内用のWebアプリをSPAで開発していますが、画像を配信するエンドポイントをどのように設定するかで悩む場面がありました。
状況としては以下のような感じです:
- 基本的に社内限定で使うアプリなので、Web APIのエンドポイントはほぼ全てが認証を必須にしている。
- 認証はJWT認証を使う。フロントエンドでlocalStorageから読み出したBearerトークンをヘッダーに渡す方式。
- 扱う画像は社外秘の情報を含む可能性が高いため、何らかの方法で配信用のエンドポイントを保護しておきたい。
TL;DR
- URLで画像を指定する際はヘッダーの
Authorization
をいじれない! - 今回はBlob URLを使って解決した。
- JavaScriptのコードで保護されたエンドポイントからバイナリ形式で画像を取得する。
- バイナリをBlob化して、
URL.createObjectURL()
でBlob URLを生成する。 - 生成したBlob URLは
<img>
タグやフロントエンドのライブラリで使用可能。 - Blobを使い終わったら、忘れずに
URL.revokeObjectURL()
で破棄しよう。
どうにかして画像用のエンドポイントも保護したい
通常 <img>
タグの src
属性には、ヘッダーに認証トークンを含む任意の情報を渡すことができません。また、同様の問題はフロント側のライブラリで画像リソースを指定するケースなどでも考えられます。
そこで、対策をいくつか考えてみました。
画像についてはエンドポイントの保護を諦めて、Web APIのURI設計でカバーする方法【危険!!】
最初に考えた方法です。 src
属性には通常通り配信用URIを指定して、JWTによる保護を諦めるというパターンです。
このまま無策だと流石にヤバいので、Web APIのURI設計でなんとかしようとしていました。以下のようにして、リソースの指定はUUIDだけで行うように徹底します。
https://<mywebapp>.azurewebsites.net/api/resources/{UUID}/children/{UUID}/images/{UUID}
問題点
URLのパラメータにはUUIDしかないので、外部から画像を取得する通信を覗かれても「それが何を指すデータなのか」は判別できません。しかし、画像配信用のエンドポイントが丸裸なのは変わりありません!
リソースの指定を意味のある文字列で行うような状況よりはマシですが、依然としてセキュリティリスクは残っていることになります。
クラウドストレージのSASトークンを利用する方法
もう一つは SAS (Shared Access Signatures) トークンの仕組みを利用する方式です。セキュリティ周りをWeb APIではなくクラウド側に任せるアプローチです。
やり方としては、Web API側で予め期限付きのSASトークンを生成しておいて、フロントエンドでそのトークンを使って画像を取得する感じです。
SASトークンについての詳しい説明は省略します。以下の記事などを参照してください。
今回はAzure Blob Storageを使用するので、画像の配信はWeb API経由ではなくBlobのエンドポイントから直接行います。URIの末尾にWeb APIから一定時間ごとに発行されるSASトークンを指定します。
ということで、<img>
タグに指定するURLは以下のようになります。
https://<mystorageaccount>.blob.core.windows.net/<myContainer>/{UUID}.jpg?{SAS Token}
問題点
はじめはこの方式で考えていたんですが、採用には至りませんでした。
- SASトークンの仕組みは期限付きでリソースを外部に共有するような状況では便利だが、今回はあくまで「ログイン時のみWebアプリからアクセスできる」という要件さえ満たせばよい。
- 通常の認証とは別に、フロント側が一定時間ごとにAPIにSASトークンを要求する必要がある。
⇒要件に対して実装が大袈裟になる懸念がある。めんどくさそう…… - 期限が設けられているとはいえ、URLパラメータに生のトークンを渡すのはちょっと抵抗感がある。
Blob URLを使う方法 【採用!】
最後に真打ちです。フロントエンドのコードで画像データを取得して、Blob URL を経由して表示させる方法です。
これまでの候補と問題点を洗い出すと、やはり通常のJSONのやり取りと同様に「JavaScript側からJWT認証を直接利用する」という方法が最もシンプルで適していると考えられます。
そこで、以下のようなフローで画像リソースを扱うアプローチでやってみることにします。
- フロントエンドで認証トークンを付加して、画像リソースのバイナリデータを取得する
- 取得したデータをURLの形式に変換する
- URLが取得できたら、あとは好きなように使う
ここで使えるのが Blob
オブジェクトです。Blobを使うと、JavaScriptから生バイナリ形式でデータを扱うことができます。
Blobのデータを外部から利用する際は、 URL.createObjectURL(object)
メソッドでURL形式の参照を作成します。1
このメソッドで取得したBlob URLですが、通常の画像リソースのURLと同様、<img>
タグの src
属性や各種ライブラリでリソースを指定する際に使うことができます。
実装例 (JavaScript)
ということで、とりあえずJavaScriptで実装を書いてみます。
// 保護されたエンドポイントにあるリソースの参照をBlob URLの形式で取得する
const getBlobURL = async (url, contentType) => {
// localStorageから認証トークンを取得する
const authToken = localStorage.getItem('AuthToken')
// 認証用トークンをヘッダーに付加して、画像リソースを取得する
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + authToken,
'Content-Type': contentType,
},
})
// 得られたresponseをBlobに変換し、Blob URLを返す
const blob = await response.blob()
return URL.createObjectURL(blob)
}
// 指定した <img> 要素に対して、保護されたエンドポイントから取得した画像を表示する
const setAuthImageSrc = async (selector, url, contentType) => {
const blobUrl = await getBlobURL(url, contentType)
const image = document.querySelector(selector)
image.setAttribute('src', blobUrl)
}
// モジュールとして使用する場合、getBlobURLメソッドを公開する (次に示すVue.jsによる実装例に必要)
// export { getBlobURL }
上記の setAuthImageSrc
メソッドを実行すると、指定した img
要素に対して非同期で画像リソースを表示してくれます。
これでとりあえず要件は達成できました。Blob URL、めちゃくちゃ便利ですね!
注意点: Blobの解放について
便利なBlob URLですが、注意点として「使用後にはこまめに解放した方が望ましい」という事項があります。
URL.createObjectURL()
でBlobへの参照が作られると、メモリ上にBlobの中身が保持された状態になります。
Blobへの参照が不必要になったタイミングで解放してあげないと、ページを閉じたり再読み込みしない限り、メモリ上にずっとBlobのデータが居座り続けることになります。
特に今回はサイズが大きめの画像リソースを扱うので、このようなメモリリークは馬鹿になりません。
Blobのリソースを解放するには URL.revokeObjectURL()
メソッドを使います。引数には解放したいBlobのURLを指定します。
React、Vue、BlazorといったSPAフレームワークを使用している場合、表示対象となるリソースの変更時や利用対象のコンポーネントを破棄するタイミングで呼び出してやると良いでしょう。2
なお実装対象がSPAではない場合、ページを遷移すると自動的にBlobも解放されるため、破棄はほぼ不要です。
実装例 (Vue.js 3.x + TypeScript)
ということで、上記のリソースの解放も踏まえて実装してみた結果がこちらです。
以下はVue.js 3.x + TypeScriptでの実装例です。ついでにですが、普通に <img>
タグ感覚で使えるようにコンポーネント化もしておきました。
<script setup lang="ts">
import { onMounted, onUnmounted, watch, ref } from 'vue'
import { getBlobURL } from '@/utils/blob.js' // 先のスクリプトをライブラリとして使う
// リソースの解放に必要なので、Blob URLはコンポーネント内で保持しておく
let blobURL = ref('')
// Props
const props = defineProps({
src: { type: String, required: true },
contentType: { type: String, default: 'application/octet-stream' }
})
// mount時に画像リソースを読み込む
onMounted(() => loadBlobSrc())
// props.srcが変更されるたびに、画像リソースを読み込む
watch(() => props.src, () => loadBlobSrc())
const loadBlobSrc = async () => {
URL.revokeObjectURL(blobURL.value) // 前のリソースを破棄する
blobUrl.value = await getBlobURL(props.src, props.contentType)
}
// unmount時にBlobを解放する
onUnmounted(() => {
URL.revokeObjectURL(blobURL.value)
})
</script>
<template>
<img v-if="blobURL" :src="blobURL" />
</template>
<script setup lang="ts">
import AuthImage from '@/components/AuthImage.vue'
</script>
<template>
<AuthImage src="/api/images/image-with-jwt-auth.jpg" />
</template>
contentType
のpropsはお好みで指定しますが、application/octet-stream
のままでもOKです。
これでJWT認証でエンドポイントを保護したまま、画像のリソースを表示することができました🎉
次回予告的な何か
今回はJavaScript (TypeScript) + Vue.js での実装方法を紹介しました。
ですが、実は自分が現在担当している案件だとフロントエンドにVue.jsやTypeScriptは使用していません。
実際のところ、現在は Blazor を使ってフロントエンドをC#で組んでいます。仕事でも趣味でも、自分が普段使っているフレームワークもBlazorの方です。
(一年以上ぶりにVue.jsに触りましたが、思い出すのがちょっと大変でした……)
BlazorでもJavaScriptのコードを呼び出すことはできるので、基本的には上記のやり方でOKです。
ですが、Blazor用の便利なライブラリ3 もあるので、次回以降はそのへんを踏まえた実装例についても書いていこうかなと思います。
ありがとうございました!