资讯动态

Django密码存储实战:从PBKDF2到Argon2的渐进式安全迁移

发布时间:2026/10/13 18:19:38 来源:尧图企业网站定制
1. 从“存明文”到“加盐哈希”Django密码存储的安全演进逻辑1.1 密码直接进数据库为什么是灾难级操作我刚入行时接过一个老项目第一件事是打开数据库看用户表。当时心里一凉密码字段清清楚楚存着明文。不是说这个项目团队不负责任而是项目上线年代太早当年“密码加密存储”还不是默认常识很多业务系统直接把密码塞进数据库就算完事。后来这个项目被脱裤百万级用户数据全部裸奔所有账号沦为撞库样本。从那以后我就养成一个习惯不管接手什么Django项目第一件事就是审查密码存储方案。这里先讲清楚一个认知哈希不等同于加密。加密是双向的有密钥就能解回原文哈希是单向的理论上除了暴力穷举没有任何办法从结果反推输入。密码存储要用的恰恰是单向哈希因为我们永远不应该有能力从数据库里把用户的明文密码“找回来”——哪怕数据库管理员、哪怕拿到服务器权限的人都不该看到用户密码。那直接用现成的哈希函数把密码算一下存下来不行吗比如MD5、SHA-1、SHA-256。答案是不行而且是彻底不行。原因有两层。第一层是彩虹表问题攻击者可以提前把常见密码字典、生日、键盘组合全部算成哈希值存成一张大表拿到数据库中的哈希串直接查表就能反推出明文复杂度接近零。第二层是速度问题现代CPU和GPU对MD5/SHA系列的计算速度快到每秒几十亿次攻击者拿到一个无盐哈希可以用纯暴力把8位纯数字组合瞬间跑完。换句话说哈希函数设计的目标是“计算高效”可密码存储要的恰恰是“计算昂贵”——这个矛盾是所有密码存储方案设计的起点。1.2 加盐让相同密码不再是同一个哈希即使是同一个用户在不同网站注册同一个密码存储值也必须完全不同即使是同一个网站里的两个用户恰好选了同一个密码存储值也不能一样。做到这一点靠的就是盐Salt。盐是一串随机生成的字符串在计算密码哈希前先拼进密码里。公式变成这样hash(密码 盐)。盐每次随机生成因此同样明文密码在不同盐的作用下会得到完全不同的哈希值。这样一来攻击者预计算的彩虹表全部作废——因为彩虹表里每一条都得在某种特定盐值下才有意义而每个用户的盐都不一样为几百万用户预计算几百万张表是不现实的。这里有一个很多人会搞错的关键认知盐是不需要保密的。盐的作用不是“增加破解难度所以必须藏起来”而是“让每个用户的哈希结果彼此独立打散批量破解的规模效应”。换句话说哈希串里明文携带盐是设计使然而不是设计缺陷。你藏不住它也大可不必藏。1.3 从“哈希一次”到“迭代成千上万次”盐解决的是“批量破解”问题但还没解决“单点破解速度快”的问题。攻击者拿到某个具体用户的盐和哈希值之后依然可以对常见密码字典逐个试把候选密码拼上盐、算哈希、比对。如果只算一次几十亿次每秒的速度依然让破解变得轻而易举。所以密码哈希算法又引入了“迭代”的概念不把密码拼上盐算一次哈希就收工而是把这个计算过程重复成千上万次。每一次迭代的结果作为下一次的输入最终输出的哈希值只有在完全相同地重复同样次数时才能验证。计算一次需要几十毫秒对正常用户登录来说几乎无感但对攻击者来说原本每秒能试几十亿次的暴力破解直接跌到每秒只能试几百次、几千次。用可接受的响应时间成本换取攻击者几万倍的计算成本放大——这是所有密码哈希方案的利润空间。2. PBKDF2算法链拆解Django默认方案为什么是它2.1 Django到底在跑什么计算打开Django项目给用户设置一个密码存进数据库的哈希串长这样pbkdf2_sha256$390000$M9xR7tK2wE5j$b2a4e8f1c3d5...这不是一串乱码它由四个字段用美元符号拼接而成算法名、迭代次数、盐、最终哈希结果。Django默认使用的算法是PBKDF2基于密码的密钥派生函数第2版以HMAC-SHA256作为内部的伪随机函数。整个计算过程用伪代码表达是这样的def pbkdf2(password: bytes, salt: bytes, iterations: int, dklen: int) - bytes: # U1 PRF(password, salt INT_32_BE(i)) # i从1开始 # U2 PRF(password, U1) # ... # U_c PRF(password, U_{c-1}) # 最终输出 U1 XOR U2 XOR ... XOR U_c ...具体到Django每一个新密码的生成流程使用系统安全随机源生成一段长度为12字节左右的随机盐转成字符串把密码编码成字节串盐编码成字节串对盐 || 块索引32位大端整数从1开始做第一次HMAC-SHA256Key是密码得到第一轮输出将上一轮输出作为本轮输入继续做HMAC-SHA256如此反复迭代390000次把所有轮次结果按位异或合并作为该密码的最终派生哈希把算法名、迭代次数、盐、最终哈希用美元符号组装成那串存储值。这套设计里密码其实是HMAC算法中的Key盐被放进消息里。这样设计的好处是即使两个用户盐相同极小概率只要密码不同HMAC输出也完全无法预测而HMAC本身的密码学强度保证了每一轮迭代都在做一次完整的单向压缩操作。2.2 为什么默认迭代次数是390000Django直接写死在源码里的默认值是390000轮这个数字不是拍脑袋定的。它基于一个权衡官方希望一次密码哈希的计算时间在主流服务器硬件上大约落在30到60毫秒区间。这个范围内用户登录过程中的额外延迟在可接受范围内同时对GPU批量破解形成了明确的计算门槛。实际上写代码时不应该依赖这个默认值而应该在项目自己的配置里显式设置。比如PASSWORD_HASHERS [ django.contrib.auth.hashers.PBKDF2PasswordHasher, ] # 覆盖默认迭代次数 from django.contrib.auth.hashers import PBKDF2PasswordHasher as Base class PBKDF2PasswordHasher(Base): iterations 600_000 PASSWORD_HASHERS[0] myproject.hashers.PBKDF2PasswordHasher需要注意迭代次数是存在哈希串里的。老用户哈希串里存的是390000代码里定义的是600000校验时Django会以哈希串里记录的迭代次数为准来重算而不是以代码里的值为准。这个设计直接支撑了后面要讲的“在线升级”机制先说结论改迭代次数不会让老用户登录失败。2.3 官方为什么选PBKDF2而不直接用bcrypt或者Argon2如果你研究过密码存储方案大概率听过bcrypt、scrypt、Argon2这些名字。早些年Django一直把PBKDF2作为默认方案根本原因不是PBKDF2天下无敌而是它有两个别人替代不了的实际优势。第一是合规与标准兼容。PBKDF2是NIST发布的正式标准在很多合规审计场景下直接说“我用了国家标准的密钥派生函数”比说“我用了第三方算法”要少很多解释成本。第二是硬件友好度。PBKDF2的每次迭代都是HMAC-SHA256SHA系列在现代CPU上有硬件加速指令在普通服务器上也能保持稳定的计算吞吐。这一点听起来不起眼但在高并发登录场景下直接影响服务端的CPU预算。相比之下bcrypt的Blowfish算法是基于桌内存访问的表驱动存在侧信道风险争议scrypt和Argon2都强调大内存占用出场就是奔着“让GPU并行破解无利可图”去的。所以Django的设计哲学是默认配置必须保守、稳定、让人挑不出毛病更强的方案留给想折腾的人自己换。早几年如果打开新项目的设置Django甚至会在文档里明确说“我们不打算把Argon2设成默认值”直到4.x之后才逐渐把重心移到Argon2id。理解了这一点再回头看默认的PBKDF2就会明白这绝不是一个“过时方案”而是一个在安全性和工程性之间取了最大公约数的选择。3. 密码哈希字符串解析四段字段与版本兼容设计3.1 一段真实哈希的拆解随便打开一个Django用户表的password字段你会看到类似下面的内容pbkdf2_sha256$600000$Ab3RfT8yZq0L$s7XK9pQw2eR4...用美元符号拆分四个字段的含义如下位置字段示例作用1算法标识pbkdf2_sha256告诉Django用哪个哈希器2迭代次数600000重算时需要迭代多少轮3盐Ab3RfT8yZq0L参与哈希计算的随机串4最终哈希值s7XK9pQw2eR4...前三个字段共同作用后的计算结果这种“参数和哈希一起存储”的方式初看会觉得奇怪盐是密码学里用于混淆的要素就这样存进数据库不等于把钥匙和锁放在一起吗但它恰恰是整个密码校验流程能够正常运转的基石。校验密码时系统需要知道三件事用哪个算法、按多少轮迭代、用什么盐。这些参数如果写死在代码里未来一旦调整方案老用户的密码就永远无法校验了。而把它们跟在哈希后面存等于每一条哈希记录都自带完整的“算法说明”系统可以精确复现当初生成它时的一切条件。3.2 这种格式如何支撑多算法平滑共存Django在源码里维护着一张哈希器注册表键名就是上面说的算法标识HASHERS { pbkdf2_sha256: PBKDF2PasswordHasher, pbkdf2_sha1: PBKDF2SHA1PasswordHasher, argon2: Argon2PasswordHasher, bcrypt_sha256: BCryptSHA256PasswordHasher, scrypt: ScryptPasswordHasher, md5: MD5PasswordHasher, # 仅用于历史兼容 unsalted_sha1: UnsaltedSHA1PasswordHasher, # 仅用于历史兼容 }当check_password收到一个哈希串时它先按美元符号切出第一段算法标识然后去这张表里找到对应的Hasher类用哈希串里自带的迭代次数和盐重新计算候选密码再用恒定时间比较函数比对结果。整个过程不依赖任何配置文件里的“当前密码算法”——这条记录当初是用什么算法生成的就还是用什么算法去验证。这意味着同一个项目的数据库里可以同时存在几十种不同算法生成的密码哈希。有的用户账号是2008年注册的存的是老式MD5有的用户昨天刚改过密码存的是Argon2id今天新注册的账号存的是PBKDF2-600000。校验时各自按自己的元数据走谁也不会出错。这种多算法共存的兼容能力正是大型系统安全升级时不需要强制用户改密码就能平滑过渡的先决条件。3.3 读取与创建哈希串的内部逻辑创建密码哈希时Django实际走的是make_password这条链路。它挑出settings.PASSWORD_HASHERS列表中第一个可用的Hasher做以下几件事class PBKDF2PasswordHasher(BasePasswordHasher): algorithm pbkdf2_sha256 iterations 390000 def encode(self, password, salt, iterationsNone): iterations iterations or self.iterations hash pbkdf2(password, salt, iterations, dklen32) return f{self.algorithm}${iterations}${salt}${hash}盐的生成用的是secrets.token_hex(12)也就是24个十六进制字符来自操作系统的加密安全随机源。这里有一个很容易被忽略的细节Django官方并不建议你自己去重写编码逻辑因为一旦你有意无意地把盐改成固定值、把迭代次数降低到几百整个安全模型瞬间崩塌。在日常开发里不要对密码哈希字段做任何形式的二次处理——比如截断、转大写、序列化成别的格式都会让后续校验直接失败或产生安全问题。4. 密码校验与在线升级check_password背后的完整流程4.1 从set_password到make_password的调用链在Django的认证系统里设置密码非常直接user User.objects.get(usernamealice) user.set_password(new-pass) user.save()set_password内部做的事情看起来简单实际包含几个关键步骤生成随机盐、使用当前项目中优先级最高的密码哈希器计算哈希、再赋给user.password字段。这里不要自己手动去调make_password再拼字符串直接使用set_password是标准做法因为它还承担了一项隐藏职责——把哈希器版本变化导致的任何异常路径都挡在门外。校验密码的入口是user.check_password(input-pass)它内部先取数据库里存的哈希串根据算法标识找到哈希器再用记录中的盐和迭代次数去重算候选密码。如果最后一步比对不一致返回False。4.2 恒定时间比较挡住侧信道攻击的细节很多开发者会忽略比对环节的安全问题。如果Django在校验时用普通的字符串相等比较密码验证的结果成功/失败会在响应时间上留下微小的差异——哈希串前面几个字符碰上了就早退出碰不上就晚一点。这看起来微不足道但在局域网或云环境里攻击者可以通过成千上万次试探测量出“候选密码和真实密码到底在第几个字符开始不同的”从而逐位还原出真实哈希最终反向推动出密码。这就是时序侧信道攻击。Django在校验密码时用的是hmac.compare_digest()或者Django自身封装的constant_time_compare。它的核心特征是无论两个字符串在前几个字节有多匹配整个循环都会执行到底耗时只取决于字符串长度不取决于内容匹配程度。永远不要自作聪明地把这个比对替换成普通这类改动在测试环境测不出来问题上了生产就是给攻击者留后门。4.3 在线自动升级老哈希怎么悄悄变成新哈希前面反复提到迭代次数存在哈希串里这个设计的意义终于要在这个环节体现了。想象这样一个场景团队决定把迭代次数从390000提升到600000上线后那些老用户账号里存的哈希串依然带着390000字段。如果严格按照“哈希串里的参数”来校验老用户完全能正常登录但代价是他们的密码仍然按弱配置存储没有享受到这次安全升级。Django为此内置了一套升级机制。在check_password的源码里当密码校验成功之后还有一段“尾巴”def check_password(self, password, encoded): ... if not constant_time_compare(encoded_hash, calculated_hash): return False # 如果哈希串记录的算法或参数不是当前最优先配置重新哈希并通知更新 ...具体逻辑是校验通过后Django会比较当前哈希串的算法标识和迭代次数与settings.PASSWORD_HASHERS列表第一个哈希器定义的默认参数是否一致。只要不一致它就会用当前最优配置重新算一遍密码然后把新哈希串写回到user.password。这套动作不需要用户重新提交表单不需要管理员手动跑脚本就在用户下一次正常登录时自动完成。这是“渐进式密码升级”范式里最优雅的一种实现。整个过程中用户完全无感知数据库里的弱哈希被一根根拔掉老化的插头逐渐换成当前最强配置。实现这套机制只需要正确配置PASSWORD_HASHERS列表剩下的交给框架本身。5. 生产环境选型Argon2与渐进式迁移实操5.1 Argon2凭什么成为新一代首选如果您在用Django 4.x做新项目官方推荐的第一哈希器已经是Argon2。Argon2是密码哈希竞赛PHC的冠军方案属于内存-困难型算法设计目标很明确让GPU和ASIC这类并行计算设备在暴力破解上无利可图。Argon2有三个变体变体适用场景特性Argon2d无人为侧信道风险的环境防GPU破解但存在低熵密码下的侧信道风险Argon2i需要抵抗侧信道攻击的环境数据独立访问但内存成本利用弱一些Argon2id混合场景前半段数据独立、后半段数据依赖兼顾两类风险官方推荐Django从4.x开始在新项目模板中会把Argon2id放在PASSWORD_HASHERS的第一位。即使你现在还在维护老项目也建议在安全审计时把它加进升级清单。它已经过了密码学界多年审视是目前公开领域能用到的最强密码哈希方案。5.2 完整的settings配置与参数权衡迁移到Argon2配置文件中的哈希器列表应该长成下面这样PASSWORD_HASHERS [ django.contrib.auth.hashers.Argon2PasswordHasher, django.contrib.auth.hashers.PBKDF2PasswordHasher, django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher, ] # 可选自定义Argon2参数 class Argon2PasswordHasher(BaseArgon2PasswordHasher): time_cost 3 # 迭代轮数越多越慢越安全 memory_cost 65536 # 内存成本单位KB64MB parallelism 2 # 并行度先解释列表顺序的重要性。Django在生成新哈希时永远使用列表第一位的哈希器在校验成功后的在线升级中也只会把“不符合第一位默认参数”的哈希重算成第一位格式。老哈希器只要还留在列表里老用户账号就都能继续校验同时每个账号在登录后都会逐步升级到第一位。这是典型的“先兼容后演进”策略。参数调整没有绝对正确的值应该结合自己的服务器配置和登录并发量来定。一般建议以memory_cost262144(256MB)、time_cost3为起点做压测然后用这个标准衡量单次登录在测试环境里耗时尽量控制在100ms左右。为了减小对业务的影响可以把Argon2的parallelism尽量调低事实上Django在某些版本里为了兼容多线程环境甚至建议把parallelism设为1因为Django的检查逻辑假设同一时间只用一个线程处理一个哈希。5.3 渐进式迁移的三步走方案对于存量项目不建议搞“停机一夜批量重算所有密码”这种玩法。理由很实际数据库中只存了密码的不可逆哈希没有明文你拿什么重算如果让所有用户提交一次明文密码且不说用户体验崩盘光是几百万用户的流量回流就能把服务打挂。最稳妥的方案是三步走第一步双算法共存。先把Argon2加进PASSWORD_HASHERS首位保留PBKDF2在第二位上线观察。老用户登录照常校验新用户改密码后直接生成Argon2哈希。第二步等登录自然升级。每个老用户只要登录一次并校验成功就会自动触发在线升级。活跃用户的哈希在几周内陆续完成迁移。对不活跃用户这段等期的代价是他们的密码哈希仍然停留在旧算法但安全性没有劣化只是没有增强。第三步设置最后期限。三个月或半年后评估一下活跃用户的升级比例。如果达到95%以上就可以在代码里把PBKDF2从PASSWORD_HASHERS列表中移除。移除后还残留的极少数PBKDF2哈希将无法校验这些用户只能走“忘记密码”流程重置。建议提前一个月给这批用户发邮件提示而不是被动等着他们投诉。这里有个坑必须强调千万不要手动写脚本遍历所有用户去重算哈希。除非你同时能拿到每个用户的明文密码否则做出来的只会是把所有账号的密码重置成同一个随机值直接导致全网用户集体“被找回密码”。5.4 Argon2迁移后的回滚注意事项任何涉及密码存储的上线都存在回滚问题。如果Argon2上线后某次压测显示内存成本过大导致老服务器频繁OOM需要立刻回滚时首先确认旧版本代码里PASSWORD_HASHERS第一位的迭代次数定义没有变。如果回滚后第一位还是Argon2这只是配置里的代码变了数据库里已有的Argon2哈希仍然能正常校验因为参数都写在哈希串里。真正要注意的是回滚后如果新代码把Argon2从列表里整个移除那些已经升级过的Argon2哈希就会失去对应的哈希器用户将无法登录。所以在紧急回滚时建议保留Argon2在列表中只是把PBKDF2提回第一位等压力稳定后再择机调整。6. 容易踩的坑与边界条件从哈希到认证链路的常见误判6.1 自作聪明地叠Buff式自定义哈希有一种很常见的“工程直觉”是这样把密码先用SHA-512算一遍再套PBKDF2中间再插一点自己的随机数逻辑觉得多重哈希叠加一定更安全。实际上这个想法在密码学上既有风险也无必要。Django哈希器的encode方法返回格式固定任何自定义逻辑都可能改变哈希串的组装方式导致未来升级算法时逆向解析失效。更麻烦的是如果自定义逻辑里的“随机数”不是加密安全随机源生成的而是在时间戳上做了变换攻击者反而能得到一个破解线索。我的建议是千万不要自己发明密码哈希的“混合配方”标准算法已经经过了全世界密码学家的审视任何业余改动都只可能让它变弱不可能让它变强。6.2 测试环境用弱哈希器忘了换回来Django为了方便单元测试提供了MD5PasswordHasher、UnsaltedSHA1PasswordHasher这类弱哈希器用来加速测试用例跑批。但如果有人把配置从测试环境带到生产环境那所有新密码都会变成MD5甚至无盐SHA1安全水平一夜回到十年前。我的习惯是在配置里加入环境断言if not DEBUG and md5 in PASSWORD_HASHERS[0]: raise ImproperlyConfigured(生产环境禁止使用MD5作为密码哈希器)这个小断言可以放在应用配置模块的ready()方法里一旦部署时有人忘记切换哈希器配置服务直接拒绝启动而不是带病上线。6.3 日志与脱敏链条上的哈希泄露很多项目在用户登录失败时会把失败原因记录到日志如果不小心把用户密码写进日志这就是一次明文泄露而如果把password字段连同哈希串一起打出来也等于把盐和最终哈希完整暴露给了日志系统。一旦日志系统被攻破那些哈希就能被离线暴力破解。所以日志脱敏的过滤规则里必须把password字段和包含“hash”字样的大字段都列入排除范围。另外密码哈希字段在ORM序列化、API输出、Redis缓存写入时也要格外小心不要随手把它返回给前端。6.4 改密码后的会话失效问题有一类边界问题在安全审计中经常被忽略用户改了密码但旧的登录会话依然有效。Django的Session框架设计了自己的auth hash机制会话建立时会记录一个登录时的密码摘要密码一旦改变旧的会话在下次请求时会因为auth hash不一致而失效。但这条机制只对“通过认证系统登录”的会话生效对于通过Remember Me长期会话、或者其他自定义认证后端建立的会话可能不会自动失效。所以在实现改密码、找回密码、封禁账号等操作时光靠默认会话机制是不够的应该在处理函数里显式清空用户相关会话from django.contrib.sessions.models import Session def invalidate_user_sessions(user): for session in Session.objects.all(): if str(user.pk) in session.get_decoded().get(_auth_user_id, ): session.delete()这个操作在用户量大的时候要注意性能建议改成异步任务执行而不是在改密码的同步请求里遍历全表。6.5 过早移除旧哈希器导致老用户集体卡死最后再提醒一遍在线升级的“终点步骤”。当团队决定停用某个旧哈希器时执行操作的那一刻所有仍在使用这个哈希器的用户将无法通过密码校验。如果有心人统计数据会发现“密码找回”入口的流量在某个版本上线后突然暴增——那多半就是移除哈希器时没有做用户影响评估。正确做法是在移除前先跑一个数据表统计算清楚旧算法哈希在总用户量中的占比。占比超过可接受范围时先保持双算法共存给用户留出登录升级的缓冲期占比降到低个位数后再安排二次通知并完成清理。从密码存储的全局视角看真正重要的不是“追着最新算法跑”而是让系统具备平滑演进的余量。哈希串自带参数、哈希器可热插拔、登录时自动升级——这套设计保证了即便未来出现更优算法Django项目也能在不打断用户的前提下完成迁移。在做技术决策时优先保住这个演进能力比在一开始就选一个“当前最强”的方案更值得投入。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