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

ASP.NET Core ワンタイムトークンをハッシュ化して一度だけ使用する

メールアドレスの確認やパスワード再設定では、URLに含めたトークンで操作を許可することがあります。

トークンが漏れた場合の影響を抑えるため、有効期限を設定し、使用後は再利用できないようにします。

今回はランダムなトークンを生成し、データベースにはハッシュだけを保存する方法を紹介します。

保存する項目

one_time_tokensテーブル
CREATE TABLE one_time_tokens (
    token_id uuid PRIMARY KEY,
    user_id uuid NOT NULL,
    purpose varchar(50) NOT NULL,
    token_hash bytea NOT NULL UNIQUE,
    expires_at timestamptz NOT NULL,
    used_at timestamptz NULL,
    created_at timestamptz NOT NULL
);

purposeにはemail-verificationpassword-resetなど、トークンの用途を保存します。

用途を確認せずに使うと、メール確認用トークンを別の操作へ流用できる可能性があります。

トークンを生成する

OneTimeTokenService.cs
using System.Security.Cryptography;
using Microsoft.AspNetCore.WebUtilities;

public sealed record IssuedToken(Guid TokenId, string PlainToken, DateTimeOffset ExpiresAt);

public static IssuedToken CreateToken(TimeProvider timeProvider)
{
    var now = timeProvider.GetUtcNow();
    var plainToken = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));

    return new IssuedToken(
        Guid.NewGuid(),
        plainToken,
        now.AddMinutes(30)
    );
}

予測できない値を作るため、RandomNumberGeneratorを使用します。URLへ含めやすいようにBase64URL形式へ変換します。

ハッシュだけを保存する

トークンのハッシュ化
using System.Security.Cryptography;
using System.Text;

public static byte[] HashToken(string plainToken)
{
    return SHA256.HashData(Encoding.UTF8.GetBytes(plainToken));
}

利用者へ送るURLには平文のトークンを含め、データベースにはHashToken(plainToken)の結果だけを保存します。

データベースの内容が漏れた場合でも、保存値をそのまま確認APIへ送信できません。

使用時に行をロックする

同じトークンへ同時に2件のリクエストが届いた場合、検索してから更新するだけでは両方が成功する可能性があります。

トランザクション内で対象行をロックします。

トークンを取得するSQL
SELECT token_id, user_id, purpose, expires_at, used_at
FROM one_time_tokens
WHERE token_hash = @TokenHash
FOR UPDATE;

取得後に用途、有効期限、使用済み状態を確認します。

トークンの検証
var now = timeProvider.GetUtcNow();
var token = await repository.FindForUpdateAsync(
    HashToken(plainToken),
    cancellationToken
);

if (token is null || token.Purpose != expectedPurpose)
{
    throw new InvalidTokenException();
}

if (token.UsedAt is not null || token.ExpiresAt < now)
{
    throw new InvalidTokenException();
}

token.UsedAt = now;
await repository.UpdateAsync(token, cancellationToken);
await transaction.CommitAsync(cancellationToken);

トークンを使用済みにする更新と、メール確認やパスワード変更などの本来の処理は同じトランザクションで実行します。

本来の処理だけ失敗した場合に、トークンだけ使用済みになることを防ぐためです。

比較から情報が漏れないようにする

SQLでハッシュを検索する場合はデータベースが比較を行います。

アプリケーション内で受け取ったハッシュと保存済みハッシュを比較する場合は、固定時間比較を使用します。

ハッシュの比較
var matches = CryptographicOperations.FixedTimeEquals(
    receivedHash,
    storedHash
);

文字列の通常比較で処理時間に差が出ることを避けられます。

再発行時の扱いを決める

新しいトークンを発行したときに、以前の未使用トークンをどうするか決めます。

  • 同じ用途の古いトークンをすべて無効にする。
  • 有効期限内は複数のトークンを許可する。
  • 発行回数や送信間隔を制限する。

パスワード再設定では、最新のトークンだけを有効にする方法が分かりやすい場合があります。

URL、リクエスト本文、例外メッセージに含まれる平文トークンをログへ記録しないようにします。

リバースプロキシやアクセス解析がクエリ文字列を記録する場合もあるため、URLから受け取ったトークンをPOST本文へ移して処理する方法も検討します。

動作を確認する

状況期待する結果
正しい未使用トークン操作を実行して使用済みにする
同じトークンを再利用拒否する
有効期限切れ拒否する
用途が異なる拒否する
同じトークンを同時使用行ロックにより1件だけ成功する

平文を保存しないこと、有効期限を確認すること、使用済みへの更新を原子的に行うことがポイントです。


関連記事