【9日目】Django でパスキー認証――TOTP・WebAuthn・復旧方法まで実装

【9日目】Django でパスキー認証――TOTP・WebAuthn・復旧方法まで実装
目次

【9日目】Django CMSにTOTP・リカバリコード・パスキーを実装する――WebAuthnと管理者MFAの安全設計

連載「10日で作る Django CMS」の9日目です。
成果物: https://github.com/kurumonn/DjangoCMS
対応タグ: day-09

8日目までで、メールアドレス・パスワード・メールログインコード・Google・GitHubを使った認証を実装しました。

9日目は、認証をさらに強化します。

今回追加するのは、TOTP、リカバリコード、パスキー(WebAuthn)、管理者へのMFA必須化、本番設定を検査するDjangoシステムチェックです。

ただし、認証機能は「設定をONにした」だけでは安全になりません。

実装後に経路を見直したところ、管理者向けMFAを迂回できる可能性のある入口が見つかりました。

この記事では、正常系だけではなく、「本来入れない方法で本当に入れないか」まで検証する設計を扱います。


この記事の要点

  • TOTP・パスキーを導入するときは、端末紛失に備えた復旧手段も同時に設計する。

  • リカバリコードだけを日常用MFAとして扱わない。

  • パスキーはフィッシング耐性が高いが、認証時のUser Verificationが常に保証されるとは限らない。

  • allauth 65.18.0のWebAuthn認証は user_verification=preferred で開始される。

  • パスキー単独ログインでは通常のMFAステージがスキップされるため、高権限セッションでは成立経路も確認する。

  • Django標準の /admin/login/ を残すと、allauthのログインフローを迂回できるため入口を統一する。

  • MFA_TOTP_TOLERANCE は秒ではなく、前後に許容するTOTPタイムステップ数である。

  • @register(deploy=True) のチェックは通常の runservermigrate ではなく、check --deploy をデプロイ工程へ組み込んで実行する。

  • MFAの安全性は「登録済みか」だけでなく「現在のセッションが何で認証されたか」まで確認する。

  • 本番のTOTPシークレットやQRコードをブログ・スクリーンショットへ掲載してはいけない。


1. 今日の結論

file_0000000006d08209b3a02f8b791666d5.png

9日目では、パスワードに依存しない認証と、多要素認証を追加します。

実装する機能は次のとおりです。

  • TOTP

  • リカバリコード

  • パスキー(WebAuthn)

  • 管理者へのMFA必須化

  • 管理画面ログイン経路のallauth統一

  • 本番設定を検査するシステムチェック

  • 本番と同じログイン経路を使うテスト

今日いちばん重要なのは、

認証を強化するときは、復旧手段も同時に設計すること

です。

例えば、スマートフォンだけにパスキーを保存していた場合、その端末を失うとログインできなくなる可能性があります。

そのため、このCMSでは次の3層に分けます。

日常の認証
├── TOTP
└── パスキー

復旧
└── リカバリコード

最終復旧
└── 管理者による本人確認

もう1つ重要なのが、

MFAを登録していることと、現在のセッションがMFAを通って成立したことは別

という点です。

実装の再確認では、次の2つの迂回経路が問題になりました。

経路問題
パスキー単独ログインパスワードレスログインでは通常のMFAステージがスキップされる
/admin/login/Django Admin独自のログイン画面はallauthのログインフローではない

そのため管理者では、

MFAを登録しているか
        +
このセッションが何で認証されたか

の両方を確認します。


2. この記事で分かること

この記事を読むと、次の内容が分かります。

  • TOTPがどのようにコードを生成するか

  • MFA_TOTP_TOLERANCE の正しい意味

  • リカバリコードを日常認証として扱わない理由

  • WebAuthnがフィッシングに強い理由

  • User PresenceとUser Verificationの違い

  • パスキーだけで常に2要素とは言えない理由

  • 管理者へMFAを必須化する方法

  • /admin/login/ を残す危険性

  • allauthの認証記録を使ったセッション判定

  • Djangoシステムチェックによる危険設定の検出

  • テスト用ログインを本番の認証経路へ近づける理由


3. 対象読者・前提環境

対象読者は次のような人です。

  • DjangoでCMSを作っている

  • django-allauthを利用している

  • TOTPを導入したい

  • パスキーを導入したい

  • Django Adminを利用している

  • 管理者アカウントの乗っ取り対策を強化したい

この記事では、次の環境を前提にします。

Django                 5.2系
django-allauth          65.18.0
Python                  3.x
fido2                   2.x
qrcode                  8.x
認証                    django-allauth
セッション              Django DB Session

成果物ではバージョンを固定しています。

https://github.com/kurumonn/DjangoCMS

4. 今日の完成画面

MFA設定画面では、利用者が認証手段を管理できます。

多要素認証
├── TOTP
│   ├── 登録
│   └── 無効化
│
├── リカバリコード
│   ├── 発行
│   ├── ダウンロード
│   └── 再発行
│
└── パスキー
    ├── 登録
    ├── 複数登録
    └── 削除

TOTP登録時にはQRコードが表示されます。

TOTP画面を記事へ載せるときの注意

TOTPのQRコードには共有秘密鍵が含まれています。

本番のQRコードを公開すると、TOTP要素そのものが漏えいします。

したがって、スクリーンショットに使うのは必ず、

デモ専用アカウント
+
公開後に破棄する秘密鍵

にします。

「QRコードだけでアカウント全体へ必ずログインできる」という意味ではありませんが、TOTPという認証要素は完全に侵害された状態になります。


5. 今日変更するファイル

config/
├── settings.py
└── urls.py

accounts/
├── middleware.py
├── checks.py
├── apps.py
├── testing.py
└── tests_mfa.py

blog/
├── views.py
└── tests/
    └── factories.py

dashboard/
└── api.py

tools/
└── capture_screenshots.py

主な役割は次のとおりです。

ファイル役割
config/settings.pyMFA・WebAuthn設定
config/urls.pyAdminログイン入口をallauthへ統一
accounts/middleware.py管理者MFA必須化
accounts/checks.py本番危険設定の検査
accounts/testing.py本番相当の認証経路をテストで再現
blog/views.py編集者の権限判定修正
dashboard/api.py画面とAPIで権限判定を共有
tools/capture_screenshots.py記事用画面の自動撮影

6. 用語と認証全体像

TOTP

TOTPは、スマートフォンとサーバーが共有秘密鍵を持ち、時刻から同じワンタイムコードを計算する仕組みです。

イメージとしては、

同じ秘密の計算表
+
現在時刻
=
同じ答え

を両者が別々に計算します。

正式にはRFC 6238で定義されたTime-Based One-Time Passwordです。


リカバリコード

リカバリコードは、TOTP端末やパスキーを失ったときに使う非常用コードです。

通常ログインに毎回使うものではありません。

TOTP端末を紛失
      ↓
パスキーも使えない
      ↓
リカバリコード

という最後の復旧経路として使います。


パスキー

パスキーはWebAuthn/FIDO2を利用した公開鍵認証です。

登録時に端末が鍵ペアを作ります。

端末
├── 秘密鍵 → 端末側で保持
└── 公開鍵 → サーバーへ登録

ログイン時に秘密鍵そのものをサーバーへ送ることはありません。


User PresenceとUser Verification

WebAuthnでは、次の2つを区別します。

User Presence
    ↓
利用者が認証器へ触れた・操作した

User Verification
    ↓
PIN・生体認証などで利用者本人を確認した

「セキュリティキーへ触れた」ことと、「PINや生体認証まで確認した」ことは同じではありません。

この違いが、管理者向けパスキー設計で重要になります。


7. 完成コード

7.1 MFAを有効にする

config/settings.py にMFAアプリを追加します。

INSTALLED_APPS = [
    # ...
    "django.contrib.humanize",

    "allauth",
    "allauth.account",
    "allauth.socialaccount",
    "allauth.usersessions",

    # 9日目
    "allauth.mfa",

    # ...
]

MFA種別を設定します。

MFA_SUPPORTED_TYPES = [
    "totp",
    "recovery_codes",
    "webauthn",
]

このCMSでは3種類をそれぞれ別の役割で使います。

TOTP
→ 日常の追加認証

WebAuthn
→ フィッシング耐性の高い認証

Recovery Codes
→ 認証器を失ったときの復旧

7.2 パスキーログイン

MFA_PASSKEY_LOGIN_ENABLED = True
MFA_PASSKEY_SIGNUP_ENABLED = False

このCMSではログイン後にパスキーを登録します。

初回登録からパスキーだけに依存させず、先にメール確認や復旧経路を整える方針です。

これはCMS側の運用ポリシーです。


7.3 TOTP設定

MFA_TOTP_ISSUER = os.environ.get(
    "DJANGO_MFA_ISSUER",
    "KururuCMS",
)

MFA_TOTP_PERIOD = 30
MFA_TOTP_DIGITS = 6

# 秒数ではない。
# 前後に許容する「time step数」。
MFA_TOTP_TOLERANCE = 1

MFA_TOTP_TOLERANCE は秒ではない

ここは特に注意してください。

MFA_TOTP_PERIOD = 30
MFA_TOTP_TOLERANCE = 1

の場合、

前の30秒枠
現在の30秒枠
次の30秒枠

の3つを検証対象にします。

つまり、

counter - 1
counter
counter + 1

です。

MFA_TOTP_TOLERANCE = 30 とすると、±30ステップを許容することになり、30秒周期なら非常に広い範囲を受け付けてしまいます。

