【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)のチェックは通常のrunserverやmigrateではなく、check --deployをデプロイ工程へ組み込んで実行する。MFAの安全性は「登録済みか」だけでなく「現在のセッションが何で認証されたか」まで確認する。
本番のTOTPシークレットやQRコードをブログ・スクリーンショットへ掲載してはいけない。
1. 今日の結論

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.py | MFA・WebAuthn設定 |
config/urls.py | Adminログイン入口を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設定 | 設定しに行けない |
| ログアウト | セッションから抜けられない |
| 再認証 | 認証し直す先へ行けない |
| static | CSSや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_TOLERANCEが1になっている同じ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) はデプロイ用チェックです。
通常の runserver や migrate だけで必ず検査されるとは考えません。
対処
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部)
9日目: パスキーとTOTPの多要素認証(この記事)
連載「10日で学ぶ Django 本番デプロイ」(第2部)
11日目: Linuxサーバー初期設定(公開予定)(公開予定)
12日目: SSHを鍵認証だけにする(公開予定)(公開予定)
追記:CMS側の認証経路も再点検
パスキー実装の再検証に合わせ、記事編集・自動保存・管理者ログインについても再点検しました。
確認した項目は次のとおりです。
メール確認必須設定とユーザーモデルの不整合がないこと
Adminログインがallauthを迂回しないこと
管理者セッションに有効な認証記録が存在すること
パスキー単独ログインだけで管理者向け追加認証済みとみなさないこと
APIと画面で編集権限判定を共有すること
CSRF・セッション切れ時にHTMLレスポンスをJSONとして誤処理しないこと
編集フォーム・本文・ブロック・履歴・自動保存を統合テストすること
テスト用ログインも可能な範囲で本番と同じ認証経路を通すこと
認証の安全性は、
設定値
だけでは判断できません。
最終的には、
利用者が実際に通る経路
+
攻撃者が狙う迂回経路
の両方をテストする必要があります。
コメント(0件)
まだコメントはありません。最初のコメントを投稿してください!