php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【実務完全ガイド】Laravelセキュリティ&信頼性強化――認証/認可・2FA/WebAuthn・CSP/ヘッダー・入力/ファイル・多要素復旧・監査ログ・マルチテナント・アクセシブルな安全設計

この記事で学べること(要点)

  • Laravelにおける認証/認可(Fortify/Sanctum/Policies)の安全な組み立て
  • 2FA・WebAuthn(パスワードレス)・復旧コード・デバイス承認と、だれにでも使いやすいUX
  • 入力検証・ファイルアップロード・SSRF/コマンドインジェクションなど実務で起きる脆弱性対策
  • HTTPS/HSTS/CSP/Permissions-Policy/Referrer-Policy などセキュアヘッダーの具体値と落とし穴
  • 署名付きURL/署名Webhooks/Idempotency・キュー/ジョブの安全運用、秘密情報の保護と鍵ローテーション
  • マルチテナント分離・監査ログ・PIIマスキング・バックアップ/リストア/レジリエンス
  • チェックリスト・落とし穴・サンプルコード一式と、アクセシビリティを両立するエラーデザイン

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:抜け漏れなく基本対策を固めたい方
  • テックリード/PM:SaaS/社内基盤で標準の安全ガイドを策定したい方
  • CS/QA/アクセシビリティ担当:多要素認証やエラー文を誰にでも理解できる形に整えたい方

アクセシビリティレベル:★★★★★

認証/2FA/ロックアウト/復旧の文言と導線、読み上げ(role="status"/alert)、色非依存、キーボード操作、画像CAPTCHAの代替、prefers-reduced-motion に配慮した設計まで具体化しています。


1. 原則:安全と使いやすさは両立できます

セキュリティは、守る対象(資産)×攻撃面(攻撃対象領域)×運用のクセで決まります。Laravel は CSRF・XSS対策・暗号化・認可が標準装備ですが、運用で穴が空きやすいところ(ファイル・Webhooks・多要素認証の復旧・ログ/監査)に手当てが必要です。さらに、ログインや2FAの導線が難しければ人が回避してしまいます。この記事では、守りと使いやすさを同時に高める現場手法を示します。


2. 認証:Fortify/Sanctum とセッションの堅牢化

2.1 パスワードの方針

  • 12文字以上、複雑さより長さを重視(辞書攻撃に強い)
  • 貼り付け禁止にしない(パスワードマネージャ支援)
  • Hash::make()(bcrypt/argon2id)を使用。再ハッシュは needsRehash()
if (Hash::needsRehash($user->password)) {
    $user->password = Hash::make($plain);
    $user->save();
}

2.2 セッション固定化対策

  • ログイン成功時にセッション再生成(Fortify/Laravelは既定で実施)
  • セッションはサーバサイド(Redis等)で管理、SameSite=Lax 以上、Secure/HttpOnly を有効
// config/session.php
'driver' => 'redis',
'secure' => true,
'http_only' => true,
'same_site' => 'lax',

2.3 Sanctum とスコープ(Abilities)

  • SPA:Cookieベース(状態ful)
  • API/モバイル:個別トークン+最小権限(abilities)
  • 重要操作は追加検証(再認証/2FA)を挟む
// 発行例
$token = $user->createToken('cli', ['orders:read','orders:create'])->plainTextToken;
// チェック
abort_unless($request->user()->tokenCan('orders:create'), 403);

3. 多要素認証(2FA)とパスワードレス(WebAuthn)

3.1 TOTP(アプリ型2FA)

  • Fortifyで TOTP/Recovery codes を有効化
  • 2FA入力画面はアクセシブルに:labelaria-describedbyinputmode="numeric"、エラーはrole="alert"
<label for="otp">6桁コード</label>
<input id="otp" name="code" inputmode="numeric" autocomplete="one-time-code"
       aria-describedby="otp-help" class="w-40">
<p id="otp-help" class="text-sm text-gray-600">認証アプリに表示された6桁を入力</p>
  • 連続失敗時は穏やかなメッセージRetry-Afterを提示(ロックアウト暴露を避ける)