許容幅は必要最小限にします。


7.4 リカバリコード

MFA_RECOVERY_CODE_COUNT = 10
MFA_RECOVERY_CODE_DIGITS = 8
MFA_RECOVERY_CODES_SHOW_ONCE = True

SHOW_ONCE=True にすると、コードは発行時を中心に安全に保管する運用になります。

いつでも一覧表示できる状態より、セッション侵害時の漏えい範囲を小さくできます。

利用者には、

紙に印刷する
パスワードマネージャーへ保存する
安全なオフライン保管を行う

などの方法を案内します。


7.5 WebAuthnの安全設定

MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = (
    os.environ.get(
        "DJANGO_MFA_ALLOW_INSECURE_ORIGIN",
        "0",
    )
    == "1"
)

本番では必ず False にします。

この設定を DEBUG と直接連動させません。

避けたい設定は次です。

MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG

理由は、DEBUG の設定ミスがWebAuthnの保護まで同時に弱めるからです。

独立した設定にします。

なお、通常のブラウザーではWebAuthnはSecure Contextが前提です。

開発では localhost がSecure Contextとして扱われるため、利用しているブラウザーとFIDO2ライブラリで問題がなければ、開発環境でもこの緩和設定を有効にする必要はありません。


7.6 管理者へMFAを必須化する

CMS独自設定として次を用意します。

MFA_REQUIRED_FOR_STAFF = (
    os.environ.get(
        "DJANGO_MFA_REQUIRED_FOR_STAFF",
        "1",
    )
    == "1"
)

このCMSでは is_staff=True の利用者を対象にします。


7.7 MFA登録済みか確認する

管理者に日常利用できる認証器が登録されているか確認します。

from allauth.mfa.models import Authenticator


def has_daily_authenticator(user) -> bool:
    return (
        Authenticator.objects
        .filter(user=user)
        .exclude(
            type=Authenticator.Type.RECOVERY_CODES
        )
        .exists()
    )

リカバリコードだけでは、

MFA設定済み

とは扱いません。

リカバリコードは日常利用する認証器ではなく、復旧手段だからです。


7.8 セッションが何で成立したか確認する

allauthは認証経路をセッションへ記録します。

例えばパスワードとTOTPなら、概念的には次のようになります。

[
    {
        "method": "password",
        "at": 1700000000.0,
    },
    {
        "method": "mfa",
        "type": "totp",
        "at": 1700000001.0,
    },
]

パスキー単独ログインなら、WebAuthnによる記録だけになる場合があります。

この記録を使って、

認証器を登録している

だけでなく、

現在のセッションで
どの認証を通ったか

を確認します。

例えば次のようなヘルパーを用意します。

import time

from django.conf import settings

from allauth.account.authentication import (
    get_authentication_records,
)
from allauth.mfa.models import Authenticator


PRIMARY_METHODS = frozenset({
    "password",
    "password_reset",
    "code",
    "socialaccount",
})


def is_fresh(record: dict) -> bool:
    at = record.get("at")

    if not isinstance(at, (int, float)):
        return False

    timeout = getattr(
        settings,
        "ACCOUNT_REAUTHENTICATION_TIMEOUT",
        300,
    )

    return (time.time() - at) <= timeout


def has_recent_strong_authentication(request) -> bool:
    records = [
        record
        for record in get_authentication_records(request)
        if is_fresh(record)
    ]

    if not records:
        # allauthを通っていないセッションを
        # 安全側に倒して拒否する。
        return False

    primary = False
    additional = False

    for record in records:
        method = record.get("method")

        if method in PRIMARY_METHODS:
            primary = True

        elif method == "mfa":
            mfa_type = record.get("type")

            if mfa_type in {
                Authenticator.Type.TOTP,
                Authenticator.Type.RECOVERY_CODES,
            }:
                additional = True

            elif mfa_type == Authenticator.Type.WEBAUTHN:
                # WebAuthnを追加要素として使った記録。
                additional = True

    return primary and additional

これはこのCMSの高権限セッション用ポリシーです。

allauthそのものの標準MFA判定を置き換えるものではありません。


7.9 パスキー単独ログインを特別扱いする理由

allauth 65.18.0のWebAuthn認証開始処理では、

user_verification = preferred

が指定されています。

preferred は、

可能ならUser Verificationをしてほしい

という指定です。

required のように、

User Verificationされていなければ認証失敗

をサーバー側で必ず要求する指定ではありません。

そのためこのCMSでは、高権限の管理画面に関しては、

パスキー単独ログイン
=
常に「所持 + 生体/PIN」の2要素が保証された

とは判断しません。

これは安全側へ倒したCMS独自ポリシーです。


7.10 管理者必須化ミドルウェア

from urllib.parse import urlencode

from django.conf import settings
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import resolve, reverse


class StaffMfaRequiredMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if self._should_require_setup(request):
            messages.warning(
                request,
                "管理者アカウントではMFA設定が必要です。",
            )
            return redirect("mfa_index")

        if self._should_require_recent_auth(request):
            messages.warning(
                request,
                "管理者画面へ進むには追加の本人確認が必要です。",
            )

            target = reverse("account_reauthenticate")

            query = urlencode({
                "next": request.get_full_path(),
            })

            return redirect(f"{target}?{query}")

        return self.get_response(request)

重要なのは、設定画面そのものを塞がないことです。


7.11 除外しなければならないURL

MFA必須化の対象外にする必要があるのは、主に次の4種類です。

1. MFA設定画面
2. ログアウト
3. 再認証画面
4. 静的ファイル

塞ぐと次の問題が起きます。

対象塞いだ場合
MFA設定設定しに行けない
ログアウトセッションから抜けられない
再認証認証し直す先へ行けない
staticCSSやJSが読み込めない

特に設定画面を塞ぐと、

MFAが必要
 ↓
MFA設定画面へ
 ↓
MFAが必要
 ↓
MFA設定画面へ

という無限リダイレクトになります。


7.12 Django Adminのログイン入口をallauthへ統一する

Django Adminには独自のログイン画面があります。

/admin/login/

この入口をそのまま残すと、

allauthのログイン
        ↓
MFAステージ

とは別経路になります。

そこで admin.site.urls より先にログインURLを定義します。

from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView


urlpatterns = [
    path(
        f"{settings.ADMIN_URL_PATH}/login/",
        RedirectView.as_view(
            pattern_name="account_login",
            query_string=True,
        ),
        name="admin_login_redirect",
    ),

    path(
        f"{settings.ADMIN_URL_PATH}/",
        admin.site.urls,
    ),

    path(
        "accounts/",
        include("allauth.urls"),
    ),
]

DjangoのURLは上から順に判定されるため、Adminより前に置きます。

query_string=True も重要です。

?next=/admin/...

を維持し、ログイン後に元の管理画面へ戻せるようにします。


7.13 本番設定をシステムチェックする

危険な設定をDjangoシステムチェックへ登録します。

from django.conf import settings
from django.core.checks import Error, Warning, register


@register(deploy=True)
def check_mfa_settings(
    app_configs,
    **kwargs,
):
    issues = []

    if getattr(
        settings,
        "MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN",
        False,
    ):
        issues.append(
            Error(
                "本番でWebAuthnのinsecure originが許可されています。",
                hint=(
                    "DJANGO_MFA_ALLOW_INSECURE_ORIGIN=0 "
                    "へ変更してください。"
                ),
                id="accounts.E001",
            )
        )

    if (
        settings.EMAIL_BACKEND
        == "django.core.mail.backends.console.EmailBackend"
    ):
        issues.append(
            Error(
                "メール送信先がコンソールのままです。",
                hint=(
                    "確認メール・ログインコード・"
                    "パスワード再設定メールが届きません。"
                ),
                id="accounts.E002",
            )
        )

    if "recovery_codes" not in getattr(
        settings,
        "MFA_SUPPORTED_TYPES",
        [],
    ):
        issues.append(
            Warning(
                "リカバリコードが無効です。",
                hint="復旧手段を別途確認してください。",
                id="accounts.W001",
            )
        )

    return issues

deploy=True の意味

ここは誤解しやすいところです。

@register(deploy=True)

は、

runserverを実行したら必ず動く
migrateを実行したら必ず動く

という意味ではありません。

デプロイ用チェックとして登録し、

python manage.py check --deploy

で実行します。

したがって、本番ではCIやデプロイスクリプトへ必ず組み込みます。

CI
 ↓
python manage.py check --deploy
 ↓
エラーならデプロイ停止
 ↓
migrate
 ↓
アプリ起動

通常のコマンドでも必ず検査したい項目は、別途 @register() の通常チェックとして実装します。


7.14 apps.pyでチェックを読み込む

from django.apps import AppConfig


class AccountsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "accounts"

    def ready(self):
        from . import checks  # noqa: F401

INSTALLED_APPS では、

"accounts.apps.AccountsConfig",

を使います。


7.15 編集者の権限判定を直す

is_staff と「記事編集者」は同じ意味ではありません。

def _can_edit(user, article) -> bool:
    if not user.is_authenticated:
        return False

    if user.is_staff:
        return True

    if user.has_perm("blog.review_article"):
        return True

    return article.author_id == user.pk

このCMSでは、

blog.review_article

を持つ利用者を編集者として扱います。

APIでも独自条件を書かず、同じ判定関数を使います。

if not _can_edit(request.user, article):
    return JsonResponse(
        {
            "ok": False,
            "error": "この記事を編集する権限がありません。",
        },
        status=403,
    )

