はじめに
Webアプリケーションが快適に利用できるのはセッションが存在するから、とよく言われると思う。前回の記事Node.js Expressの例を通じて、WebアプリにおけるCookieについて理解するではCookieが必要でそのCookieのおかげと言っていたのに、どういう事?となるかもしれないが、セッションについて理解を深めていく事でCookieとセッションの関係性についてもスッキリすると思う。
そこで今回は、セッションとは何か?について理解しつつ、実際にNode.jsのExpressでセッションを実現する実装をやってみたのでその備忘録を残す。
セッションとは何か?
簡単に一言で言ってしまうと、「論理的な意味での開始から終了までの一連の連続した操作(クライアントとサーバ間でやり取りされるひとまとまりの操作)を表す単位・概念」の事。
ここでポイントは「一連の連続した操作」や「ひとまとまりの操作」と言っているように、1回だけでなく連続で行わる操作に対し論理的な意味で、その開始から終了までを1単位としている事(論理的というのは、ここからここまではやり取りとして一貫している・そのやり取りは1つの同じやり取りの中の事として扱うよ、という事を人間が決めたという事)。
どうしてセッションという概念が出てくるのか?定義を見ても分かりにくいと思うので、以降では図示しながらなぜ上記に書いたセッションという概念が必要なのか?を見ていきたいと思う。
まず、前提としてクライアントとサーバ間のやりとりは1回1回で完結してしまう(本記事で出てくるクライアントというのはWebアプリなどを開くブラウザの事だと思ってよい)。どういう事かというと、以下の図で示したように、2回目のリクエストも1回目のリクエストと同じクライアントからのリクエストだとしても、基本的にサーバはそのリクエストが同じクライアントからのリクエストだとは判断できない(ステートレスである)。
上記のようになる理由としては、クライアントとサーバ間の通信の仕組みはHTTPというプロトコルで行われ、このHTTPのリクエストとレスポンスの仕組みは簡潔になっており、クライアントとサーバーの1回のやりとりで処理が完結するように設計されているから。
これによるメリットもあるが、例えば認証があるようなWebアプリの場合非常に不便な事が起きる。上記の図で1回目にログインを行い、2回目に会員のみが開けるページをリクエストする事を想像してみる。この時、上記の図で見たようにサーバは同じクライアントからのリクエストであるという事を認識できず未ログインの状態として扱ってしまうため、常にログイン画面を返すという事をしてしまう。
ではこれを解消するにはどうすればいいのか?だが、クライアントとサーバのやり取りが1回1回で完結してしまい、ログイン~会員ページの表示までを1つのまとまった操作として扱えていない事が原因。そのため解決策としては、ある時点までのやり取りは全て一連の同じ操作として扱うという事をすればよく、ここで「一連の同じ操作として扱う」という表現をしているがこれがまさしくセッションという概念そのものの事(セッションという概念をクライアントとサーバ間の通信に取り入れる事で、1まとまりの操作というものを扱えるようになるので上記のような問題を解決できる(ステートフルになる))。
セッションという概念がどいうものか?理解が深まったので、次に実際にどのようにセッションを実現するのか?をみていく。今回の例で言うと、ログイン~会員ページの表示までを1つのセッションとして扱えないのは、ログインしたというクライアントの状態をサーバが保持する仕組みがない事が直接の原因(もしサーバ側でクライアントの状態を知る術があれば、ログイン済みとして会員ページをクライアントに返す事ができる)。という事はサーバ側でクライアント側の状態を何らかの方法で保持すればいい!という事になる。
というわけで実際のセッションの実現方法としては以下の図のように、サーバ側にクライアントの状態を保存するための新しいストア(store)(Redis、MySQLなど)を用意する。そしてこのストアにクライアントを識別するための何らかの識別子(セッションID)を保存し、それをクライアント側に送る。クライアントからのサーバへのリクエスト時にはそのクライアントの識別子を送ってもらい、その識別子でサーバはクライアントの状態を把握し、セッションという概念を成り立たせる。
以下の図を見て分かる通り、ここでCookieが登場する(CookieとはについてはNode.js Expressの例を通じて、WebアプリにおけるCookieについて理解するを参照)。Cookieの仕組みを利用する事で、サーバからクライアントにテキスト情報を送り、クライアントからのサーバへのリクエスト時には送ったテキスト情報を送り返してもらう事ができるので、以下の図のような仕組みが実現できる(セッションとCookieの関係性としては、Cookieはセッションという概念を実現するための手段、という関係性になっている)。
※セッションはあくまで論理的な開始から終了までなので、どこを開始としてどこを終了とするかは自由だが、認証があるようなWebアプリであればログイン~ログアウトまでを1セッションとみなす事が多いようである(利便性の観点から)
Node.js Expressでのセッションの実装
では実際にNode.js Expressでセッションを実現する実装を行ってみる。Expressの公式Use cookies securelyに書かれているような実装が参考になるので今回はexpress-sessionを利用して実装していく。セッションの情報(クライアントの状態)を保存するセッションストアも必要になるが、今回はexpress-sessionの公式Compatible Session Storesに書かれている中のRedisを使う(Redisをセッションストアとして利用するのに、必要になるライブラリとしてはconnect-redisとioredis)。
以下のように実装すればセッションは簡単に実現できる(今回は特にexpress-sessionの公式Optionsに書かれているオプションの内、必須のものと、Expressの公式に書かれている"don’t use the default session cookie name(デフォルトのセッションクッキーの名前を使用しない)"のみ適用したシンプルな実装にしている)。
...
import expressSession from 'express-session';
import connectRedis from 'connect-redis';
import Redis from 'ioredis';
...
const redis = new Redis();
const RedisStore = connectRedis(expressSession);
const store = new RedisStore({ client: redis });
app.use(
expressSession({
name: 'prr.sid',
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
store
})
);
...
app.use((req, res, next) => {
const {
session,
query: { login }
} = req;
if (session.id && !session.logined)
console.log('未初期化セッション(Cookie未保存)', session.id);
if (session.logined) console.log('logined', session.logined);
if (login) {
console.log('login処理 や Cookieへの同意');
session.login = 'logined';
}
next();
});
app.use('/', router);
app.listen(port, () => console.log(`Application listening at 3000`));
ソースコード全体は以下。
※Redisについてはdocker-copmposeを利用して立てた。docker-composeでRedisを立てる際のyamlの設定はRedisの動きを確認してみた Redisの準備を参照。また、docker-composeのインストール方法やdocker-composeでのサーバ起動についてはdocker-compose で MySQL Server を起動し操作するを参照(MySQLのサーバを立てる手順だがMySQLの部分をRedisに置き換えれば手順としては全く同じ)。
上記の実装で実際にクライアント(ブラウザ)からサーバへリクエストを送ると、以下の動画のようになる。
2,3回リクエストを送っている時に"session.id"のログ出力値が毎回変わっているが、これはサーバの方でセッションIDを払い出したものの初期化されていないのでセッションストアには保存されず、またCookieにも設定されないため(これは"saveUninitialized: false"という設定に基づく動き)。
実際にセッションがスタートするのは、ログインを行った時(今回は"/?login=true"でクエリパラメータを送信する事でログインをしたことにした)で、ログインのリクエストがサーバに届くと、セッションストアにセッションIDが保存されるとともに、レスポンスでそのセッションID(今回は"prr.sid"という名前)がCookieに設定される。そのため、ログインの後のリクエスト時にはCookieがサーバに送信され、"login is logined src/app.js:93" というログが出力されるようになる。これ以降はセッションを明示的に終了するか、Cookieの有効期間が切れるまでセッションが継続する。
以下で、実装について一部補足する。
※今回はデモなので特にCookieのオプション設定(公式のcookieを参照)はせずデフォルトのままにした。また、express-sessionのオプション設定も一部の必要なものだけに限定して設定している。
※上記の実装は ES Modules を利用した実装になっているが、Node.js で ES Modules を利用する方法についてはNode.js で import・export(ES6 の構文)を使えるように webpack × Babel の設定をやってみたを参照。
const redis = new Redis();
今回は特にカスタマイズする事はないのでデフォルトの設定にしている。デフォルトの設定はnew Redis([port], [host], [options])に書かれており、"port: 6379", "host: localhost"などに設定される。
const RedisStore = connectRedis(expressSession)、const store = new RedisStore({ client: redis })
connect-redisのサイトに使い方が書かれているのでそちらを参照。
name: 'prr.sid'
name自体はオプションの設定項目(公式のnameの項を参照)で、未設定の場合には"connect.sid"というキー名でCookieが保存される。ただ、Expressの公式にDon’t use the default session cookie nameと書かれている通り、デフォルトのセッションクッキー名を使うべきではないとの事なので、名前を変えている(以下、公式からの引用)。
Using the default session cookie name can open your app to attacks. The security issue posed is similar to X-Powered-By: a potential attacker can use it to fingerprint the server and target attacks accordingly.(デフォルトのセッションクッキー名を使用すると、アプリが攻撃される可能性があります。このセキュリティ上の問題は、X-Powered-By と同様です。潜在的な攻撃者は、これを利用してサーバーのフィンガープリントを作成し、それに応じて攻撃を仕掛けることが可能です。)
To avoid this problem, use generic cookie names(この問題を回避するために、一般的なクッキー名を使用します)
resave: false
オプションの意味ついては公式resaveに書かれているのでここでは簡単にしか取り上げないが、クライアントからリクエストがきた際にセッションの中身が未変更でも再度セッションをセッションストアに保存し直すか?を設定できるオプション。デフォルトはtrue(未変更でも保存し直す)になっている(以下、公式からの引用)。
Forces the session to be saved back to the session store, even if the session was never modified during the request.(たとえリクエスト中にセッションが変更されなかったとしても、 セッションストアに戻ってセッションを強制的に保存する)
この設定がtrueになっていると、以下で引用したような副作用(リクエストが2つのリクエストが来た際に、一方で書き換わったセッションをもう一方で上書きしてしまう)があるので今回はfalseとした。
but it can also create race conditions where a client makes two parallel requests to your server and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using).(クライアントがサーバーに 2 つのリクエストを並行して行い、一方のリクエストでセッションに加えられた変更が、もう一方のリクエストの終了時に、たとえ何も変更されていなくても上書きされてしまうという競合状態を引き起こす可能性もあります (この動作は、使用しているストアにも依存します))
※falseを設定する上では、以下の引用に書かれている通り、"touch()"メソッドの実装有無が重要だが、今回利用しているセッションストアには"touch()"メソッドが実装されているのでfalseにして問題ない(今回のストアはconnect-redisで生成しているが、connect-redis.jsを見ると"touch()"メソッドが実装されている事が確認できる)。
The best way to know is to check with your store if it implements the touch method. If it does, then you can safely set resave: false.(ストアがtouchメソッドを実装しているかどうかを確認するのが最も良い方法です。もし実装されていれば、resave: false を安全に設定することができます。)
saveUninitialized: false
これも公式saveUninitializedに詳細は書かれている。設定できる内容としては、未初期化のセッションを強制的にストアに保存するか?を設定できるオプション。ここで言う「未初期化のセッション」とは、作成されたセッションに対して何も保存をしなかった(実装で言えば"session.hoge = 'hoge'"など変更をしていない)セッションの事。これはデフォルトはtrue(未初期化でもセッションストアに保存する)になっている(以下、公式からの引用)。
Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. (「未初期化」のセッションを強制的にストアに保存します。セッションは、新しいが変更されていない場合、未初期化である。)
今回はfalseにし、初期化されて初めてセッションが保存されるようにした。ログインをセッションの開始としたい場合などはこの設定にする必要がある(以下、公式からの引用)。
Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. Choosing false will also help with race conditions where a client makes multiple parallel requests without a session.(falseを選択すると、ログインセッションの実装、サーバーのストレージ使用量の削減、クッキーの設定前に許可を必要とする法律に準拠する場合に便利です。また、false を選択すると、クライアントがセッションを持たずに複数のリクエストを並行して行うような競合状態にも対応できます。)
secret: process.env.COOKIE_SECRET
これは公式に書かれている通り必須なので指定している。このsecretで指定した文字列でCookieを署名する事になる(以下、公式からの引用)。
This is the secret used to sign the session ID cookie.(セッションIDクッキーに署名するために使用される秘密鍵です)
※secretには配列を指定する事もできるが、配列を指定した場合には、配列の最初の要素を使ってCookieの署名が行われる仕様のよう(以下、公式からの引用)。
If an array of secrets is provided, only the first element will be used to sign the session ID cookie(secret の配列を指定した場合、最初の要素のみがセッション ID クッキーの署名に使用されます)
※Cookieの署名の目的としては、Cookieの改ざんを防止する事であり、Cookie自体の値を隠したり暗号化するわけではないのに注意(以下、expressの公式からの引用)。
Note that signing a cookie does not make it “hidden” or encrypted; but simply prevents tampering (because the secret used to sign is private).(クッキーに署名することは、それを「隠す」あるいは暗号化するのではなく、単に改ざんを防ぐだけであることに注意してください(署名に使われる秘密はプライベートなものだからです))
※ちなみに指定しないでserverを起動すると、以下のようなエラーが出る。
Error: secret option required for sessions
store
これはセッションストアに使用するストアを設定しているだけ。公式のstoreを参照。
【補足】sessionの変更後にsession.save()は不要か?
sessionの変更後にSession.save(callback)を呼び出していないが、これは公式に以下のように書かれている通り、セッションを変更した際には、レスポンスが返る前に自動的に保存されるようになっており、明示的に呼び出す必要がないため、呼び出していない。
This method is automatically called at the end of the HTTP response if the session data has been altered(このメソッドは、セッションデータが変更された場合、HTTP レスポンスの最後で自動的に呼び出されます)
まとめとして
今回はセッションとは何か?について、実際にExpressでのセッションの実装をやってみた。認証があるWebアプリではセッションは必須になると思っているので、セキュアにセッションを実装する方法についてさらに理解を深めていきたいと思った。