🔐

APIトークン認証の論理設計

2023/09/16に公開2

SPAやモバイルアプリから利用するAPIを開発する際の、トークン認証のお話です。

どの認証ライブラリを使うべきという話ではなく、トークン認証の論理的な設計について考察します。

私自身も結論が出ていないので、色んな意見が聞けると嬉しいです。

出発点

ユーザテーブルにアクセストークンを持つのが最も安直な発想だと思います。

users
- user_id
- email
- password
- access_token
- access_token_expired

ログイン成功時にアクセストークンを発行し、該当ユーザレコードにセット。
同時に有効期限もセットします。

認証時には、アクセストークンが存在し有効期限内であれば、認証を通過させ、
そうでなければ認証失敗とします。

ログアウト時には、該当ユーザレコードのアクセストークンを空にします。

発行日時を持ち、システム内に定義された有効期間をもとに、認証時に計算する方法もあると思います。
Laravel Sanctum 等はそういう実装です(しかもデフォルトでは有効期限なし)。
有効かどうかがひとめでわかる方が好ましいと思うので、私は有効期限を持ちたいです。

問題点

問題1. アクセストークンを長期に設定せざるを得ない

頻繁に再ログインを求められるのはUXが最悪なので、
アクセストークンの有効期限をある程度長期に設定せざるを得ません。
しかし、全てのリクエストに乗ってネットワーク上を頻繁に飛び交うアクセストークンを、
数時間〜数日のような長期有効とするのは、アプリケーションによっては非常に気持ちが悪いです。

問題1'. 有効期限が固定

短期であれ長期であれ、有効期限が固定である以上、
ユーザが連続的に操作している最中に有効期限を迎え、突然再ログインを求められる、ということが発生します。

これもユーザにとって大きなストレスです。

問題2. 複数端末からの並行利用ができない

ログイン認証するたびにアクセストークンが書き換わるため、
別の端末からログインすると、先にログインしていた端末からの認証が通らなくなります。

問題1の対応(1): リフレッシュトークン方式

OAuth では一般的なリフレッシュトークンですが、OAuth でなくとも利用する価値はあると思います。

users
- user_id
- email
- password
- access_token
- access_token_expired
- refresh_token
- refresh_token_expired

ログイン成功時にアクセストークンに加え、リフレッシュトークンも生成して返します。

アクセストークンは短期、リフレッシュトークンは長期とします。

認証時には、アクセストークンのみを利用し、
フロントエンドがアクセストークンの期限切れを検知した場合には、
リフレッシュトークンを用いてリフレッシュ用のエンドポイントを叩きます。

リフレッシュ用のエンドポイントは、有効なリフレッシュトークンであれば、
新たにアクセストークンを発行して返します。

フロントエンドは、新たに発行されたアクセストークンを用いて処理を継続することができます。

リフレッシュトークンは、リフレッシュ用エンドポイントを叩く時にしかリクエストに乗らないため、
アクセストークンを長期にすることに比べれば、安全性は高くなるといえます。

Laravel Passport などは(OAuthライブラリなので当たり前ですが)リフレッシュトークンを採用しています。

問題点

フロントエンドの実装は複雑になります。

特に1つの画面で複数の並行するリクエストが走る場合、
一方のリクエストによりアクセストークンが書き換わり、他方のリクエストが弾かれる、
という問題にしばしば直面します。

以前のプロジェクトでは、試行錯誤の末、最後までこの問題を完全にクリアすることができず、この方式を断念する羽目になりました。

問題1の対応(2): アクセストークンの延長方式

ログイン成功時の有効期間は短期としておきながら、
認証を通過するたびに、有効期限を延長していく、という方式も考えられます。

users
- user_id
- email
- password
- access_token
- access_token_expired

アプリケーションを連続使用している限り、途中で再認証を求められることはなく、
体感的にはセッションによるログイン認証に近くなります。

アクセストークンを長期にすることに比べれば、安全性は高くなるといえますが、
リフレッシュトークン方式に比べるとやや劣るでしょうか。

この方式を採用している認証ライブラリは、私は見たことがありません。

問題2の対応(1): 1ユーザ複数トークン方式

1ユーザが複数のトークンを持つことができるようにすることで、
端末ごとに異なるアクセストークンを発行することが出来ます。

users
- user_id
- email
- password
tokens
- token_id
- user_id
- access_token
- access_token_expired

これにより、複数端末から並行して利用することができるようになります。

Laravel Sanctum や Laravel Passport はこのような作りになっています。

有効なトークンが増える分不正利用のリスクも高まるため、
「全ての場所からログアウトする」等、ユーザの意志で全てのトークンを削除できるようにしておく必要はあるかと思います。

問題2の対応(2): トークンの再利用方式

異なるクライアントからログインした際、該当ユーザが有効期間内のトークンを持っていれば、
トークンを再発行せずに同じトークンを返す、という方式です。

users
- user_id
- email
- password
- access_token
- access_token_expired

これにより、複数端末から並行して利用することができるようになります。

ただし、いずれかの端末でログアウトすれば、他の端末も強制的にログアウトすることになります。

この方式を採用している認証ライブラリも、私は見たことがありません。

落とし所1: リフレッシュトークン方式×1ユーザ複数トークン方式

users
- user_id
- email
- password
access_tokens
- access_token_id
- user_id
- access_token
- access_token_expired
refresh_tokens
- refresh_token_id
- user_id
- refresh_token
- refresh_token_expired

Laravel Passport はこのような作りになっています。

有効なアクセストークンが複数存在できるため、リフレッシュトークン方式の問題点で述べたような、
並行処理による予期せぬ認証失敗も、ある程度回避できるのではないかと思います。

落とし所2: 延長方式×トークンの再利用方式

users
- user_id
- email
- password
- access_token
- access_token_expired

実装が非常にシンプルであり、セキュリティ的にもバランスの取れた手法なのではないかと思っているのですが、
延長方式、トークンの再利用ともに、一般的ではないように感じます。

別案1:クッキー認証

Laravel の公式のどこかにも、「サードパーティに提供するAPIでないならクッキー認証を使え」的なことが書いてあった気がします。

ここでいうサードパーティが、完全なる第三者のアプリケーションを意味しているのか、
別ドメインでホストされているフロントエンドも含んで言っているのかはわかりませんが。

クッキー認証は、モバイルアプリを考慮するなら即検討外になりますし、
サードパーティクッキーがブラウザによって完全に提供されなくなれば詰むので、
フロントエンドを別ドメインでホストしている場合には、採用を躊躇います。

別案2:JWT

アクセストークンをデータベースで管理しなくてよいので、
問題点2(複数端末問題)の解としてはアリかと思います。

問題点1(有効期限問題)に対しては、
トークン延長方式は使えない(有効期限をトークン自身にもっている)ので、
必然的にリフレッシュトークン方式を取ることになります。

この際、リフレッシュトークンもJWTにするのか、データベース管理とするのかという選択が生じます。

リフレッシュトークンもJWTにする場合、
長期有効なリフレッシュトークンを明示的に無効にできないと困るので、
何らかのデータベース管理は必要になります。

実装との兼ね合い (Laravel)

ライブラリ選定の話ではないと書いておきながら、ライブラリ選定の話なのですが、

言語・フレームワークを問わず「セキュリティ関連は独自実装すべからず」が原則だと思っています。

筆者は Laraveler ですが、
公式に推奨されている Laravel Sanctum は、
問題1に対する解をなんら提供しておらず、利用を躊躇っています。

Laravel Passport は、OAuth ライブラリなので、
自前のSPAやモバイルアプリから利用するには冗長すぎる印象です。

結果、Laravel の認証機構(AuthManager)は使用しつつも、
上記の落とし所で Guard その他を自前実装しているというのが現在地です。

しかし、なるべく認証周りは既製ライブラリを使いたいというのが正直なところで、
最適解を探しています。

諸賢のご意見を聞かせていただけると嬉しいです。

Discussion