8. 内部で起きていること

8.1 TOTPはどうやって同じ数字を作るのか

登録時、サーバーが秘密鍵を生成します。

サーバー
  ↓
共有秘密鍵を生成
  ↓
QRコード
  ↓
認証アプリへ保存

ログイン時は、サーバーと認証アプリが同じ計算をします。

秘密鍵
+
現在時刻
↓
TOTP

通信は必要ありません。

両者が同じ秘密鍵と時刻を持っていれば、同じ結果になります。


8.2 TOTPの計算

概略は次のようになります。

counter = int(time.time()) // 30

次にcounterを8バイトへ変換し、共有秘密鍵を使ってHMAC-SHA1を計算します。

hmac_result = HMAC_SHA1(
    secret,
    counter_bytes,
)

その後、RFC 4226で定義されたDynamic Truncationを行います。

HMAC
 ↓
末尾4bitからoffsetを取得
 ↓
offset位置から31bitを取り出す
 ↓
10^digits で剰余
 ↓
6桁へゼロ埋め

単純に、

HMAC-SHA1の下6桁

を使っているわけではありません。


8.3 TOTP tolerance

MFA_TOTP_PERIOD = 30
MFA_TOTP_TOLERANCE = 1

なら、

counter - 1
counter
counter + 1

を確認します。

30秒周期なので、端末とサーバーの時計ずれを前後1ステップまで吸収します。

許容値を増やすほど、有効なコード候補が増えます。

そのため必要以上に広げません。


8.4 パスキーがフィッシングに強い理由

パスキー登録時には、端末側で鍵ペアが作られます。

秘密鍵
→ 認証器から外へ出さない

公開鍵
→ サーバーへ登録

認証時にはサーバーからチャレンジが送られます。

サーバー
 ↓ challenge
認証器
 ↓ 秘密鍵で署名
サーバー
 ↓ 公開鍵で検証

WebAuthnでは、認証データがRP IDやWeb Originと結び付けられます。

そのため、

本物
https://cms.example.com

偽物
https://cms-example.com

は同じサイトとして扱われません。

偽サイトで作られた認証応答を、そのまま本物のサイトへ再利用することはできません。


8.5 パスワードやTOTPとの違い

パスワードは利用者自身が文字列を入力します。

偽サイト
 ↓
利用者がパスワード入力
 ↓
攻撃者が本物サイトへ中継

TOTPも同様です。

偽サイト
 ↓
6桁コードを入力
 ↓
有効時間内に本物へ中継

WebAuthnでは、認証先そのものへ暗号学的に結び付けられるため、この種のリアルタイムフィッシングに強くなります。


8.6 パスキーは常に2要素なのか

ここが今回の重要ポイントです。

パスキーでは、

秘密鍵を持っている

という所持要素に加えて、

PIN
生体認証

によるUser Verificationを組み合わせられます。

ただし、サーバーが認証時にUser Verificationを必須として検証していることが重要です。

allauth 65.18.0のWebAuthn認証開始では、

user_verification = preferred

です。

preferred は、

できればUser Verificationをしてほしい

であり、

User Verificationが無ければ必ず失敗

ではありません。

そのため、このCMSでは管理者向けポリシーとして、

パスキー単独ログインを常に2要素認証済みとは扱いません。


8.7 パスワードレスログインでは通常MFAがスキップされる

allauthのMFAログインステージは、パスワードレスログインが成立している場合、追加MFAを要求しません。

概念的には、

if did_use_passwordless_login(request):
    return False

です。

通常利用者にとっては便利です。

しかし管理者画面のような高権限領域では、

パスキーを登録しているから安全

だけでなく、

今回のセッションが
何によって成立したか

を別途確認します。


8.8 Django Adminは別の入口を持っている

通常ログインは、

/accounts/login/
 ↓
allauth
 ↓
認証ステージ
 ↓
MFA
 ↓
ログイン

です。

一方Django Adminは、

/admin/login/
 ↓
Django Admin Login
 ↓
ログイン

という独自入口を持っています。

そのため、

MFA機能を実装した

ことと、

すべての入口でMFAを回避できない

ことは別問題です。

Adminログインをallauthへ統一します。


8.9 テスト用ログインが穴を隠すことがある

Djangoテストでは、

client.force_login(user)

が便利です。

しかしこれは、利用者が本番で通るログイン画面やMFAフローを通過しません。

そのため、

テストではログイン済み
↓
しかし認証記録が無い

という状態になります。

認証フローそのものを検証するテストでは、

実際のログインPOST
↓
MFA
↓
セッション成立

まで再現します。


9. 確認方法

MFAアプリを確認する

python manage.py showmigrations mfa

正常なら、

[X] 0001_initial
[X] 0002_...

のように表示されます。


TOTP設定を確認する

python manage.py shell
from django.conf import settings

print(settings.MFA_TOTP_PERIOD)
print(settings.MFA_TOTP_TOLERANCE)

期待値は次です。

30
1

WebAuthnの危険設定を確認する

python manage.py shell -c "from django.conf import settings; print(settings.MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN)"

本番では、

False

である必要があります。


AdminのログインURLを確認する

python manage.py shell -c "from django.urls import reverse; print(reverse('admin_login_redirect'))"

Adminログインがallauthへ転送されることもブラウザーで確認します。


10. コマンドの説明

python manage.py migrate

目的

MFA用テーブルを作成します。

実行場所

manage.py があるディレクトリです。

python manage.py migrate

正常例

Applying mfa.0001_initial... OK
Applying mfa.0002_... OK

異常例

django.db.migrations.exceptions.InconsistentMigrationHistory

判断方法

python manage.py showmigrations mfa

ですべて [X] になっていることを確認します。


python manage.py check --deploy

目的

本番向け設定を検査します。

実行場所

manage.py があるディレクトリです。

Linux / macOS:

DJANGO_DEBUG=0 \
DJANGO_SECRET_KEY='temporary-check-key' \
python manage.py check --deploy

PowerShell:

$env:DJANGO_DEBUG="0"
$env:DJANGO_SECRET_KEY="temporary-check-key"
python manage.py check --deploy

正常例

System check identified no issues

または、Django標準のデプロイ警告のうち意図したものだけが表示されます。

異常例

accounts.E001
accounts.E002
security.W004

判断方法

少なくとも独自の ERROR が0件になることを確認し、Django標準警告も1件ずつ確認します。


python tools/capture_screenshots.py

目的

記事用スクリーンショットを再生成します。

前提

Playwrightを使用する場合は、

pip install playwright
python -m playwright install chromium

を実行します。

正常例

11枚を保存しました。

異常例

警告: 内容が同じ画像があります

判断方法

  • ログイン画面がダッシュボードへ置き換わっていない

  • MFA QRコードがデモ用である

  • 重複画像がない

  • 本番秘密情報が写っていない

ことを確認します。


11. 動作確認

TOTP

  • /accounts/2fa/ が表示される

  • TOTP登録画面が表示される

  • QRコードを認証アプリで読み取れる

  • 正しいコードでTOTPを有効化できる

  • ログアウト後、必要なログイン経路でTOTPを要求される

  • 間違ったコードでは認証が完了しない

  • MFA_TOTP_TOLERANCE1 になっている

  • 同じTOTPコードの再利用が防止されていることを確認する

リカバリコード

  • 10個のコードが生成される

  • コードを安全に保存できる

  • リカバリコードで復旧できる

  • 同じコードを2回使えない

  • リカバリコードだけで「日常MFA設定済み」と扱われない

パスキー

  • WebAuthn設定画面が500にならない

  • django.contrib.humanize が入っている

  • パスキーを登録できる

  • 複数パスキーを登録できる

  • パスキーを削除するとき再認証を要求する

  • パスキーだけでログインできる

  • パスキー単独ログイン後の認証記録を確認する

  • 管理者ではパスキー単独だけで高権限ページへ通さないポリシーが機能する

管理者MFA

  • MFA未登録スタッフはMFA設定へ案内される

  • MFA設定画面自体は開ける

  • ログアウトできる

  • 再認証画面を開ける

  • staticファイルが読み込める

  • allauth認証記録の無いセッションを通さない

  • 古い認証記録だけのセッションを再認証へ送る

  • 一般利用者には管理者MFAポリシーを強制しない

Admin迂回

  • /admin/login/ がallauthログインへ転送される

  • パスワードだけでAdminへ直接入れない

  • ?next= が維持される

  • Adminからallauthを迂回する別ログイン入口が残っていない

システムチェック

  • check --deploy をCIへ組み込んでいる

  • insecure origin有効時に accounts.E001 が出る

  • console email backend時に accounts.E002 が出る

  • recovery codes無効時に警告が出る

  • check --deploy 失敗時に本番デプロイを止める


12. よくあるエラー

12.1 MFA_TOTP_TOLERANCE = 30 にする

問題

30秒の意味ではありません。

30ステップを前後へ許容します。

対処

MFA_TOTP_PERIOD = 30
MFA_TOTP_TOLERANCE = 1

とします。


12.2 MFA設定画面をミドルウェアで塞ぐ

症状

ERR_TOO_MANY_REDIRECTS

原因

MFA未設定
 ↓
設定画面へ
 ↓
MFA未設定
 ↓
設定画面へ

を繰り返しています。

対処

MFA設定・ログアウト・再認証・staticを除外します。


12.3 force_login() だけでMFAテストをする

問題

allauthの認証記録が作られません。

対処

認証フローを調べるテストでは、実際のログインPOSTを通します。


