zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core Refresh Token Rotationでトークンの再利用を検知する

ASP.NET CoreRefresh Token Rotationを実装する方法を紹介します。

Refresh Token Rotationでは、Refresh Tokenを使用するたびに古いトークンを無効にして、新しいトークンを発行します。

使用済みのトークンが再度送信された場合は、トークンが漏えいした可能性があるため、同じログインセッションで発行したトークンをすべて無効にします。

Refresh Tokenに保存する項目

以下のように、トークンの有効期限だけではなく、無効にした日時、交換後のトークンID、トークンファミリーIDを保存します。

RefreshToken.cs
public sealed class RefreshToken
{
    public Guid RefreshTokenId { get; init; }
    public byte[] TokenHash { get; init; } = [];
    public Guid UserId { get; init; }
    public DateTimeOffset ExpiresAt { get; init; }
    public DateTimeOffset? RevokedAt { get; set; }
    public Guid? ReplacedByTokenId { get; set; }
    public Guid TokenFamilyId { get; init; }

    public bool IsRevoked => RevokedAt is not null;

    public bool IsValid(DateTimeOffset now)
        => ExpiresAt >= now && !IsRevoked;
}

TokenFamilyIdは、1回のログインから派生した一連のRefresh Tokenを識別する値です。

トークンを交換しても、TokenFamilyIdは同じ値を引き継ぎます。

Refresh Tokenはハッシュ化して保存する

データベースには、ブラウザへ返したRefresh Tokenをそのまま保存しません。

RefreshTokenの生成
var plainToken = WebEncoders.Base64UrlEncode(
    RandomNumberGenerator.GetBytes(32)
);

var tokenHash = SHA256.HashData(
    Encoding.UTF8.GetBytes(plainToken)
);

ブラウザにはplainTokenを返し、データベースにはtokenHashだけを保存します。

データベースの内容が漏えいした場合でも、保存された値をそのまま認証に使用できないようにします。

使用時に新しいトークンへ交換する

受け取ったRefresh Tokenをハッシュ化し、対象の行をロックして取得します。

Refresh Tokenの取得
SELECT
    refresh_token_id,
    user_id,
    expires_at,
    revoked_at,
    replaced_by_token_id,
    token_family_id
FROM refresh_tokens
WHERE token_hash = @TokenHash
FOR UPDATE;

FOR UPDATEを付けることで、同じトークンを使用した更新処理が同時に成功するのを防ぎます。

有効なトークンの場合は、古いトークンを無効にしてから新しいトークンを作成します。

Refresh Tokenの交換
var now = DateTimeOffset.UtcNow;

if (!oldToken.IsValid(now))
{
    throw new UnauthorizedAccessException();
}

oldToken.RevokedAt = now;

var newToken = CreateRefreshToken(
    oldToken.UserId,
    oldToken.TokenFamilyId,
    now
);

oldToken.ReplacedByTokenId = newToken.RefreshTokenId;

await repository.InsertAsync(newToken, cancellationToken);
await repository.UpdateAsync(oldToken, cancellationToken);

古いトークンの無効化と新しいトークンの保存は、同じデータベーストランザクションで実行します。

使用済みトークンの再利用を検知する

無効になったRefresh Tokenが送信された場合は、単に401 Unauthorizedを返すだけでは不十分です。

正規の利用者と第三者のどちらが新しいトークンを持っているかを、サーバー側では判断できないためです。

使用済みトークンを検出した場合は、同じTokenFamilyIdを持つトークンをすべて無効にします。

トークンファミリーの無効化
if (oldToken.IsRevoked)
{
    var familyTokens = await repository.FindByFamilyIdForUpdateAsync(
        oldToken.TokenFamilyId,
        cancellationToken
    );

    foreach (var token in familyTokens.Where(x => !x.IsRevoked))
    {
        token.RevokedAt = DateTimeOffset.UtcNow;
        await repository.UpdateAsync(token, cancellationToken);
    }

    throw new UnauthorizedAccessException();
}

これにより、交換後の新しいトークンも使用できなくなり、利用者は再ログインが必要になります。

複数リクエストによる競合を防ぐ

画面の表示時に複数のAPIを同時に呼び出すと、複数のリクエストが同時にアクセストークンの期限切れを検出する場合があります。

それぞれが同じRefresh Tokenを送信すると、最初のリクエストだけが成功し、次のリクエストは使用済みトークンの再利用として判定されます。

フロントエンド側では、トークンの更新処理を共有して、同時に1回だけ実行するようにします。

refreshToken.ts
let refreshPromise: Promise<void> | undefined;

export const refreshToken = async () => {
  if (!refreshPromise) {
    refreshPromise = requestRefreshToken().finally(() => {
      refreshPromise = undefined;
    });
  }

  return refreshPromise;
};

このような処理はsingle-flightと呼ばれます。

同じブラウザ内で発生した通常の競合を、トークンの漏えいとして検出してしまうのを防ぎます。

注意点

再利用を検知したときに、利用者が持つすべての端末のトークンを無効にすると、影響範囲が大きくなります。

トークンファミリーはアカウント単位ではなく、1回のログインセッション単位で作成します。

ログアウトするときも現在のトークンファミリーだけを無効にすると、ほかの端末のログインを維持できます。


関連記事