Web フロントで Authorization Header が必要な画像を表示する2つの方法
概要
Web フロントエンドにおいて、HTTP GET で認証が必要な画像を img
要素で表示したいケースについて考えます。
認証に Cookie を使う場合、ブラウザが自動で Cookie を付与したリクエストを発行するため問題なく取得できます。
認証に Authorization
Header もしくは独自の X-Service-Token
のような Request Header を使う場合、img
要素では自動で任意の Header は付与してくれない為、認証を通すために何かしらの手段を講じる必要があります。
ここでは、Authorization
Header を指定する必要がある画像の取得において、取り得る手段を検討します。
対象読者
- Service Worker と Web Components に対して基本的な理解があるソフトウェアエンジニア
TL;DR
Service Worker もしくは Web Components を利用することでこの問題の解決を試みます。
- Service Worker を用いてリクエストを中継し、
Authorization
Header を挿入する - Web Components を利用して
img
要素を拡張し、Authorization
Header を付与した fetch を実行する
双方に良し悪しがあり、どちらが優れているとは言えません。その都度要件に合った方法で解決しましょう。
モチベーション
Query Parameter に api-token=xxx
のように API Token を含める場合、URL に credential が含まれることになります。特に対策を打たない限りミドルウェアのログや、Web サーバーのアクセスログに記録されてしまいます。このログを閲覧することができる開発者は、リクエストしてきたユーザーに成りすますことができるというリスクがあります。
URL から credential を確実に取り除いてログ出力するというのは大変手間です。ミドルウェア等の構成変更のタイミングで対応が漏れて、再びログに出力されるリスクもあります。
そこで、署名付き URL を用いるという手があります。これは、その操作やリソースに対して短時間有効な credential を発行して URL に付与するため、credential の悪用によるリスクを最小限に抑えることが可能です。操作が限定されていない、有効期間の長い API Token を URL に含めるよりはいくらかマシでしょう。要件によってはこのような手段を取る必要があります。
一方、Authorization
Header であれば、意図的に出力されるような設定にしない限りログに現れることはありません。
更に、画像配信サーバーで Web フロントエンドのためだけに URL に含まれる credential を処理する必要が無くなり、実装の保守性が上がります。
セキュリティと保守性の観点から、Query Parameter に credential を含めるのは基本的に避けた方が良いでしょう。
解決方法
Service Worker を使う方法
Service Worker を使うと、ページ上で発火する FetchEvent を中継することができます。
const interceptor = async (request, token) => {
const headers = new Headers(request.headers);
headers.set("Authorization", `Bearer ${await token}`);
const req = new Request(request, {
mode: "cors", // no-cors mode では Request Header が書き換えられないため変更
headers
});
return await fetch(req);
};
self.addEventListener("fetch", event => {
const req = event.request;
if (
req.method !== "GET" ||
req.headers.get("Authorization") != null ||
!req.url.includes("/restricted/")) {
// do nothing
return;
}
event.respondWith(interceptor(event.request, store.getToken(3000)));
});
<img src="path/to/restricted/image.jpg"></img>
利点
-
img
タグの記述を修正する必要がない - CSS の
background-image
で URL を指定する場合などにも適用可
欠点
- Service Worker のインストールの直後と hard reload の直後はリクエストが中継されず、画像が表示されない
Service Worker のインストール直後に関しては、一度 HTTP Request が失敗することを許容できれば img
の onerror
属性を使って retry することができます。
function waitServiceWorkerActivated() {
// Service Worker が activated になると resolve される Promise を返す
return new Promise((resolve) => {
const onStatechange = (event) => {
if (navigator.serviceWorker.controller.state === "activated") {
if (event != null) {
navigator.serviceWorker.controller.removeEventListener("statechange", onStatechange);
}
resolve(navigator.serviceWorker.controller);
return true;
}
return false;
};
const onControllerchange = (event) => {
if (navigator.serviceWorker.controller) {
if (event != null) {
navigator.serviceWorker.removeEventListener("controllerchange", onControllerchange);
}
if (onStatechange()) {
return true;
}
navigator.serviceWorker.controller.addEventListener("statechange", onStatechange);
return true;
}
return false;
};
if (onControllerchange()) {
return;
}
navigator.serviceWorker.addEventListener("controllerchange", onControllerchange);
});
}
function retry(target) {
// 1度きりの retry を実現するために flagment を付与
const retryFlag = "#retry";
if (target.src.includes(retryFlag)) return;
waitServiceWorkerActivated().then(() => {
target.src += retryFlag;
});
}
<img onerror="retry(this)" src="path/to/restricted/image.jpg"></img>
Web Components を使う方法
Web Components により HTMLImageElement
を拡張します。Fetch API により Authorization
Header を付与したリクエストを行い、取得した画像の Object URL 生成して img
要素の src
属性に設定することで、画像を表示します。
class RestrictedImage extends HTMLImageElement {
constructor() {
super();
const url = this.getAttribute("restricted-src");
const self = this;
fetch(url, {
mode: "cors",
credentials: "include",
headers: {
Authorization: `Bearer ${token}`
}
}).then(resp => resp.blob()).then(blob => {
self.src = URL.createObjectURL(blob);
}).catch(() => {
console.error("failed to fetch in RestrictedImage");
});
}
}
customElements.define("restricted-image", RestrictedImage, { extends: "img" });
<img restricted-src="path/to/restricted/image.jpg" is="restricted-image"></img>
組み込み要素の拡張 (is
属性) に対応していない Safari ではこの方法は動かないため、Safari でも動くパターンも用意しました。
class RestrictedImageElement extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const img = new Image();
const url = this.getAttribute("src");
const self = this;
this.getAttributeNames().forEach(key => {
if (key === "src") return;
img[key] = self.getAttribute(key);
});
fetch(url, {
mode: "cors",
credentials: "include",
headers: {
Authorization: `Bearer ${token}`
}
}).then(resp => resp.blob()).then(blob => {
img.src = URL.createObjectURL(blob);
}).catch(() => {
console.error("failed to fetch in RestrictedImageElement");
});
shadow.appendChild(img);
}
}
customElements.define("restricted-img", RestrictedImageElement);
<restricted-img src="path/to/restricted/image.jpg"></restricted-img>
利点
- hard reload の直後でも画像が表示される
欠点
-
img
タグの記述を修正する必要がある
Demo
実際に動くページを用意しました。
まとめ
操作やリソースが制限されていない有効期間の長い API Token を URL に含めるのはアンチパターンでリスクが高いので、
- Service Worker を用いてリクエストを中継し、
Authorization
Header を挿入する - Web Components を利用して
img
要素を拡張し、Authorization
Header を付与した fetch を実行する
これらが難しい場合は、最終手段として(操作やリソースが制限され有効期間の短い)署名付き URL を使用する事を検討するのが良いでしょう。
ここ何年も Web フロントエンドから離れていたので久々に触りましたが、便利になっていますね。
因みに Service Worker で Authorization
Header を付与するというのは Firebase Auth が結構前からやっているようです。(Beta ですが)
Discussion