12.4 編集者が他人の記事を編集できない

原因

is_staff と編集者ロールを混同しています。

対処

CMSの業務権限で判定します。

user.has_perm("blog.review_article")

などを利用します。


12.5 Adminログインを残す

問題

MFAを実装していても、別入口からallauthを通らずログインできます。

対処

/admin/login/ をallauthへ統一します。


12.6 check --deploy が自動で毎回実行されると思う

問題

@register(deploy=True) はデプロイ用チェックです。

通常の runservermigrate だけで必ず検査されるとは考えません。

対処

CI・デプロイ手順で、

python manage.py check --deploy

を必須にします。


13. セキュリティ上の注意

復旧手段を必ず作る

強い認証ほど、紛失時の影響が大きくなります。

スマートフォン故障
       ↓
パスキー使用不能
       ↓
TOTPも同じ端末
       ↓
ログイン不能

を防ぐため、別の場所へリカバリコードを保管します。

可能ならパスキーも複数端末へ登録します。


1台へ全部集めすぎない

例えば、

スマートフォン
├── メール
├── TOTP
└── パスキー

のすべてを1台だけに依存すると、その端末の紛失がすべての認証手段へ影響します。

利便性と復旧性の両方を考えます。


パスキーを過大評価しない

パスキーには大きな利点があります。

  • フィッシング耐性

  • 秘密鍵をサーバーへ送らない

  • パスワード漏えいの影響を受けない

一方で、

その認証セッションでUser Verificationがどの強さで要求・検証されたか

まで見なければ、高権限領域で「常に2要素」と断定するのは危険です。


MFAを有効化しただけで安心しない

次を別々に確認します。

MFAが動く
        ↓
正常系テスト

MFAを通らず入れない
        ↓
迂回テスト

正常系だけでは不足します。


本番のQRコードを公開しない

TOTP秘密鍵を含むQRコードは秘密情報です。

デモ用アカウント以外のQRコードを、

  • ブログ

  • GitHub

  • スクリーンショット

  • 動画

  • チャット

へ載せてはいけません。


リカバリコードを平文のメモへ放置しない

PCのデスクトップへ、

recovery-codes.txt

として保存したままにすると、マルウェアやバックアップ共有などから漏れる可能性があります。

安全な保管場所を決めます。


WebAuthnのinsecure originを本番で許可しない

MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = False

を本番の原則とします。

HTTPSはWebAuthnだけではなく、セッションCookieや認証情報全体の保護にも必要です。


14. 再発防止

今回のMFA迂回を再発させないため、テストの考え方を変えます。

従来は、

正しい認証をすると入れるか

を中心に確認していました。

今後は、

入れてはいけない方法で
本当に入れないか

もテストします。

例えば、

def test_password_alone_cannot_enter_admin(self):
    # MFA設定済みスタッフを作る
    # ↓
    # Admin入口へパスワードだけで入ろうとする
    # ↓
    # 管理画面200にならないことを確認
    ...

さらに、

def test_session_without_allauth_records_is_blocked(self):
    ...

のように、allauthを通っていないセッションも確認します。

セキュリティ機能では、

機能テスト
+
迂回テスト
+
本番設定チェック

の3層にします。


15. まとめ

9日目では、CMSの認証を次の構成へ強化しました。

ログイン
├── パスワード
├── メールログインコード
├── Google / GitHub
└── パスキー

追加認証
├── TOTP
└── WebAuthn

復旧
├── リカバリコード
├── 別のパスキー
└── 管理者本人確認

重要なのは、認証機能の数ではありません。

MFAを有効にしたあと、そのMFAを通らずに入れる入口が残っていないか確認することです。

今回見つかった問題は、

/admin/login/

と、

パスキー単独ログイン

でした。

どちらも通常の画面操作では問題なく動いているように見えます。

だからこそ、

「本来失敗するはずの操作」

をテストする必要があります。

そして認証を強くしたら、必ず復旧経路も用意してください。

安全性と復旧性の両方がそろって、初めて実用的な認証設計になります。


16. FAQ

Q1. TOTPはインターネット接続が無くても使えますか?

使えます。

共有秘密鍵と現在時刻から、端末とサーバーが独立に同じコードを計算します。


Q2. MFA_TOTP_TOLERANCE = 1 は1秒という意味ですか?

違います。

前後に許容するタイムステップ数です。

30秒周期なら、現在と前後1ステップを検証します。


Q3. パスキーだけでMFAになりますか?

環境と認証ポリシーによります。

パスキーはUser Verificationと組み合わせれば複数要素相当の特性を持てますが、サーバーがUVを必須として検証していることが重要です。

このCMSでは管理者について安全側に倒し、パスキー単独ログインを自動的に2要素認証済みとは扱いません。


Q4. パスキーはTOTPより安全ですか?

