Skip to content

TOTP 二要素認証の実装ガイド

概要

このガイドでは NENE2 を使って RFC 6238 TOTP(Time-based One-Time Password)二要素認証を実装する方法を説明します。 Google Authenticator・Authy 互換のシークレット生成・コード検証・リプレイ攻撃防止・ブルートフォースロックアウトを提供します。


DB スキーマ

sql
CREATE TABLE totp_secrets (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id         INTEGER NOT NULL UNIQUE,
    secret          TEXT    NOT NULL,
    is_enabled      INTEGER NOT NULL DEFAULT 0,
    failed_attempts INTEGER NOT NULL DEFAULT 0,
    locked_until    TEXT,
    created_at      TEXT    NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE used_totp_steps (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    time_step  INTEGER NOT NULL,
    used_at    TEXT    NOT NULL,
    UNIQUE (user_id, time_step),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

used_totp_steps テーブルがリプレイ攻撃防止の核心。使用済みの時間ステップを記録する。


エンドポイント設計

メソッドパス説明
POST/users/{id}/totp/setupTOTP シークレット生成(返却後にアプリへ登録)
POST/users/{id}/totp/enableコード検証して 2FA 有効化
POST/users/{id}/totp/verifyコード検証(ログインフロー)
DELETE/users/{id}/totp2FA 無効化(有効コード必須)
GET/users/{id}/totp2FA ステータス取得

RFC 6238 TOTP の実装

php
class TotpGenerator
{
    private const int DIGITS = 6;
    private const int PERIOD = 30; // 秒

    public function computeCode(string $base32Secret, int $timeStep): string
    {
        $secret = $this->base32Decode($base32Secret);

        // 時間ステップを 8 バイト big-endian にパック
        $msg = pack('N*', 0) . pack('N*', $timeStep);
        $hash = hash_hmac('sha1', $msg, $secret, true);

        // Dynamic truncation (RFC 4226 §5.4)
        $offset = ord($hash[19]) & 0x0F;
        $code = ((ord($hash[$offset]) & 0x7F) << 24)
              | ((ord($hash[$offset + 1]) & 0xFF) << 16)
              | ((ord($hash[$offset + 2]) & 0xFF) << 8)
              | (ord($hash[$offset + 3]) & 0xFF);

        return str_pad((string) ($code % (10 ** self::DIGITS)), self::DIGITS, '0', STR_PAD_LEFT);
    }

    public function verify(string $base32Secret, string $code, int $window = 1): ?int
    {
        $t = (int) floor(time() / self::PERIOD);
        for ($offset = -$window; $offset <= $window; $offset++) {
            $step = $t + $offset;
            $expected = $this->computeCode($base32Secret, $step);
            if (hash_equals($expected, $code)) {   // タイミング攻撃防止
                return $step;
            }
        }
        return null;
    }
}

設計のポイント

リプレイ攻撃防止

TOTP コードは 30 秒間有効。同じコードを 2 回使われるとなりすましが可能。 used_totp_steps テーブルで使用済み time_step を記録し、再利用を拒否する。

php
$matchedStep = $this->totp->verify($secret, $code);
if ($matchedStep === null) {
    // コードが無効
    return 401;
}
if ($this->repo->isStepUsed($userId, $matchedStep)) {
    // 同じ time_step のコードが既に使用済み → リプレイ攻撃
    return 401;
}
// 使用済みとして記録
$this->repo->markStepUsed($userId, $matchedStep, $now);

タイミング攻撃防止

TOTP コードの比較には hash_equals() を使う。===strcmp() は文字列比較を早期終了するため、応答時間から一致桁数が推測できる。

php
// NG: タイミング攻撃に脆弱
if ($expected === $inputCode) { ... }

// OK: 定時間比較
if (hash_equals($expected, $inputCode)) { ... }

window 幅(時刻ずれ対応)

window = 1 で現在ステップ ± 1(= ±30 秒)を許容する。 スマートフォンの時刻ずれはほぼこの範囲に収まる。 window を広げるとセキュリティが下がるため 1 を推奨。

ブルートフォースロックアウト

3 回失敗で 15 分ロック(423 Locked)。 ロック中は正しいコードでも拒否する(timing oracle 防止):

php
if ($this->repo->isLocked($userId, $now)) {
    return 423; // ロック中 — 正しいコードか確認しない
}

セットアップフロー

  1. POST /users/{id}/totp/setup でシークレット生成
  2. レスポンスの secret(Base32)または otpauth_uri を Authenticator アプリに登録
  3. POST /users/{id}/totp/enable で初回コードを検証して有効化
  4. 有効化前はシークレットが DB に保存されるが is_enabled = false
otpauth://totp/NENE2:alice?secret=JBSWY3DPEHPK3PXP&issuer=NENE2&algorithm=SHA1&digits=6&period=30

再セットアップで旧シークレット失効

POST /users/{id}/totp/setup を再度呼ぶと旧シークレットが上書きされ、 used_totp_steps も削除される。旧シークレットのコードは認証不可になる。


セキュリティチェックリスト(脆弱性診断 12 件全 Pass)

#確認項目対策
Aリプレイ攻撃used_totp_steps で使用済み time_step を記録
Bブルートフォース3 回失敗で 15 分ロック(423)
Cロック中の正規コードロック判定を先に行い、コード検証を一切しない
D不正な 2FA 無効化DELETE にも有効コードを要求
E不正な 2FA 有効化enable にコード検証必須
F旧シークレット悪用再セットアップで旧シークレット・使用済みステップを削除
GIDORコードはユーザーごとに独立した secret で検証
Hシークレット露出verify/enable レスポンスに secret を含めない
I不正形式コード非一致 → 401(format バリデーションは任意)
J空コードrequired バリデーションで 422
K未有効化での verifyis_enabled チェックで 409
L存在しないユーザーfindUser() → null → 404

テスト上の注意

TOTP コードは時刻依存のため、同じ time_step のコードを続けて使うとリプレイ扱いになる。 テストでは TotpGenerator::computeCode($secret, $gen->currentTimeStep() + N) で異なるステップのコードを生成して使い分ける:

php
$enableCode  = $gen->computeCode($secret, $gen->currentTimeStep());     // enable に使用
$verifyCode  = $gen->computeCode($secret, $gen->currentTimeStep() + 1); // verify に使用
$disableCode = $gen->computeCode($secret, $gen->currentTimeStep() + 2); // disable に使用

参照実装

../NENE2-FT/totplog/ — FT159 フィールドトライアル(21 テスト + 脆弱性診断 12 件 = 32 テスト)

MIT ライセンスの下で公開されています。