2023.10.06
Nest.js + Fastify セッション管理の注意点
Webアプリケーションフレームワーク「Nest.js」は,下層のフレームワークに「Fastify」を使用できます。今回は,この構成でセッションを取り扱う際の注意点について紹介します。注意すべきなのは,「同一セッションで同時にリクエストが発生する場合」です。
1.Nest.jsとFastify
Nest.jsは,Node.jsなどのJavaScriptランタイム上で動作するサーバサイドWebアプリケーションを構築するためのフレームワークです。Node.jsでは,サーバサイドWebアプリケーションを構築するためのフレームワークとしてExpressが有名です。Nest.jsはExpressよりも上のレイヤのフレームワークであり,開発者がアプリケーションアーキテクチャの設計とビジネスロジックの実装に集中できる基盤を提供するプラットフォームです。
Nest.jsは,下層で使用するフレームワークとしてExpressのかわりにFastifyを使用することができます。FastifyはExpressよりも高速であることが知られていますが,Web上の情報量や対応するライブラリの数などがExpressよりも少ないのが現状です。しかし,そのデメリットを補って余りあるパフォーマンスを発揮できるため,Fastifyを選択する理由は十分にあります。
2.Fastifyでセッションを使う
Nest.jsのSessionのDocumentには,ExpressとFastify双方の実装方法が記載されています。しかし,ここで紹介されている「@fastify/secure-session」は,暗号化したセッションデータをクライアントのCookieに保存するタイプ(CookieStore)です。実際のアプリケーションでは,セッションデータをサーバ側で管理するセッションID方式を採用したい場面が多いでしょう。
FastifyでセッションID方式のセッションを利用するには,fastify/sessionを利用します。Nest.jsでは,mainファイルにて以下のように設定することで,fastify/sessionを組み込むことが可能です。
await app.register(fastifyCookie); await app.register(fastifySession, {...options});
3.fastify/sessionのセッションストア
fastify/sessionを本番環境で利用するには,セッションデータを保存するためのセッションストアの指定が必要です。ドキュメントには以下のような記述があります。
A session store. Needs the following methods: set(sessionId, session, callback) get(sessionId, callback) destroy(sessionId, callback) Compatible to stores from express-session.
fastify/sessionのセッションストアは,express-sessionのストアとインタフェースに互換性があります。express-sessionのストアは,RDB(TypeORM)やRedisに対応した実装が公開されており,これらを使うことでセッションID方式のセッション管理を実装することが可能です。
また,set/get/destroyの3つの処理のみを実装すれば良いため,独自に実装するのも容易です。例えば,アプリケーションの他の機能でRedisを使っており,既に独自のラッパサービスなどを実装している場合は,このサービスをNest.jsの機能であるDIで注入して使用するセッションストアを実装する,といった場面が考えられます。
4.発生する問題
これまでに記述したドキュメント通りの実装をしていれば,おおむね問題なく動作します。しかし,同一セッションから「セッションを書き換えるリクエスト」と「セッションに影響を与えないリクエスト」を同時に送信すると,タイミング次第では問題が発生します。まず,どのような問題が,どのような経緯で発生するのかを見てみましょう。セッションを書き換えるリクエストをリクエストA,セッションに影響を与えないリクエストをリクエストBとして,以下のようなフローを考えます。
- リクエストA:サーバに到着
- リクエストA:セッションストアから読み込み
- リクエストB:サーバに到着
- リクエストB:セッションストアから読み込み
- リクエストA:ビジネスロジックによりセッション書き換え
- リクエストA:セッションを保存しレスポンス返却
- リクエストB:セッションの有効期限を更新後,「保存して」レスポンス返却
さて,このときサーバ上に記録されているセッションデータは,リクエストAの変更が反映されているでしょうか?一目見てわかる通り,答えはNOです。リクエストBが,リクエストAによる変更が入る前のセッションの期限を更新し,上書きしています。
このように,セッションを変更・削除するリクエストと,何もしないリクエストを同時に送信すると,後者のリクエストによるセッション期限の更新によってセッションデータが上書きされる,という問題が発生します。
5.問題の原因
この問題の原因は,fastify/sessionのセッションストアのインタフェースがシンプルすぎることに起因します。express-sessionは,set/get/destroyの他にも,「touch」という有効期限のみを更新する関数を備えています。以下は,express-sessionのコードの一部です(この箇所)。
} else if (storeImplementsTouch && shouldTouch(req)) { // store implements touch method debug('touching'); store.touch(req.sessionID, req.session, function ontouch(err) { if (err) { defer(next, err); } debug('touched'); writeend(); });
これは,リクエスト終了時にセッションの値が更新されていたらset,そうでなければtouchを呼ぶ,という処理です。DBにセッションデータを保存するTypeORMストアのtouch処理も見てみましょう(この箇所)。
touch = (id: string, session: any, callback?: (error: any) => void): void => { const ttl = this.getTTL(session); const expiresAt = Math.floor(new Date().getTime() / 1000) + ttl; this.repository .update(id, { expiresAt }) .then(() => callback && callback(null)) .catch((error: any) => callback && callback(error)); };
このように,明示的にUPDATEで有効期限のみを更新しており,データ自体は変更していません。先ほどの例に当てはめると,リクエストAではsetを呼び出しセッションデータを更新し,リクエストBではtouchを呼び出し有効期限を更新します。このような実装であれば,後からリクエストBが古いデータでセッションを更新する,という現象は発生しません。
fastify/sessionのストアは,touchを使用せず,有効期限の更新にもsetを使用します。TypeORMストアでは,setの実装は以下のようになっています(この箇所)。
this.repository .save({ id, data, expiresAt }) .then(() => callback && callback(null)) .catch((error: any) => callback && callback(error));
セッションデータであるdataも含めてsaveを呼び出しています。TypeORMのsaveは,まずSELECTをして,既にあるデータならUPDATE,なければINSERTを行います。つまり,有効期限の更新のために古いセッションデータでsetを呼び出すと,既に更新されている新しいデータを上書きしてしまうわけです。
6.問題の対策
対策方法としては,以下のようなものが考えられます。
- 同時に複数リクエストを送らないようにする
- セッションには最低限必要なデータ(userIdなど)のみ保存し,その他は別に保存する
- セッションストアを自作してsetの処理で対策をする
これらの方法は,どれか1つで全てを解決するものではないため,複合的に利用するのが良いでしょう。
まず,1つ目の同時にリクエストを送らないようにする方法です。これは有効な方法ですが,ユーザの操作やクライアントのネットワークの状態によっては,同時にリクエストが到着する可能性もあるため,十分ではありません。
2つ目の方法は,fastify/sessionのsaveUninitializedオプションを併用すると有効です。このオプションでは,セッションにデータを新しく書き込んだタイミングで,初めてセッションが生成されます。ログインなどでuserIdを書き込んだときに初めてセッションIDに紐づくDBレコードやRedisのKey-Valueペアが生成されます。この方法により,userIdが入っていない状態のセッションで既存のセッションを上書きされることはなくなります。userId以外をセッションに書き込むことがなければ,古いセッションで上書きされる心配もありません。しかし,唯一の問題は,ログアウトなどでセッションを削除するときです。このタイミングで同時にリクエストが発生すると,削除したはずのセッションを,再度同じデータで書き込んでしまう,という問題が発生します。
3つ目の方法は,2つ目の方法と組み合わせて使用すると効果的です。2つ目の方法で,更新に関しては対策ができました。setの処理を自作することにより,削除したはずのセッションを再度書き込んでしまう,という問題を対策します。
まず,アプリケーションでセッションに初めてデータを書き込むタイミング(ログインなど)で,必ずregenerateSessionを呼ぶことにします。この操作により,regenerateのタイミングで空のセッションデータでストアのsetが呼び出されるようになります。空のセッションは,sessionオブジェクトにcookieしか含まれていない,つまりObjectのKeyが1つだけになっています。setの処理では,この条件に当てはまる場合は初回の書き込みであると判断して,そのセッションIDに対応するデータが「存在しない場合」だけ書き込みを行うようにします。そして,それ以外の場合は更新であると判断し,そのセッションIDに対応するデータが「存在する場合」だけ書き込みを行うようにします。これらの操作は,RedisのXXやNXのオプションが役に立つでしょう。
この実装により,削除したはずのセッションを改めてセットしてしまう問題も解消します。このように,複数の対策を組み合わせることで,fastify/sessionで破綻のないセッション管理を行うことが可能になります。
7.まとめ
今回は,Nest.js + Fastifyでセッション管理を行う際に,意図せずセッションが上書きされてしまう問題の原因と対策について紹介しました。Fastifyは積極的に使っていきたいフレームワークですが,特に日本語の情報は少ない印象です。ご興味を持たれたかたは,ぜひ試したり,その過程をアウトプットしたりしてみてください。
次世代システム研究室では,グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発者の方,次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら,ぜひ募集職種一覧からご応募をお願いします。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD