ブログ一覧

アカウントを止めたのに、トークンはまだ使える

· 約10分で読めます

退職者のアカウントを夕方に止めたのに、翌朝まで顧客データを読めた。トークンが発行されてから使えなくなるまでに通る6つの関門を、順に点検する。

事件:止めたはずのアカウントで、翌朝まで読めた

社内の顧客管理システムで、退職者のアカウントを夕方に停止した。翌朝のログを見ると、そのアカウントのトークンで、夜のうちに顧客データが読まれていた。

アクセストークンは24時間有効のJWTで、APIは署名と期限だけを確かめていた。停止は、発行済みのトークンに何の影響も与えていなかった。

こうした事故の原因は、1か所に絞れないことが多い。トークンは、発行されてから使えなくなるまでに6つの関門を通る。

トークンが通る6つの関門。この記事はこの順に点検する

受け取る → 確かめる → データに触れる → 判定をキャッシュする → 取り直す → 取り消す

受け取る:認可コードを、要求したアプリに結びつける

OAuthの認可コードフローでは、アプリが短命の認可コードを受け取り、トークンと交換する。サーバーのアプリは交換にclient secretを使うが、SPAやモバイルアプリは秘密を持てない。配った中身から、誰でも取り出せるからだ。

PKCEでは、アプリがログインのたびにランダムなcode_verifierを作り、そのハッシュだけを最初の要求に付ける。交換のときにcode_verifierを出せるのは、要求したアプリだけだ。横取りされた認可コードは、交換で拒まれる。

TypeScript:SPAでPKCEの値を作り、認可を要求する
// 32バイトの乱数をBase64URLにすると43文字。ログインのたびに作り直す
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem("pkce_verifier", verifier);

// 認可の要求には challenge だけを付ける
const params = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: redirectUri,
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
});
location.assign(`${authorizeUrl}?${params}`);
方式横取りされたら
implicitフロートークンがそのまま使われる(今は使わない)
認可コード + 埋め込んだclient secretアプリを展開すればsecretが読める
認可コード + PKCEcode_verifierがないので交換で拒まれる

確かめる:署名が正しくても、自分宛てとは限らない

1つのIdPは、多くのアプリに同じ鍵で署名したトークンを発行する。署名が示すのは「このIdPが発行した」ことだけで、「自分のAPI向けに発行した」ことではない。

署名と期限だけを確かめていた会社では、誰でも登録できる勉強会用のアプリでログインして得たトークンで、人事のAPIから給与のデータを読めた。宛先は aud に、発行者は iss に入っている。

Go(golang-jwt/jwt v5):宛先と発行者も確かめる
token, err := jwt.Parse(raw, keyFunc, // keyFuncはIdPの公開鍵(JWKS)を返す
	jwt.WithValidMethods([]string{"RS256"}),       // ヘッダーのalgには従わない
	jwt.WithIssuer("https://idp.example.com/"),     // 発行者が完全に一致する
	jwt.WithAudience("https://hr-api.example.com"), // 自分宛てである
	jwt.WithExpirationRequired(),                   // 期限のないトークンは拒む
)

サービスごとに別のaudでトークンを発行し、1つのトークンですべてのAPIを呼べる作りにしない。

データに触れる:ログインしていても、見てよいとは限らない

2,000社が使う請求書のSaaSで、APIは /invoices/1002 のようにIDを受け取り、ログインしているかだけを確かめていた。URLの番号を1つ変えると、他社の請求書が見えた。 これがBOLA(オブジェクト単位の認可の不備)だ。

IDをUUIDにすれば推測はしにくくなるが、共有リンクやログからIDが漏れれば同じことが起きる。データアクセス層でテナントIDを必ず付け、DBの行レベルセキュリティを最後の防壁にする。

SQL:行レベルセキュリティでテナントを絞る(PostgreSQL)
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;  -- テーブルの持ち主にも適用する
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- 検証済みトークンのテナントIDを、このトランザクションだけに設定する
BEGIN;
SELECT set_config('app.tenant_id', $1, true);
SELECT * FROM invoices WHERE id = $2;  -- 他社の請求書は0行になる
COMMIT;

テナントIDは、URLや本文ではなく検証済みのトークンから取る。前の関門でaudとissを確かめていなければ、そのIDも信用できない。設定を忘れたクエリは current_setting がエラーになるので、全件が見える側ではなく失敗する側に倒れる。

判定をキャッシュする:外した直後の許可が残る

