- 后端
- 认证鉴权
- 身份认证
【免费下载链接】django-allauth
Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication. 🔁 Mirror of https://codeberg.org/allauth/django-allauth/
本文是面向正在使用第三方扩展django-allauth-2fa、希望切换到 django-allauth 内置多因素认证(MFA)实现的团队的技术迁移指南。文章以官方参考迁移代码为核心,结合本仓库中allauth.mfa的模型、适配器与内部流程源码,逐段讲解 TOTP 密钥与恢复码的迁移原理、数据格式差异、加密存储钩子以及迁移后的验证方式,帮助读者在不停用既有用户双因素保护的前提下完成平滑切换。
迁移背景:为什么需要迁移 TOTP 密钥与恢复码
django-allauth 从某个版本起将多因素认证(MFA)作为内置能力提供,涵盖:
- 基于 TOTP 的动态口令认证;
- 基于恢复码(recovery codes)的备援认证;
- 恢复码的查看、下载与重新生成;
- WebAuthn 凭据与 Passkey 登录(默认关闭)。
内置实现位于 allauth/mfa 包中。而历史项目中很多团队使用独立的第三方扩展 django-allauth-2fa 实现双因素认证,该扩展基于django-otp的TOTPDevice与StaticDevice模型保存密钥。两套实现的存储模型完全不同,因此在切换时必须将既有用户的TOTP 密钥与恢复码一并迁入 django-allauth 的Authenticator模型,否则已开启双因素的用户将无法登录。
本仓库官方文档 docs/mfa/django-allauth-2fa.rst 为此提供了参考迁移代码,下文将逐段剖析。
迁移前置条件:启用内置 MFA 应用
在编写迁移命令之前,需要先让内置 MFA 应用处于可用状态。
安装 mfa 扩展依赖
内置 MFA 依赖qrcode等额外包,需要安装mfaextras:
pip install "django-allauth[mfa]"注册应用并执行数据迁移
在项目的settings.py中将allauth.mfa加入INSTALLED_APPS(参见 docs/mfa/introduction.rst):
INSTALLED_APPS = [ ... 'allauth', 'allauth.account', 'allauth.mfa', ... ]随后执行数据迁移,创建Authenticator模型对应的表:
python manage.py migrateAuthenticator的表结构由 allauth/mfa/migrations/0001_initial.py 及其后续迁移定义,其中 0003_authenticator_type_uniq.py 为每个用户施加了(user, type)上的条件唯一约束:对totp与recovery_codes两种类型,同一用户只能各有一条记录。这一点对迁移脚本的编写有直接影响(见下文)。
新旧数据模型对照
迁移的核心是理解两套模型的数据格式差异。
django-otp 侧的模型(迁移来源)
django_otp.plugins.otp_totp.models.TOTPDevice:保存 TOTP 设备,关键字段为key(十六进制字符串)、confirmed(是否已确认启用)与user_id;django_otp.plugins.otp_static.models.StaticDevice:保存静态令牌(恢复码)设备,其下通过token_set关联一组StaticToken,即恢复码本体。
allauth.mfa 侧的模型(迁移目标)
目标模型为 allauth/mfa/models.py 中的Authenticator:
class Authenticator(models.Model): class Type(models.TextChoices): RECOVERY_CODES = "recovery_codes", _("Recovery codes") TOTP = "totp", _("TOTP Authenticator") WEBAUTHN = "webauthn", _("WebAuthn") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) type = models.CharField(max_length=20, choices=Type.choices) data = models.JSONField() created_at = models.DateTimeField(default=timezone.now) last_used_at = models.DateTimeField(null=True)关键差异:
- 所有认证方式统一存放在一张表,通过
type区分,totp、recovery_codes、webauthn三种类型的业务数据全部放在dataJSON 字段中; - TOTP 的
data结构为{"secret": <加密后的 Base32 密钥>},密钥必须为 Base32 编码(与标准 TOTP otpauth URI 的格式一致); - 恢复码的
data结构为{"migrated_codes": [<加密后的恢复码>, ...]},这是一个迁移专用字段:正常新建的恢复码记录使用seed+used_mask的派生方案,而migrated_codes专门用于承接从外部系统迁入的、无法用新方案重新派生的旧恢复码。
为什么 TOTP 密钥要转成 Base32
django-otp 的TOTPDevice.key是随机字节的十六进制表示,而 django-allauth 的 TOTP 实现(见 allauth/mfa/totp/internal/auth.py)在generate_totp_secret()中使用base64.b32encode(random_bytes)生成 Base32 密钥,并在hotp_value()中以base64.b32decode(secret.encode("ascii"), casefold=True)解码。因此迁移时要把十六进制密钥解码回原始字节、再重新编码为 Base32:
secret = base64.b32encode(bytes.fromhex(totp.key)).decode("ascii")只有完成这一转换,用户手机上的 Authenticator 应用才能继续基于同一密钥生成正确的动态口令。
参考迁移命令:完整代码
官方文档给出的迁移脚本是一个 Django management command(BaseCommand),全文如下(来自 docs/mfa/django-allauth-2fa.rst):
import base64 from allauth.mfa.adapter import get_adapter from allauth.mfa.models import Authenticator from django.core.management.base import BaseCommand from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice class Command(BaseCommand): def handle(self, **options): adapter = get_adapter() authenticators = [] for totp in TOTPDevice.objects.filter(confirmed=True).iterator(): recovery_codes = set() for sdevice in StaticDevice.objects.filter(confirmed=True, user_id=totp.user_id).iterator(): recovery_codes.update(sdevice.token_set.values_list("token", flat=True)) secret = base64.b32encode(bytes.fromhex(totp.key)).decode("ascii") totp_authenticator = Authenticator( user_id=totp.user_id, type=Authenticator.Type.TOTP, data={"secret": adapter.encrypt(secret)}, ) authenticators.append(totp_authenticator) authenticators.append( Authenticator( user_id=totp.user_id, type=Authenticator.Type.RECOVERY_CODES, data={ "migrated_codes": [adapter.encrypt(c) for c in recovery_codes], }, ) ) Authenticator.objects.bulk_create(authenticators)逐段剖析迁移脚本
1. 获取适配器实例
adapter = get_adapter()get_adapter()定义于 allauth/mfa/adapter.py,它依据MFA_ADAPTER设置(默认"allauth.mfa.adapter.DefaultMFAAdapter")实例化适配器:
def get_adapter() -> DefaultMFAAdapter: return import_attribute(app_settings.ADAPTER)()迁移脚本通过适配器调用encrypt(),从而自动继承项目自定义的加密策略——这一点非常关键,详见下文"密钥加密存储"小节。
2. 遍历已确认的 TOTP 设备
for totp in TOTPDevice.objects.filter(confirmed=True).iterator():confirmed=True过滤出用户已完成验证、实际可用的 TOTP 设备;未确认的设备(如用户扫码后从未输入过验证码)不具备认证价值,不参与迁移。.iterator()避免一次性将所有设备载入内存,适合大用户量场景。
3. 收集同一用户的恢复码
recovery_codes = set() for sdevice in StaticDevice.objects.filter(confirmed=True, user_id=totp.user_id).iterator(): recovery_codes.update(sdevice.token_set.values_list("token", flat=True))对每个已确认的 TOTP 设备,查找同一用户的已确认StaticDevice,将其token_set中的全部静态令牌收进一个set。使用set有两个作用:
- 自动去重(同一恢复码在多个设备中重复出现时只保留一份);
- 为后续列表推导提供确定性的迭代行为。
注意:恢复码是绑定到 TOTP 用户的,因此以user_id=totp.user_id为关联键聚合,而不是为每个 StaticDevice 单独建一条Authenticator记录。
4. 构造 TOTP Authenticator 记录
secret = base64.b32encode(bytes.fromhex(totp.key)).decode("ascii") totp_authenticator = Authenticator( user_id=totp.user_id, type=Authenticator.Type.TOTP, data={"secret": adapter.encrypt(secret)}, )bytes.fromhex(totp.key):把 django-otp 的十六进制密钥还原为原始字节;base64.b32encode(...).decode("ascii"):重新编码为 Base32 字符串,与 allauth/mfa/totp/internal/auth.py 中generate_totp_secret()的输出格式对齐;data={"secret": adapter.encrypt(secret)}:密钥经适配器加密后存入 JSON 字段。TOTP 校验时,allauth/mfa/totp/internal/auth.py 的TOTP.validate_code()会先decrypt(self.instance.data["secret"])再执行 HMAC-SHA1 动态口令比对,与迁移写入的格式一一对应。
5. 构造恢复码 Authenticator 记录
authenticators.append( Authenticator( user_id=totp.user_id, type=Authenticator.Type.RECOVERY_CODES, data={ "migrated_codes": [adapter.encrypt(c) for c in recovery_codes], }, ) )每个恢复码在存入前同样经过adapter.encrypt(),整份列表挂在migrated_codes键下。这条记录与 TOTP 记录一起被收集,随后统一bulk_create。
6. 批量写入
Authenticator.objects.bulk_create(authenticators)一次性批量创建所有用户的认证记录,性能优于逐条save()。
迁移后恢复码的消费逻辑:migrated_codes 的生命周期
迁入的恢复码并不是静态数据,它会参与后续的认证与校验。其消费逻辑定义在 allauth/mfa/recovery_codes/internal/auth.py 的RecoveryCodes类中:
def _get_migrated_codes(self) -> list[str] | None: codes = self.instance.data.get("migrated_codes") if codes is not None: return [decrypt(code) for code in codes] return None def _validate_migrated_code(self, code: str) -> bool | None: migrated_codes = self._get_migrated_codes() if migrated_codes is None: return None try: idx = migrated_codes.index(code) except ValueError: return False else: migrated_codes = self.instance.data["migrated_codes"] migrated_codes.pop(idx) self.instance.data["migrated_codes"] = migrated_codes self.instance.save() return True def validate_code(self, code: str) -> bool: ret = self._validate_migrated_code(code) if ret is not None: return ret ...从中可以看出三个重要事实:
- 优先消费迁移代码:
validate_code()首先尝试_validate_migrated_code(),只有migrated_codes字段不存在(返回None)时才走基于seed的新方案; - 一次性使用:匹配到某个迁移恢复码后,会将其从
migrated_codes列表中pop并持久化,该恢复码随即失效,不可重复使用——这符合恢复码的安全惯例; - 与原生恢复码共存语义一致:迁移代码同样遵守"用完即废"的语义,只是底层存储从
seed+used_mask位图换成了显式列表。
仓库中的测试 tests/apps/mfa/recovery_codes/test_auth.py 对此进行了验证:
def test_migrated_codes(db, user): auth = Authenticator(user=user, data={"migrated_codes": ["abc", "def"]}) ... assert rc.instance.data["migrated_codes"] == []该测试断言:两个迁移代码依次使用后,migrated_codes列表被清空,从侧面印证了"使用即移除"的实现细节。
密钥加密存储:adapter.encrypt / decrypt 钩子
迁移脚本全程通过adapter.encrypt()写入密钥与恢复码,这是 django-allauth 为密钥存储安全预留的扩展点。默认实现位于 allauth/mfa/adapter.py:
def encrypt(self, text: str) -> str: """Secrets such as the TOTP key are stored in the database. This hook can be used to encrypt those so that they are not stored in the clear in the database. """ return text def decrypt(self, encrypted_text: str) -> str: """Counter part of ``encrypt()``.""" text = encrypted_text return text- 默认行为是原样返回,即密钥明文存入
dataJSON 字段; - 生产环境建议通过设置
MFA_ADAPTER指向自定义适配器,覆盖encrypt/decrypt实现真正的加密(如基于项目的SECRET_KEY派生密钥的对称加密),避免 TOTP 密钥与恢复码以明文形式落库; - 所有读写密钥的路径(TOTP 校验、恢复码校验)都经由
allauth.mfa.utils的encrypt/decrypt薄封装(见 allauth/mfa/utils.py),因此只要适配器实现了对称加解密,迁移写入与运行时读取即自动匹配。
运行迁移前的核对清单
将上述脚本落地为项目内的 management command(例如放入yourapp/management/commands/migrate_allauth_2fa.py)后,建议按以下清单核对后再执行:
- 依赖顺序:脚本依赖
django_otp的模型,应确认项目在迁移完成前仍安装着 django-allauth-2fa / django-otp; - 备份数据库:迁移脚本会创建新记录,执行前对
mfa_authenticator及旧otp_totp_totpdevice、otp_static_staticdevice相关表做备份,以便回滚核对; - 幂等性:参考脚本未做幂等保护,重复运行会因 0003_authenticator_type_uniq.py 引入的
(user, type)条件唯一约束而违反约束报错(同一用户出现两条totp或两条recovery_codes记录)。如需可重复执行,应先对目标用户过滤"已存在Authenticator则跳过"; - 空恢复码处理:若某用户只有 TOTP 设备而没有 StaticDevice,
recovery_codes集合为空,此时仍会创建一条migrated_codes=[]的恢复码记录。可以接受(等价于无恢复码),也可以在脚本中跳过空集合; - 迁移后验证:迁移完成后,用一位测试用户的 TOTP 密钥生成动态口令、用一条旧恢复码各做一次登录验证,确认两条路径(
TOTP.validate_code与RecoveryCodes.validate_code)均正常; - 收尾卸载:确认全部用户迁移成功且验证通过后,方可移除
django-allauth-2fa及django-otp相关依赖与INSTALLED_APPS条目。
迁移后的运行时配置(可选)
迁移完成后,内置 MFA 的默认行为即可满足基本需求。若需调整,可参考 docs/mfa/configuration.rst 中与迁移直接相关的设置项:
MFA_ADAPTER(默认"allauth.mfa.adapter.DefaultMFAAdapter"):自定义适配器路径,用于实现密钥加密等行为定制;MFA_RECOVERY_CODE_COUNT(默认10)、MFA_RECOVERY_CODE_DIGITS(默认8):仅影响新生成的恢复码;迁移进来的旧恢复码保持原样,不受这两个参数影响;MFA_TOTP_PERIOD(默认30)、MFA_TOTP_DIGITS(默认6)、MFA_TOTP_TOLERANCE(默认0):TOTP 的步长、位数与时钟容差。迁移的密钥与这些参数解耦——只要手机端应用使用同一 Base32 密钥,就能在相同时间窗口内计算出正确口令;MFA_RECOVERY_CODES_SHOW_ONCE(默认False):是否只在生成时展示一次恢复码,对已迁入的恢复码同样生效。
小结
从 django-allauth-2fa 切换到 django-allauth 内置 MFA,本质是一次数据迁移:把 django-otp 的TOTPDevice.key(十六进制)转为 Base32 密钥写入Authenticator的totp记录,把StaticDevice的令牌写入recovery_codes记录的migrated_codes字段,全程经由适配器的encrypt()钩子保证与运行时读取格式一致。参考官方迁移脚本(docs/mfa/django-allauth-2fa.rst)配合本仓库 allauth/mfa 的源码理解,即可在保证既有用户双因素保护不中断的前提下完成平滑切换。
- 后端
- 认证鉴权
- 身份认证
【免费下载链接】django-allauth
Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication. 🔁 Mirror of https://codeberg.org/allauth/django-allauth/
相关推荐
django-allauth MFA 配置完全指南:TOTP、恢复码、WebAuthn 与浏览器信任机制
django allauth MFA 配置完全指南:TOTP、恢复码、WebAuthn 与浏览器信任机制 django allauth 的 MFA(多因素认证)
后端认证鉴权身份认证django-allauth MFA 多因素认证入门指南:TOTP、恢复代码与 WebAuthn/Passkey
django allauth MFA 多因素认证入门指南:TOTP、恢复代码与 WebAuthn/Passkey 本文是 django allauth 内置多因
后端认证鉴权身份认证django-allauth MFA 表单深度定制指南:MFA_FORMS 配置与源码级解析
django allauth MFA 表单深度定制指南:MFA_FORMS 配置与源码级解析 本文聚焦 django allauth 的 MFA(多因素认证)模
后端认证鉴权身份认证
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考