web サービスを開発していると、時々こんな要件が舞い込んでくる。
- パスワード変更したら全デバイス/ブラウザから強制ログアウトさせたい
- UI で「すべてのデバイス/ブラウザからログアウトするボタン」を実装したい
- 管理画面から特定のユーザーを全デバイス/ブラウザから強制ログアウトさせたい
理由は様々だが、よく実装が求められる機能である。
では、どうやって実装すればいいのか考えていく。
前提条件
- Cookie を使ったログインセッションで認証は実装されているとする
- セッション情報の保存先は RDB もしくは KVS とする
RDB の場合
MySQL や PostgreSQL など RDB であれば簡単に実装できる。
以下のようなテーブル(仮に名前を user_login_session とする)にセッション情報が保存されているとする。
| カラム名 | 型 | 中身 | 備考 |
|---|---|---|---|
| session_id | varchar | セッション ID | PK(主キー) |
| user_id | int | ユーザー ID | |
| expire_at | datetime | 有効期限 |
user_id でユーザーを特定できるので DELETE するだけでよい。
仮にユーザーID = 100 のユーザーを全デバイス/ブラウザからログアウトするには以下の SQL で可能だ。
DELETE FROM user_login_session WHERE user_id = 100;
KVS の場合
Redis や Valkey などの KVS にセッション情報が保存されている場合はどうだろうか?
Redis を例に考えてみる。
key がセッション ID のみの場合
よくある実装だが Redis の key がそのままセッション ID になっているケース。
Redis でセッションを作る時
SETEX [セッション ID] [有効期限] [セッション情報]
のようにしているケースだ。
仮に
- セッション ID
13fbb89b6ecfef349e566276ae159a69
- セッション情報
- ユーザー ID (数値) のみ
- 仮に
100とする
- 仮に
- ユーザー ID (数値) のみ
- 有効期限
- 1 日(86400 秒)
であれば
SETEX 13fbb89b6ecfef349e566276ae159a69 86400 100
というコマンドを Redis に発行することになる。
結論から言うと、このままでは実装は大変難しい
Redis の特長として
文字列型の場合検索対象は原則 key のみ
があげられる。
正確には SCAN で調べていくと言う方法はあるが、スケールしないため現実的ではない。
また、セッションを Redis で実現するのは文字列型で実装するのが一番楽、という都合もある。
ただ、このことは逆に
ユーザー ID での検索ができない
ということになる。
SQL なら簡単にできた
WHERE user_id = 100
が実現不可能ということだ。
key にユーザー ID 入れたらどうなるか
検索対象にユーザー ID 入れればいいのでは?
と思った方もいるかもしれない。
しかし、それではログインセッション自体が成立しなくなる。
セッション ID からユーザー ID を探したいのに key を「セッション ID + ユーザー ID」にしてしまうと、その key 自体を探すことができなくなる。
なぜならユーザー情報は value (中身)の方に存在するから。
例をあげると key にユーザー ID を含めて
13fbb89b6ecfef349e566276ae159a69:100
このように Redis に保存したとしよう。
Cookie にはセッション ID しか保存されていないためこの key を再現することができない。
では Cookie に「セッション ID + ユーザー ID 」で保存してはどうか?
こちらも、ユーザー ID が外部に出ると言うよろしくない状態ができてしまうので、これもダメ。
では、どう実装すればいいのか?
世代管理
と言う考え方を導入すればよい。
まず Redis に保存するものは以下の 2 つ。
- セッション ID とセッション情報を紐づける従来と同じもの
- key はセッション ID
- ユーザーごとの現在の「世代」情報
- key はユーザー ID
「世代」? なにそれ?
って人は、シンプルに
全デバイス/ブラウザからログアウトするたびにインクリメントされる数字
と思っておけばよい。
あるいは、ログインセッションの
バージョン番号
だと思えばよい。
Redis のコマンドだと
INCR [ユーザーID]
みたいなコマンドが発行されるイメージだ。
ログイン時にやること
- ユーザーごとの現在の「世代」情報を取得して
- セッション生成時、ユーザー ID と一緒に「世代」情報も保存する
Redis に発行するコマンドはこんな感じ。
GET [ユーザーID]
SETEX [セッションID] [有効期限] [ユーザーID+世代]
例としてはこうなる。
GET user_login_age:100 # 仮に 1 が返ったとする
SETEX session:13fbb89b6ecfef349e566276ae159a69 86400 100,1
SETEX の value にカンマ区切りで
- ユーザー ID
- 「世代」情報
を入れている。
認証する時にやること
- ユーザーごとの現在の「世代」情報を取得して
- セッション ID でユーザー ID と「世代」情報を取得
- 1 と 2 の「世代」情報が一致しているか検証する
ポイントは 3 で、もし「世代」情報が食い違っていたら無効なセッションとして扱う。
例としては以下のようになる。
GET user_login_age:100
GET session:13fbb89b6ecfef349e566276ae159a69
を実行して「世代」情報を比較する。
「全デバイス/ブラウザ」からログアウトする時にやること
これは 1 つだけ。
「世代」情報をインクリメントする
なぜこれでうまくいくのか?
「全デバイス/ブラウザ」からログアウトした時の動作の例としては以下のようになる。
INCR user_login_age:100
このコマンドが発行されると user_login_age:100 の値が 1 増える。
そして user_login_age:100 の現在の値とは違う値を「世代」情報として持っているセッションは
「世代」情報が一致しない
ため、認証チェックで弾くことができる。
具体例を考えよう。
ログインセッション生成時は現在「世代」情報が含まれるので、問題なく認証をクリアすることになる。
| key | value |
|---|---|
| user_login_age:100 | 1 |
| session:13fbb89b6ecfef349e566276ae159a69 | 100,1 |
であれば user_login_age と session の「世代」が一致しているから有効なログインセッションとなる。
また user_login_age がインクリメントされた状態では
| key | value |
|---|---|
| user_login_age:100 | 2 |
| session:13fbb89b6ecfef349e566276ae159a69 | 100,1 |
と言う状態になるので「世代」が一致しないので、セッション ID 13fbb89b6ecfef349e566276ae159a69 は無効となる。
また、再度ログインし直すと
| key | value |
|---|---|
| user_login_age:100 | 2 |
| session:6cdd9e33ce7aa133a6c5fb9e081e36b8 | 100,2 |
と「世代」が一致するので、セッション ID 6cdd9e33ce7aa133a6c5fb9e081e36b8 は有効である。
最後に
実装を考えるとき「今あるデータ構造からどうにか検索する」よりも「要件を満たすために何を追加すべきか」を考える方が、結果的にシンプルな設計になることが多い。
今回の世代管理のように、「全デバイス/ブラウザからログアウト」という操作を 1 つの状態としてモデリングし直すことで KVS の制約を踏まえつつ素直に実現できる一例である。
似たような要件に遭遇したときの設計のヒントとして、頭の片隅に置いておいてもらえれば幸いだ。