はじめに
前回の fragfolio開発ログ #1 では、Laravel 12 × React 19 × Tailwind v4 の最新スタックで香水コレクション管理アプリ「fragfolio」を立ち上げた話を書きました。
今回はその続きとして ログイン認証基盤 について。
2025年時点でログイン体験を考えると、「パスワードレス」と「二要素認証」は避けて通れません。
fragfolio では次の方針を採用しました:
- パスキー(WebAuthn/Laragear):生体認証やPINで快適にログイン
- TOTP(Fortifyの二要素認証):堅実で普及度の高い認証方式
- ユーザー選択式UI:自分の環境に応じて認証方式を選べる
なぜ「ユーザー選択式」なのか
多くの実装は「パスキー優先 → 失敗したらTOTPへ」というフォールバック型です。
ただ、それだと以下のような課題がありました:
- なぜTOTP画面に飛ばされたのか分からない
- 環境によっては最初からパスキーが動かない
- UX的に不透明感がある
そこで fragfolio では、ログイン後に
- 「パスキーで認証」
- 「TOTPで認証」
を ユーザー自身が選択できる方式にしました。
これにより「自分の意思で方式を選んだ」という納得感が生まれ、体験がぐっと良くなります。
FortifyでのTOTP認証
Laravelの公式認証拡張である Laravel Fortify には、TOTP(二要素認証)機能が組み込まれています。
特徴
- 6桁のワンタイムコードによる認証
- リカバリコードが自動生成される(端末紛失時の保険)
- QRコードを表示してGoogle AuthenticatorやAuthyで登録可能
注意点
- 有効化直後に必ずコード入力を要求する仕組みを入れないと「ロックアウト」の危険がある
- フロントエンド(React)では
POST /two-factor-challenge
にcode
を送るだけでシンプル
これで「クラシックな二要素認証」としてのTOTPを提供できます。
WebAuthnをAPI形式で動かす壁
Laragear/WebAuthnの特徴
Laragear/WebAuthn はLaravel向けのWebAuthnライブラリ。
ただしデフォルトでは チャレンジをセッションに保存 する設計です。
SPA+API構成の問題
SPA(React)+Sanctum(Cookieベース)構成ではセッション依存が扱いづらく、API運用でのWebAuthnには不向きです。
解決策:チャレンジをキャッシュに保存
そこで キャッシュ保存リポジトリを自作しました。
class CacheChallengeRepository implements WebAuthnChallengeRepository
{
public function store(AttestationCreation|AssertionCreation $ceremony, Challenge $challenge): void
{
$userId = $ceremony->user->getAuthIdentifier();
$type = $ceremony instanceof AttestationCreation ? 'attestation' : 'assertion';
Cache::put("webauthn_challenge:{$type}:{$userId}", $challenge, now()->addMinutes(10));
}
public function pull(AttestationValidation|AssertionValidation $ceremony): ?Challenge
{
$userId = $ceremony->user->getAuthIdentifier();
$type = $ceremony instanceof AttestationValidation ? 'attestation' : 'assertion';
return Cache::pull("webauthn_challenge:{$type}:{$userId}");
}
}
- ユーザー単位でキー管理
- pull()時に即削除(リプレイ攻撃防止)
- TTLはLaragearのtimeout設定と揃える
- 本番はRedisを推奨
React側:ユーザー選択式の2FA画面
フロー
- メール+パスワードで一次ログイン
- サーバーが
requires_two_factor
とavailable_methods
を返す - UIで「パスキー」か「TOTP」を選択
- 選択に応じて認証処理を分岐
UI例
{twoFactorMethod === null && (
<>
<button onClick={() => setTwoFactorMethod('totp')}>認証アプリ(TOTP)</button>
<button onClick={() => setTwoFactorMethod('webauthn')}>生体認証(WebAuthn)</button>
</>
)}
WebAuthnの処理
const res = await fetch('/api/auth/two-factor-webauthn', {
method: 'POST',
body: JSON.stringify({ temp_token }),
});
const { webauthn_options } = await res.json();
const credential = await navigator.credentials.get({
publicKey: WebAuthnUtils.convertLoginOptions(webauthn_options),
});
await fetch('/api/auth/two-factor-webauthn/complete', {
method: 'POST',
body: JSON.stringify({ temp_token, ...WebAuthnUtils.convertLoginResponse(credential) }),
});
TOTPを選んだ場合は Fortify の /two-factor-challenge
に code
を送信します。
ハマりどころと対策
- HTTPS必須:本番はTLS必須。開発時だけ
http://localhost
が特例で許可される。 - RP IDとOrigin一致:ngrokやサブドメインの切り替えで失敗しがち。
- チャレンジの寿命管理:TTLを短めに設定し、Laragear側と揃える。
- 複数タブ問題:pull()でチャレンジが消えるので、UI側で並行利用を抑制する。
- UX設計:WebAuthnキャンセル時にTOTPへスムーズに切り替えできる導線を用意。
学び
今回の実装で得られた知見は:
- パスキーは便利だが万能ではない。環境によって使えないこともあるため、FortifyによるTOTPを並列に提供する安心感が重要。
- API構成ではキャッシュ保存が必須。セッション依存を避けることでSPAとの親和性が高まる。
- UXの透明性が大事。ユーザーが自分で方式を選べることで納得感が増す。
まとめ
今回の実装で一番大きなポイントは、「パスキーとTOTPを並列に提供し、ユーザーに選ばせる」という設計にしたことです。
- パスキーで最新の快適なログイン体験を提供しつつ、
- FortifyのTOTPで環境を問わない堅実さを確保し、
- Laravel+ReactのAPI構成に合わせてWebAuthnチャレンジをキャッシュ管理に切り替えました。
結果として、セキュリティと利便性のバランスが取れた認証基盤を構築できたと思います。
コメント