正式リリースに向けての開発で表面化した不都合
以前、アーキテクチャ編: SSR と CDN ( Fastly ) とユーザー依存情報の分離(新規開発のメモ書きシリーズ4)で紹介したように、SSR で生成した HTML を CDN ( Fastly ) でキャッシュできるように「ユーザー依存情報はクライアントサイドで非同期に取り扱うというアプローチ」をとっていました。それによって発生していた不都合と解決に関する追加情報です。
ユーザー依存情報が非同期でレイアウトされる問題
ユーザー依存情報が非同期で解決されるということは、そのような情報を扱うコンポーネントの矩形は Web ページが一度レンダリングされたあとにレイアウトされます。つまりこの記事でも紹介されたところの「ガタンッ」的なユーザー体験が生じます。
ログインと非ログインが、全く同じサイズの矩形を確保するようなビジュアルデザインになっていれば、予め矩形を CSS 的に確保しておけますが、必ずしもそのようなデザインになっているとは限らないのが世の常でしょう。
サーバーが 40x を適切に返せない問題
サーバーがユーザー依存情報を扱わないのと同時に、サーバーがリクエストがログインユーザーのものかどうか分からない、という問題も抱えていました。
ログインしているか分からないということは、いわゆるマイページとかログイン必須のページに直接アクセスされたときに適切なステータスコードを返せないということでもあります。検索エンジン的には noindex, nofollow
などで弾いておけば実害は...という気もしますが、何にせよ気持ち悪いので望ましくありません。
ログインと非ログインをサーバーで判別するようにして解決
総じて「サーバーがユーザー依存情報を扱わないのと同時に、サーバーがリクエストがログインユーザーのものかどうか分からない」が1番の問題だったのでコレを解決します。
どうしたかというと、ログイン/非ログインをサーバーで判別してキャッシュエントリのバリエーションにすることにしました。ユーザー依存情報を含む HTML を Fastly にキャッシュさせることはできませんが、ユーザーをログイン/非ログインの2群に分けて HTML を生成→キャッシュさせることは問題ありません。
アクセストークン ( JWT ) の保持を Cookie に移動
諸般の事情でアクセストークンを localStorage
に保存してユーザー依存情報を取得するときの XHR にリクエストヘッダとして付加していましたが、トークンを Cookie (secure, httpOnly) に移動させて HTML リクエストも含めてサーバーにアクセストークンが伝わるようにしました。
これは localStorage
だと DOM Based XSS でイチコロという点の対策も含んでいます。React を使っていれば正常系はだいたい防御できるのですが、いつぞやの Chrome Extension みたいに悪意のあるコードが今や広大になりすぎた Web フロントエンドの依存グラフに混入されると手の打ちようがない感。
Fastly で JWT に含まれる有効期限でログイン判定
Cookie にアクエストークンがあれば HTML への通常リクエスト時も、Fastly で JWT をいじることが可能になります。
端的に言うと日経新聞社さんと似たようなことをしています。うちの場合は HTML リクエストに絡めて Fastly 内でまじめに認証する必要がないので、有効期限の確認のみで「おそらくログインしているであろう」と判断しています。
# snippet for vcl_recv
if (req.http.Cookie:token ~ "^.+$") {
declare local var.TokenExpiration STRING;
set var.TokenExpiration = regsub(digest.base64_decode(req.http.Cookie:token), {"^.*?"exp"\s*:\s*(\d+).*?$"}, "\1");
if (time.is_after(std.integer2time(std.atoi(var.TokenExpiration)), now)) {
set req.http.X-Valid-Expiration-Token = "true";
} else {
set req.http.X-Valid-Expiration-Token = "false";
}
# ユーザー依存情報に関わる XHR 用エンドポイントのみリクエストヘッダにトークンを付加
if (req.url ~ "^/api") {
set req.http.X-Access-Token = req.http.Cookie:token;
}
} else {
set req.http.X-Valid-Expiration-Token = "false";
}
unset req.http.Cookie; # Remove obsolete cookie
サーバー側で Cookie を参照してユーザー依存情報をうっかり呼び出してしまう芽を摘むため、念には念を入れて unset
しています。あと vcl_fetch
で X-Valid-Expiration-Token
を Vary
に加えています。
クライアントサイドで認証が必要なエンドポイントに XHR を投げて失敗したら、その時点で認証失敗の強制ログアウト扱いにします。
New Context API を利用してログイン/非ログインのレンダリングを制御
React を利用しているので connectToStores
済みの Container で似たようなことをしていましたが、今回のタイミングで新しい Context に置き換えてみました。
interface UserMeContextProvideValues {
// いわゆるログインユーザー情報(クライアントサイドで非同期取得)
user: UserEntity | null;
// クライアントサイドでログインユーザー情報を取得したら true
loggedIn: boolean;
// アクセストークンが有効ならサーバーサイドレンダリングの時点から true
initialized: boolean;
}
<UserMeContext.Provider value={{ user, initialized, loggedIn }}>
{/* 中略 */}
</UserMeContext.Provider>
そしてこのように利用します。次の例はサービスのヘッダー部分に、ログインしていたら通知アイコンボタンが表示され、非ログインであればログインボタンが表示されます。
<UserMeContext.Consumer>
{({ loggedIn }: UserMeContextProvideValues): JSX.Element =>
loggedIn ? (
<ul className={css.nav}>
<li>
<NavLink href="/notifications">
<IconButton icon={<NotificationIcon />} />
<span className={css.Badge} />
</NavLink>
</li>
</ul>
) : (
<ul className={css.nav}>
<li>
<Link href="/login" appearance="button" className={css.loginButton}>
<FormattedMessage id="button.login" />
</Link>
</li>
</ul>
)
}
</UserMeContext.Consumer>
また、冒頭で紹介したような「ガタンッ」的なユーザー体験を引き起こしていたケースは次の例です。
<UserMeContext.Consumer>
{({ loggedIn }: UserMeContextProvideValues): JSX.Element =>
loggedIn ? <UserStatContainer /> : <MainVisual />
}
</UserMeContext.Consumer>
<UserStatContainer>
はあまり高さがないコンポーネントですが、非ログイン向けの <MainVisual>
は大きい画像とデカいテキストで高さもあるコンポーネントで、loggedIn
相当がクライアントサイドで判別されていると両コンポーネントのサイズが違うため「ガタンッ」が避けられません。
これが、サーバーサイドで最低限 loggedIn
の判定をするようになることで <UserStatContainer>
の中に含まれるユーザー依存情報こそ非同期取得ですが、適切なサイズの矩形をレイアウト的に確保しておくことは容易になりました。
現場からは以上です
まあ、なんとか負債になる前に回収できたかな、うん。という感じ。デザイン的なピーキーさは減ったはず。