ASP.NET Core Refresh Token Rotationでトークンの再利用を検知する
ASP.NET CoreでRefresh Token Rotationを実装する方法を紹介します。
Refresh Token Rotationでは、Refresh Tokenを使用するたびに古いトークンを無効にして、新しいトークンを発行します。
使用済みのトークンが再度送信された場合は、トークンが漏えいした可能性があるため、同じログインセッションで発行したトークンをすべて無効にします。
Refresh Tokenに保存する項目
以下のように、トークンの有効期限だけではなく、無効にした日時、交換後のトークンID、トークンファミリーIDを保存します。
RefreshToken.cspublic 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.tslet refreshPromise: Promise<void> | undefined;
export const refreshToken = async () => {
if (!refreshPromise) {
refreshPromise = requestRefreshToken().finally(() => {
refreshPromise = undefined;
});
}
return refreshPromise;
};このような処理はsingle-flightと呼ばれます。
同じブラウザ内で発生した通常の競合を、トークンの漏えいとして検出してしまうのを防ぎます。
注意点
再利用を検知したときに、利用者が持つすべての端末のトークンを無効にすると、影響範囲が大きくなります。
トークンファミリーはアカウント単位ではなく、1回のログインセッション単位で作成します。
ログアウトするときも現在のトークンファミリーだけを無効にすると、ほかの端末のログインを維持できます。