簡体字中国語からAIで翻訳しました。原文を読む
最近、Cloudflare Pages とその Functions を使って、CAS に似たシンプルな統合認証システムを実装しようとしている。サーバーレス自体がステートレスであるという制約に加えて、サーバーレスの Redis サービス(Upstash など)を新たに導入したくないので、メール認証コードの場面では、従来の Redis / Session に依存する方式は使えない(;´д`)ゞ
TOTP
ステートレスな認証コードといえば TOTP で、これもよくある二要素認証コードの一種だ。TOTP の生成方法はだいたい、現在のタイムスタンプを有効期限で割り、HMAC-SHA1 / HMAC-SHA256 のような署名アルゴリズムで署名し、最後に署名を固定桁数に切り詰めて認証コードとする、というものだ。サーバー側で同じ計算を行えば、TOTP の有効性を検証できる。
ならば、似たようなステートレスなメール認証コードを実現するときにもこの考え方が使える。メールアドレスと時間ウィンドウ番号(こう呼んでいいはず?)を連結し、先頭数桁を切り出してメール認証コードとし、サーバー側で同じ計算を行えばメールアドレスの有効性を検証できる。
同時に、TOTP のような「時間窓の境界で期限切れになる」問題を防ぐために:
たとえば現在のタイムスタンプが
1721284009で、有効期限を 10 秒に設定すると、時間ウィンドウ番号は1721284009/ 10 =172128400と計算される。1 秒後、時間ウィンドウ番号は
172128401になり、TOTP は無効になる。そのため、その時点で生成された TOTP の有効期限は 1 秒しかない。
サーバー側で有効性を検証するときに、1 つ前の時間ウィンドウまで余分に計算して 2 つの認証コードを算出し、どちらか一方が一致すればよいようにすれば、認証コードの有効期限が常に 1 つの時間ウィンドウの長さより長くなることが保証される。
検証フロー
以上を踏まえた、僕が考えるステートレスなメール認証のフローは次のとおり:
- ユーザーがメールアドレスを入力した後、サーバーのメール検証 API を呼び出す
- メール検証 API 内で:
- 現在のタイムスタンプ(ミリ秒) / 600000(10 分)を計算し、
Time Windowと呼ぶ - メールアドレスと
Time Windowを連結し、Msgと呼ぶ - 設定された
Server Secretを使ってMsgに HMAC-SHA256 署名を行い、結果を 16 進文字列に変換し、Full Codeと呼ぶ Full Codeの先頭 6 文字を切り出し、認証コードとする- 認証コードをメールに送信する
- 現在のタイムスタンプ(ミリ秒) / 600000(10 分)を計算し、
- ユーザーがメール認証コードを入力した後、登録 API を呼び出す
- 登録 API 内で:
- 上記の計算を行い、
Time WindowとTime Window- 1 に対応する認証コードを得る - どちらか一方と一致するか判定する
- 上記の計算を行い、
TypeScript 実装
Cloudflare の Edge Runtime では Node.js の crypto ライブラリを使えないけれど、幸いなことに Web Crypto API に対応しているので、上記の HMAC-SHA256 署名を行い、16 進文字列に変換できる:
export async function hmacSha256String(msg: string, key: string) {
const encoder = new TextEncoder()
const keyBuffer = encoder.encode(key)
const msgBuffer = encoder.encode(msg)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign'],
)
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgBuffer)
const signatureArray = new Uint8Array(signatureBuffer)
const hexString = Array.from(signatureArray)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return hexString
}
そして、上記のフローに沿って、次のような生成関数と検証関数を書ける:
function getCurrentTimeWindow() {
const timestamp = new Date().getTime()
return Math.floor(timestamp / CODE_TIME_WINDOW_SIZE)
}
async function genCodeWithTimeWindow(payload: string[], timeWindow: number) {
const msg = payload.join('-') + timeWindow.toString()
const fullCode = await hmacSha256String(msg, SERVER_SECRET)
return fullCode.substring(0, 6)
}
export async function genCode(
payload: string[],
): Promise<{ code: string; expiration: number }> {
const timeWindow = getCurrentTimeWindow()
const code = await genCodeWithTimeWindow(payload, timeWindow)
const expiration = (timeWindow + 2) * CODE_TIME_WINDOW_SIZE
return {
code,
expiration,
}
}
export async function checkCode(payload: string[], code: string) {
const timeWindow = getCurrentTimeWindow()
const codes = await Promise.all([
genCodeWithTimeWindow(payload, timeWindow),
genCodeWithTimeWindow(payload, timeWindow - 1),
])
if (codes.includes(code)) {
return true
}
return false
}
メールアドレスを検証するだけなら、Payload にはメールアドレスだけを渡せば十分だ。しかし、異なる操作を区別したい場合(たとえば、データベース削除前のメール検証と登録前のメール検証を区別する場合)は、Payload に現在の操作を示す文字列を加えて、認証コードが混用されないようにする必要がある。
制約
- 認証コードは一度生成されると取り消す方法がなく、有効期限内はずっと有効。これもステートレスな方式すべてに共通する難点だよね XD
- メール送信を悪用されやすい。この点は CAPTCHA(Cloudflare Turnstile や Google reCAPTCHA など)を追加することで緩和できる
- ……追加募集中 (?)
このステートレスなメール認証コードはあくまで僕のごく初歩的な構想で、他にも潜在的なセキュリティ上の懸念があるかもしれない。本番環境で他に制約がないなら、素直に従来の Redis / Session 方式を使ったほうがいい。
余談
Cloudflare の中国語公式名が「科赋锐」だと知って、ウケる 2333333