3.2 復旧コードとバックアップ手段

  • 有効化時に復旧コードその場で保存させる
  • 失くした場合のサポート導線を明示(本人確認の流れを簡潔に説明)
  • コードはハッシュ化保存し、使ったら無効化

3.3 WebAuthn(生体/セキュリティキー)

  • パスワードレス/多要素どちらでも使える
  • 登録/認証UIはキーボード操作読み上げを優先、キャンセル時の説明を短文で
// 簡易例(PublicKeyCredentialCreationOptions はサーバ生成)
const cred = await navigator.credentials.create({ publicKey: options });

4. 認可(Authorization):Gate/Policy と最小権限

  • 行単位のルールは Policy に集約(view/update/delete
  • 管理UIでは権限一覧を可視化し、ロールを分解して付与
  • 画面の非表示だけでなくサーバ側でも強制
public function update(User $user, Order $order): bool
{
    return $order->user_id === $user->id || $user->can('orders:update:any');
}

5. 入力/出力の安全:XSS/CSRF/SQLi/テンプレート

5.1 XSS

  • Blade の {{ }} は自動エスケープ。HTML挿入が必要な場合は信頼済みの生成結果のみ {!! !!}
  • Markdown/リッチテキストはホワイトリストサニタイズ

5.2 CSRF

  • フォームは @csrf を必ず
  • APIはCookie認証なら sanctum/csrf-cookie、トークン認証ならヘッダーでCSRF対象外に

5.3 SQLインジェクション

  • プレースホルダとビルダを使用し、動的カラム/ORDER BYはホワイトリスト
$allowed = ['created_at','score'];
$col = in_array($req->get('sort'), $allowed, true) ? $req->get('sort') : 'created_at';
$query->orderBy($col, 'desc');

5.4 テンプレートインジェクション

  • ユーザー入力を Blade ディレクティブや Alpine/JS の式に直埋めしない。属性値は x-text を使用

6. ファイル/画像の安全:MIME検証・EXIF・スキャン・配信

6.1 バリデーション

$request->validate([
  'file' => ['required','file','mimetypes:image/jpeg,image/png,application/pdf','max:8192'],
]);
  • MIMEタイプをサーバで確認(拡張子信頼は不可)

6.2 EXIF/位置情報

  • 画像はアップロード時にEXIFを除去、位置情報漏えいを防止

6.3 ウイルススキャン

  • ClamAV などで非同期スキャン→陽性は隔離・通知・削除フロー

6.4 署名付きURL/権限

  • 原本は非公開ストレージ、配布時のみ temporaryUrl()
  • Content-Disposition/Cache-Control を明示し、意図しないダウンロードを防ぐ

7. 外部連携:HTTPクライアント・Webhooks・SSRF/再試行

7.1 送信側(Webhooks発火)

  • 署名ヘッダーを付与(HMAC-SHA256など)、相手側は検証
  • 冪等性キーを送ると重複配信に強い
$payload = json_encode($data);
$sig = hash_hmac('sha256', $payload, config('app.webhook_secret'));
Http::withHeaders(['X-Signature'=>$sig, 'Idempotency-Key'=>$uuid])->post($url, $data);

7.2 受信側(Webhooks受理)

  • まず署名検証リプレイ対策(時刻/nonce)
  • 受信後はジョブに投げて非同期処理、同一イベントIDは無視(冪等)

7.3 SSRF/タイムアウト

  • Http::timeout(10) を必ず設定、接続/応答タイムアウトを分ける
  • 内部IP/メタデータIPへのアクセスを拒否(SSRF対策フィルタ)
  • リダイレクト/Proxy の扱いを最小に

8. ヘッダー/HTTPS/CSP:ブラウザ側の防御を高める

8.1 TLS/HSTS

  • 常時HTTPS、HSTS(max-age=31536000; includeSubDomains; preload)を返す
  • クッキーは Secure/HttpOnly/SameSite=Lax|Strict

8.2 セキュアヘッダー(例)

return response($html, 200, [
  'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
  'X-Frame-Options' => 'DENY',
  'X-Content-Type-Options' => 'nosniff',
  'Referrer-Policy' => 'strict-origin-when-cross-origin',
  'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
]);
  • CSPは段階導入。まず Content-Security-Policy-Report-Only で違反検知→修正→本番適用
  • インラインスクリプトはnonceで限定

9. 署名URL/リンク・不正アクセス検知

  • 重要な操作は署名付きルート(期限つきURL)を使う
$url = URL::temporarySignedRoute('reports.show', now()->addMinutes(30), ['id'=>$report->id]);
  • 不正アクセス試行はRate Limitingアカウントロック方針(過度な情報露出を避ける)
RateLimiter::for('login', fn($req)=>[Limit::perMinute(5)->by($req->ip())]);

10. ジョブ/キューとタスクの安全

  • ジョブは冪等に:同じ入力→同じ出力
  • ユニークロックで重複起動を防止、外部APIは RateLimited ミドルウェア
public function middleware(): array {
  return [ new \Illuminate\Queue\Middleware\RateLimited('external') ];
}
  • 例外は分類(一時/恒久)、再試行回数と待ち時間(指数バックオフ)を明示
  • 機微情報をジョブに直書きしない(IDのみ渡し、実体はサービス層で取得)

11. マルチテナント:境界を“構造で”守る

  • 物理分離(DB/スキーマ)>論理分離(テーブルに tenant_id
  • クエリはグローバルスコープで自動制限、セッション/キャッシュもテナントごとに分離
  • ストレージはパスにテナント識別子を付与し、署名URLでアクセスを限定
// グローバルスコープ例
protected static function booted() {
  static::addGlobalScope('tenant', fn($q)=>$q->where('tenant_id', tenant()->id()));
}

12. ログ/監査とプライバシー

12.1 構造化ログ

  • request_id/user_id/ip/route/status を必ず
  • PII(メール/氏名/住所)はマスクし、ログ出力前にフィルタを通す
Log::info('order.created', [
  'request_id' => request()->header('X-Request-Id'),
  'order_id' => $order->id,
  'user_id' => auth()->id(),
  'amount' => $order->amount,
]);

12.2 監査ログ(Audit)

  • だれが/いつ/なにを/どの値に変えたか
  • 認可/認証イベント・2FA設定/復旧コード表示など重要操作は必ず記録

13. 秘密情報/鍵管理・暗号化・バックアップ

13.1 .env とシークレット

  • .env本番でのみ実値、KMS/シークレットマネージャ運用が理想
  • 共有は暗号化ファイル(age/sops 等)か専用金庫で

13.2 データ暗号化

  • Crypt::encryptString()/decryptString() で限定項目を暗号化
  • バックアップは保存先も暗号化し、鍵は別系統で保管

13.3 鍵ローテーション

  • 新旧鍵の重ね使いで移行(旧鍵で復号→新鍵で再暗号)

14. エラーデザインとアクセシビリティ

  • 認証/2FA/ロックアウトの文言は短く具体的。色だけに依存せず、role="alert" で明示
  • 迷子を防ぐ:次の手段(再送、復旧コード、サポート)を常に提示
  • CAPTCHAは画像依存を避ける(人間らしさ検知/メールリンク/影時間/ハニーポット/RateLimit の組合せ)
  • モーダル/トーストは role="dialog"/status、フォーカス移動と戻り先を管理
<div role="alert" class="mb-2">コードが違います。もう一度お試しください。</div>
<p id="next" class="text-sm">復旧コードを使用する/サポートに連絡する</p>

15. 代表的なコード断片(抜粋)

15.1 ログイン試行のレート制限とロックアウト

RateLimiter::for('login', function ($request) {
  $key = 'login:'.strtolower($request->input('email')).'|'.$request->ip();
  return [Limit::perMinute(5)->by($key)];
});

// コントローラ
if (RateLimiter::tooManyAttempts($key, 5)) {
  $seconds = RateLimiter::availableIn($key);
  return back()->withErrors(['email'=>"試行回数が多すぎます。{$seconds}秒後に再試行してください。"]);
}
// 検証成功でクリア
RateLimiter::clear($key);

15.2 署名付きWebhook検証(受信側)

$payload = $request->getContent();
$signature = $request->header('X-Signature');
$calc = hash_hmac('sha256', $payload, config('services.partner.secret'));
abort_unless(hash_equals($calc, $signature), 401);

15.3 CSP用の nonce をテンプレートに渡す

// Middlewareで
$request->attributes->set('csp_nonce', bin2hex(random_bytes(16)));
// Blade
<script nonce="{{ request()->attributes->get('csp_nonce') }}">/* … */</script>

16. よくある落とし穴と回避策

  • 2FA導線が難しい → 復旧コード代替手段(メールリンク/セキュリティ質問は非推奨)を常に提示
  • 画像CAPTCHAのみ → 代替手段を併用、ハニーポット+レート制限で負担軽減
  • CSPを急に厳格化 → Report-Onlyで違反収集→修正→本番適用
  • Webhooks検証なし → 署名検証/リプレイ対策/冪等化を最初に
  • ファイルを public に直置き → 非公開保存+署名URL
  • 任意ソート/検索の直渡し → ホワイトリスト
  • ログにPIIだだ漏れ → マスキングサニタイズ、アクセス権を最小に
  • キューの再試行無制限 → 回数/待機上限デッドレター方針
  • テナントIDの付け忘れ → グローバルスコープ/ミドルウェアで強制

17. チェックリスト(配布用)

認証/認可

  • [ ] パスワード方針(長さ重視、貼り付け許可、再ハッシュ)
  • [ ] 2FA(TOTP/WebAuthn)と復旧コード、再認証フロー
  • [ ] セッションSecure/HttpOnly/SameSite、固定化対策
  • [ ] Policy/Gate による最小権限

入力/出力

  • [ ] XSS:自動エスケープ、サニタイズ、テンプレ直埋め禁止
  • [ ] CSRF:@csrf、APIは方針明確化
  • [ ] SQLi:ビルダ使用、ソート/検索はホワイトリスト

ファイル/外部連携

  • [ ] MIME検証/サイズ、EXIF除去、スキャン
  • [ ] 署名付きURL、非公開保存
  • [ ] Webhooks署名/リプレイ/冪等、HTTPタイムアウト/SSRF防止

ヘッダー/通信

  • [ ] HTTPS/HSTS、クッキー属性
  • [ ] CSP(Report-Only→本番)、XCTO/Frame-Options/Referrer/Permissions-Policy

キュー/運用

  • [ ] 冪等化、ユニークロック、レート制限
  • [ ] 監視とデッドレター、分類された再試行

マルチテナント/データ

  • [ ] テナント境界(DB/スキーマ/スコープ)
  • [ ] ストレージ分離、キャッシュ/セッション分離

ログ/秘密情報

  • [ ] 構造化ログ(request_id 等)、PIIマスク
  • [ ] 鍵管理/KMS、バックアップ暗号化、鍵ローテ

アクセシビリティ

  • [ ] 認証/2FA画面のlabel/aria-describedby/role="alert"
  • [ ] CAPTCHA代替、色非依存の状態表示
  • [ ] モーダル/トーストのフォーカス移動/戻り先

18. まとめ

  • Laravelの標準装備に加え、2FA/WebAuthn/署名URL/セキュアヘッダー/冪等ジョブを組み合わせ、攻撃面を狭めます。
  • ファイル・Webhooks・テナント分離・秘密情報・ログの運用穴に手当てを。
  • 認証や2FAの導線はアクセシブルで理解しやすく。次の手段を常に用意し、問い合わせコストを下げます。
  • 「安全」は一度の設定では終わりません。監査/計測/改善のループで、静かに強いシステムへ育てましょう。
  • 本記事のチェックリストと断片コードを土台に、チームの標準セキュリティガイドを整備してください。わたしも応援しています。

参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)