フィッシング耐性では大きな利点があります。

しかし端末紛失や復旧設計など別の課題があります。

単純な優劣ではなく用途を分けます。


Q5. リカバリコードだけ登録しておけば十分ですか?

日常の認証手段としては推奨しません。

リカバリコードは認証器を失ったときの非常手段として扱います。


Q6. Adminを使わなければ /admin/login/ の問題は関係ありませんか?

Django Adminを完全に公開していない構成なら影響は限定されます。

Adminを利用する場合は、認証経路を必ず確認してください。


Q7. check --deploy は本番サーバー起動時に自動で動きますか?

自動実行される前提にはしません。

CIやデプロイスクリプトで明示的に実行してください。

python manage.py check --deploy

17. Gitの差分

タグ:

day-09

コミット:

day-09: TOTP・リカバリコード・パスキー(WebAuthn)を実装

8日目との差分を確認します。

git diff day-08 day-09

MFA関連だけを見る場合は、

git show day-09 -- \
  accounts/middleware.py \
  accounts/checks.py \
  config/settings.py \
  config/urls.py

を使います。

注意

day-09 タグには、記事公開後の再点検で見つかった修正前の内容が含まれている箇所があります。

特に、

MFA_TOTP_TOLERANCE = 30

は、この修正版記事では 1 として扱います。

また、管理者MFA判定についても、「認証器を登録しているか」だけではなく、現在のセッションの認証記録まで確認する設計へ更新します。


18. 次回予告

10日目は、CMSを本番運用できる構成へ仕上げます。

扱う内容は次のとおりです。

  • PostgreSQL

  • Redis

  • Docker Compose

  • 設定ファイルの分割

  • SECURE_*

  • HSTS

  • 本番メール

  • 静的ファイル

  • バックアップ

  • 復元テスト

  • check --deploy

  • 本番用CI

次回:

【10日目】Django CMS完成――Docker・PostgreSQL・Redis・本番設定

https://kurutann.com/diary/detail/6241/


19. 参考情報

django-allauth 65.18.0 MFA Configuration

資料名:

django-allauth MFA Configuration

URL:

https://github.com/pennersr/django-allauth/blob/65.18.0/docs/mfa/configuration.rst

django-allauth 65.18.0 WebAuthn Implementation

URL:

https://github.com/pennersr/django-allauth/blob/65.18.0/allauth/mfa/webauthn/internal/auth.py

django-allauth 65.18.0 MFA Login Stage

URL:

https://github.com/pennersr/django-allauth/blob/65.18.0/allauth/mfa/stages.py

django-allauth Authentication Records

URL:

https://github.com/pennersr/django-allauth/blob/65.18.0/allauth/account/internal/flows/login.py

RFC 6238――TOTP

URL:

https://www.rfc-editor.org/rfc/rfc6238

RFC 4226――HOTP

URL:

https://www.rfc-editor.org/rfc/rfc4226

W3C Web Authentication

URL:

https://www.w3.org/TR/webauthn-3/

Django System Check Framework

URL:

https://docs.djangoproject.com/en/5.2/topics/checks/

Django Admin

URL:

https://docs.djangoproject.com/en/5.2/ref/contrib/admin/

DjangoCMS

URL:

https://github.com/kurumonn/DjangoCMS

day-09

URL:

https://github.com/kurumonn/DjangoCMS/tree/day-09

連載「10日で作る Django CMS」(第1部)

連載「10日で学ぶ Django 本番デプロイ」(第2部)

  • 11日目: Linuxサーバー初期設定(公開予定)(公開予定)

  • 12日目: SSHを鍵認証だけにする(公開予定)(公開予定)


追記:CMS側の認証経路も再点検

パスキー実装の再検証に合わせ、記事編集・自動保存・管理者ログインについても再点検しました。

確認した項目は次のとおりです。

  • メール確認必須設定とユーザーモデルの不整合がないこと

  • Adminログインがallauthを迂回しないこと

  • 管理者セッションに有効な認証記録が存在すること

  • パスキー単独ログインだけで管理者向け追加認証済みとみなさないこと

  • APIと画面で編集権限判定を共有すること

  • CSRF・セッション切れ時にHTMLレスポンスをJSONとして誤処理しないこと

  • 編集フォーム・本文・ブロック・履歴・自動保存を統合テストすること

  • テスト用ログインも可能な範囲で本番と同じ認証経路を通すこと

認証の安全性は、

設定値

だけでは判断できません。

最終的には、

利用者が実際に通る経路
+
攻撃者が狙う迂回経路

の両方をテストする必要があります。

読んだ内容を10問練習と実技で確認

記事で理解した用語を、StudyQuestの演習とクラウド実技ラボで定着させます。

10問練習 実技ラボ

コメント(0件)

まだコメントはありません。最初のコメントを投稿してください!

コメントを投稿