権限の判定は回数が多いので、キャッシュから返すのがふつうだ。判定を5分キャッシュしていた社内のドキュメント共有で、異動した人をプロジェクトから外した直後に、人事評価の資料がそのフォルダーに入った。キャッシュには外す前の「読める」が残っていて、4分後に資料を開けた。

すべての判定を最新にすると遅すぎる。代わりに、資料を書いたときの権限の版(SpiceDBのZedToken)を資料と一緒に保存し、読むときはその版以上のデータで判定させる。変わっていない資料は、キャッシュの速い判定のままでよい。

権限の版を資料と一緒に保存し、その版以上で判定する
  1. アリス: ボブを共有から外す
  2. 認可サービス: 権限の版がv2になる
  3. アリス: 資料を保存し、版v2も一緒に保存する
  4. ボブ: 資料を開こうとする
  5. 認可サービス: キャッシュはv1だが、v2以上で判定する
  6. ボブ: 読めない

取り直す:リフレッシュトークンは使うたびに替える

アクセストークンの期限を短くしても、長いセッションはリフレッシュトークンで保たれる。そのリフレッシュトークンが盗まれると、攻撃者は期限が切れるたびに取り直し続けられる。

そこで、使うたびに新しいものに替え(ローテーション)、古いものは無効にする。盗んだ側と正規のアプリのどちらかが使用済みのトークンを出すことになるので、それを検知したら、同じログインから続く一式をまとめて無効にする。

落とし穴は、正規のアプリが自分で使用済みのトークンを出す場合だ。タブを3つ開いた利用者は、3つのタブが同時に取り直し、2つ目以降が再利用と判定されて、毎日のようにログアウトされた。

TypeScript:リフレッシュを、タブの間でも1回にまとめる
let refreshing: Promise<void> | undefined;

export function refreshTokens(): Promise<void> {
  // 同じタブの並列のリクエストは、進行中の1回を待つ
  refreshing ??= navigator.locks
    .request("token-refresh", async () => {
      // ロックを待つ間に、ほかのタブが取り直しているかもしれない
      if (!expiresSoon(storedTokens())) return;
      await exchangeRefreshToken();
    })
    .finally(() => {
      refreshing = undefined;
    });
  return refreshing;
}

通信の切れに備えて、短い猶予期間(Oktaの既定は30秒)を置くと、誤ったログアウトはほぼなくなる。

取り消す:JWTは期限まで止められない

最後の関門が、事件の直接の原因だ。JWTは認証サーバーに問い合わせずに検証できる代わりに、発行したものを後から無効にする仕組みがない。アカウントを止めても、パスワードを変えても、期限まで通る。

だから期限は「止めたあと、どれだけ使われてよいか」で決め、取り消しはリフレッシュトークンで行う。期限を短くするほど穴は小さくなり、取り直しは増える。

アクセストークンの期限止めたあと使える時間1日8時間使う人の取り直し
24時間最大24時間1日1回以下
1時間最大1時間1日8回
15分最大15分1日32回
5分最大5分1日96回

送金や権限の変更のように、すぐ止めたい操作だけは、遅延を払ってでもサーバーで失効を確かめる。Firebase Authenticationの verifyIdToken に checkRevoked を渡すのがこれにあたる。

6つの関門のまとめ

関門確かめ忘れると守り方
受け取る横取りした認可コードでトークンを取られるPKCE
確かめる別のアプリ向けのトークンが通るaudとiss
データに触れるIDを変えるだけで他人のデータが見える持ち主の確認、行レベルセキュリティ
判定をキャッシュする外した直後の資料が見える権限の版を資料と保存
取り直す盗まれたリフレッシュトークンが使われ続けるローテーションと再利用の検知
取り消す止めたあとも期限まで使える短い期限、リフレッシュトークンの取り消し

面接で「JWTでログアウトはどう実装しますか?」と聞かれたら

まず前提を言う。「署名だけで検証するJWTは、期限まで取り消せません」。そのうえで次の順に答える。

  1. アクセストークンの期限を短くする(たとえば15分)
  2. 取り消しは、サーバーが管理するリフレッシュトークンで行う
  3. 停止やパスワード変更では、リフレッシュトークンを一式取り消す
  4. 送金のような重要な操作だけ、サーバーで失効を確かめる
  5. リフレッシュトークンはローテーションし、再利用を検知する

期限の根拠として、「止めたあと何分使われてよいか」という問いを自分から立てると、数字の選び方に理由が付く。

この記事で深掘りしたTips