ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django密码存储策略全拆解:哈希算法、加盐与自动升级机制

Django密码存储策略全拆解:哈希算法、加盐与自动升级机制

前几天帮一个团队做代码评审,看到他们把用户密码用“MD5加密”后直接落库,盐还是全局写死的。我当时愣了好几秒,因为他们用的就是Django,而Django内置的那套密码存储策略,早就把哈希算法、盐值生成、算法升级这些事安排得明明白白。这不是能力问题,纯粹是大家对Django的密码存储机制只停留在“它好像加密了”的模糊印象。今天这篇就专门把Django密码存储策略彻底拆开讲透,从哈希串格式到算法选型,从登录校验到无感迁移,一次性说完。

这个内容适合谁?只要是写过Django项目、或者正在为“密码到底该怎么存”纠结的后端开发者,都值得花十分钟看完。看完你就不会再被“加密”还是“哈希”这类概念绕晕,也能自己动手把项目的密码存储策略升级到当前推荐水准。

1. 密码存储的前世今生:为什么Django选择这种方案

1.1 明文存储与现代哈希的对比

很多人第一次接触密码存储时,脑子里第一个念头就是“加密一下”。但我必须先把一个最基础、也最关键的概念掰扯清楚:密码存储绝对不能使用可逆的加密算法,必须使用不可逆的哈希函数。

可逆加密意味着什么?意味着只要拿到密钥,就能把密文还原成明文。用户密码是用户自己也不可能再知道的秘密,系统完全没有理由存储一个“能被解密出来的密码”。一旦数据库泄露、密钥跟着泄露,所有用户的明文密码就全没了。而哈希函数是单向的,理论上只能正向计算,不能反向还原,所以哪怕数据库整个拖走,攻击者也很难直接拿到原始密码。

Django的策略属于“加盐哈希”路线,也就是哈希之前先拼上一段随机生成的盐值。这套方案的思路是这样的:同一个密码,用不同的盐,产生的哈希完全不同。攻击者手里即使拿到了一份彩虹表(预生成好的常见密码哈希对照表),也因为盐的存在而基本失效。加盐这件事听起来简单,但很多自定义代码都栽在这里,要么盐值固定,要么干脆不加盐,等于把哈希结果直接暴露给彩虹表攻击。

1.2 Django密码哈希串的解剖

Django落库的密码字段,并不是一长串看着没规律的乱码就完事了,它内部是有完整结构的。标准格式长这样:

<algorithm>$<iterations>$<salt>$<hash>

我随便构造一个示例:

pbkdf2_sha256$120000$AbC123xYz$5f6c8a9b...

用$符号切成四段:

  • 第一段pbkdf2_sha256是哈希算法标识;
  • 第二段120000是迭代次数;
  • 第三段AbC123xYz是每次生成密码时随机产生的盐值;
  • 第四段是算法计算出来的最终哈希结果。

为什么Django要把这些信息全部放在同一个字符串里?这恰恰是它的高明之处。算法、迭代次数这些参数全部自描述,这意味着Django在读这个字符串的时候,可以还原出“当时的算法+当时的迭代次数+当时的盐值”,从而完成一次完全同参数的哈希计算。哪怕你未来把默认算法从A换成了B,老用户的密码字段里依然记录着算法A的信息,系统随时可以校验,不需要额外维护一张参数映射表。

2. Django内置哈希算法面面观

2.1 PBKDF2:默认与稳妥

Django的默认密码哈希器是PBKDF2PasswordHasher,对应的算法标识是pbkdf2_sha256。PBKDF2(Password-Based Key Derivation Function 2)是一个应用极其广泛的标准密钥派生函数,核心思想很简单:对密码和盐做多次反复的哈希叠加,逐轮运算,把单个密码的哈希计算成本拉高到毫秒级别。

这个“慢”,恰恰是密码存储策略的关键。普通哈希函数如MD5、SHA1、SHA256都设计成“快”,一秒钟能算几百万次,攻击者暴力猜密码的代价极低。PBKDF2通过反复迭代把速度故意拖慢,攻击者每猜一个密码也要付出同样的计算代价,猜密码的成本立刻飙升。

Django默认还会把迭代次数这个参数随着版本演进逐步上调。比如较新版本的默认迭代次数已经比老版本高出一截,具体数值以当前环境的PBKDF2PasswordHasher.iterations属性为准。我强烈建议在部署时确认一下项目实际跑在哪个Django版本,不要想当然沿用网上几年前的教程配置。

2.2 Argon2:内存困难的优势

如果有人问“当前最推荐的密码哈希算法是什么”,答案通常是Argon2。这个算法拿过密码哈希竞赛的冠军,设计上主打“内存困难”,也就是不仅计算上慢,而且要求大量内存参与计算。这让它应对GPU并行暴力破解的能力比纯CPU迭代的PBKDF2更强,因为一枚GPU核心的内存带宽很难共享给成千上万个并行猜测任务。

Django对Argon2的支持需要额外安装argon2-cffi库,然后把它加入到PASSWORD_HASHERS配置里。我自己的项目大多以Argon2为首选哈希器。选它的原因很简单:现任冠军、抗GPU破解能力强、Django官方支持。唯一要注意的是部署环境需要编译或安装对应依赖,在受限的云环境里偶尔会多费一点功夫。

2.3 bcrypt与scrypt的实际定位

bcrypt也是一个老牌密码哈希算法,特点是具备内置的盐值管理,但Django用的是BCryptSHA256PasswordHasher,它会先把密码做一次SHA256,再交给bcrypt处理。这一步是为了规避bcrypt对超长密码截断的历史问题,属于Django团队做的兼容性设计。

scrypt则是另一个“内存困难”思路的算法,由密码学领域的权威人物提出,设计上和Argon2一样重视内存占用。Django从较新版本开始也在内置哈希器列表中加入了ScryptPasswordHasher。它的好处是Python标准库本身就提供支持,不需要额外安装第三方库,所以环境依赖最小。

2.4 算法选型怎么决策

Django默认的哈希器顺序如下(具体到不同版本略有差异):先是PBKDF2,然后是PBKDF2SHA1、Argon2、BCryptSHA256,最后是Scrypt。这个顺序本质上是优先级列表:存储新密码时,永远优先选用列表第一个哈希器;在校验老密码时,则根据密码字符串里记录的算法标识,取对应哈希器。

如果你的项目已经引用了argon2-cffi,最稳妥的做法是调整列表顺序,把Argon2提到最前面。没必要强行把PBKDF2换掉,PBKDF2本身就是合格方案,但既然有条件上更强选项,那就物尽其用。反过来,如果部署环境实在装不上额外的库,保留默认PBKDF2也完全能接受,至少比大量老项目里的裸MD5强上好几个量级。

3. 密码校验与自动升级的完整链路

3.1 登录验证的核心流程

当用户在Django登录表单里输入密码并提交后,后端做的事情比很多人想象的要复杂。核心入口是check_password()函数,它的执行链路可以拆成四步。

第一步:从用户表取出该用户已存储的密码哈希串,解析出算法名、迭代次数、盐值和旧的哈希结果。第二步:根据解析出的算法名,在PASSWORD_HASHERS列表里找到对应的哈希器类。第三步:用相同算法、相同盐、相同迭代次数,对用户提交的明文密码重新计算一遍哈希。第四步:把新算出来的哈希与数据库里存的哈希做比对,如果一致,说明密码正确。

这里必须注意一个细节:这个“比对”不是普通字符串相等比较,而是采用了时间恒定比较方式,比如Python标准库hmac.compare_digest。为什么要这样?因为普通字符串比较遇到第一个不匹配字符就立刻返回,攻击者可以通过测量响应时间猜测哈希前缀,从而逐步还原完整的哈希值。时间恒定比较则无论从哪里开始不匹配,都固定消耗相同时间,从物理层面切断这个攻击通道。

3.2 密码自动迁移机制

Django做得特别漂亮的一点,就是密码哈希的自动升级。当用户登录成功后,Django会检查这个密码哈希是否是由当前PASSWORD_HASHERS列表里的首个哈希器生成的。如果不是,比如目前列表第一位是Argon2,但老密码用的是pbkdf2_sha256,Django就会用Argon2重新计算密码,生成新的哈希字符串,然后更新到数据库。

这就意味着:你不用写任何额外代码,也不需要让用户重新设置密码,只要把配置文件里的PASSWORD_HASHERS调整好,老用户在下一次登录时就会自动被迁移到新算法。这个机制是延迟且分批的,不会q产生一次性迁移压力,对线上系统极其友好。

不过这里有一个隐形的坑:如果老密码哈希使用的迭代次数与当前哈希器的默认迭代次数不一致,但算法名相同,Django同样会重新哈希。这带来的好处是,当Django新版本上调了默认迭代次数,旧密码会在用户登录时自动用更高迭代次数的参数重新生成。

3.3 手动管理与批量迁移

除了自动迁移,Django还给了手动管理密码的工具。比如make_password()可以生成指定哈希器的新密码哈希,is_password_usable()可以判断一个密码字符串是否还能正常使用。重置密码的管理命令createsuperuser、changepassword内部也都是走这套机制。

如果你要做一次全员密码迁移,比如从自定义的旧哈希方案换到Django标准方案,单靠登录触发可能太慢。实际项目里可以写一个管理命令,遍历用户表,把旧哈希串转成Django兼容的新格式,同时保留旧字段备份。但这种操作涉及全量用户数据改写,建议优先在预发布环境演练,并确保中途失败可回滚。

4. 实战:自定义密码哈希器与策略升级

4.1 从零实现一个Hasher

有时候项目会遇到历史遗留的哈希方案,比如老系统用SHA256加盐、自定义了三轮迭代,现在要接入Django账号体系。这时候在PASSWORD_HASHERS里加一个自定义Hasher就是最干净的解法。

一个最小可用的自定义Hasher只需要继承BasePasswordHasher,并实现encode()和verify()两个方法。encode()负责把明文密码变成格式化的哈希字符串,verify()负责用相同盐值重新计算并比对。以“SHA256加固定策略盐”为例,示意代码如下:

import hashlib from django.contrib.auth.hashers import BasePasswordHasher class LegacySHA256Hasher(BasePasswordHasher): algorithm = "legacy_sha256" iterations = 1000 def encode(self, password, salt): digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt.encode("utf-8"), self.iterations ).hex() return f"{self.algorithm}${self.iterations}${salt}${digest}" def verify(self, password, encoded): algorithm, iterations, salt, _ = encoded.split("$", 3) reencoded = self.encode(password, salt) return self.constant_time_compare(reencoded, encoded)

注意verify()内部调用了constant_time_compare,这就是Django提供的恒定时间比对方法,千万别图省事直接用==。

4.2 配置PASSWORD_HASHERS的正确姿势

修改密码哈希策略,不是把推荐算法写进代码注释就完事,真正的核心动作是配置PASSWORD_HASHERS。下面是一份推荐的配置思路:

PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.PBKDF2PasswordHasher", "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", "django.contrib.auth.hashers.ScryptPasswordHasher", ]

把Argon2放第一,意思是所有新密码和重设密码统一使用Argon2。后面的PBKDF2、bcrypt、scrypt保留着,是为了兼容存量密码。这样即使旧密码是pbkdf2_sha256格式,用户依然能正常登录,并在登录后自动迁移到Argon2。

还有一个关键时刻容易忽略:你改了列表顺序之后,务必在测试环境跑一遍完整的“老用户登录”用例,确认自动迁移链路没坏。我遇到过最离谱的案例是有人把老哈希器整个从列表里删掉了,结果存量用户登录直接报错,因为Django找不到对应的Hasher解析哈希串。

4.3 升级过程的避坑清单

老项目升级密码存储策略时,最容易踩的坑不是选型,而是执行细节。

备份数据库永远排第一。任何涉及全量用户密码哈希改写的操作,都必须有完整备份,并且备份要验证可以恢复,不只是“备份过”这个心理安慰。

别动原始字段结构。只要把密码字符串格式换成Django标准的哈希串格式就够了,不要新增一张表、不要给每一行加一个算法字段,Django的标准格式本身已经包含了算法信息,多此一举反而增加出错概率。

分批处理,先灰度。如果存量用户特别多,建议先用管理命令迁移一小部分账号做试点,完成后再切全量。

# 一个可参考的迁移命令骨架 from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model User = get_user_model() class Command(BaseCommand): def handle(self, *args, **options): qs = User.objects.filter(password__startswith="legacy_sha256") total = qs.count() for i, user in enumerate(qs.iterator(chunk_size=500), 1): # 此处需要从旧哈希中解析出盐值并生成新哈希 # 实际操作时建议用 transaction.atomic 包裹 user.save(update_fields=["password", "updated_at"]) if i % 1000 == 0: self.stdout.write(f"migrated {i}/{total}")

注意示例里需要结合实际旧格式补充解析逻辑,这里只是骨架,真正执行前务必打印几条记录验证输出格式与Django兼容。

5. 绕不开的雷区:常见问题与安全建议

5.1 典型误用与修复

第一个常见误用是“密码字段用可逆加密”。不少从PHP或其他语言转过来的开发者,会把models.CharField里存一份AES加密后的密码。这种做法等于给攻击者留了一把钥匙。修复方式也很直接:用本文介绍的标准哈希方案替换,写个迁移脚本处理好存量数据。

第二个误用是“自定义加密算法”。有人觉得MD5加盐不够强,自己设计了一套层层嵌套的“加强版哈希”。这类方案的问题不在计算机强度,而在没有经过公开密码学社区检验,很容易存在未知结构性弱点。Django内置的都是经过业界多年验证的成熟算法,没有必要去自研。

第三个误用是“所有用户共用同一盐值”。这种写法我在不少教学项目里见过,本质上等于只给口令增加了一个全局后缀,彩虹表攻击者完全可以针对这个固定的盐重新生成一套彩虹表。正确的做法就是Django默认的随机盐,每个用户每次生成密码都不同。

5.2 常用操作排查速查表

现象排查思路常见解法
用户登录报错“密码格式无效”检查密码哈希串是否包含完整的四段结构确认哈希串未被截断,必要时手动用make_password()重新生成
改了PASSWORD_HASHERS后老用户无法登录检查老哈希算法对应的Hasher是否还在列表里把旧哈希器保留在列表后续位置,不要删除
登录成功但数据库哈希没有变化检查当前哈希器是否是列表第一位确认新密码确实使用了首选算法,且用户是在改配置之后登录的
部署环境缺少Argon2库检查argon2-cffi是否已安装优先安装依赖;实在无法安装则暂用PBKDF2,后续再升级

5.3 安全加固的几条实用经验

基于我日常碰到的真实问题和踩坑记录,给出几条可以直接照做的经验。

第一条:密码哈希策略要和Django版本一起升级。很多人项目一跑就是好几年,Django大版本完全没动。每次Django升级,默认的密码哈希迭代次数都可能上调,跟上升级能自动享受更强的计算成本防线。

第二条:不要直接往日志里打印User对象或者password字段。某些调试场景下,数据敏感的字段会被完整打出来,密码哈希一旦泄露到日志平台,等于白干。建议写自定义日志格式时,显式排除敏感字段。

第三条:别给用户设置密码长度上限。越长越好的密码,本来就该放行。要限制的只是存储长度和展示长度,哈希结果本身不管密码多长都等长,所以完全没有必要为了“数据库字段长度”去限制用户输入。

第四条:如果项目注册时允许第三方登录绑定,注意不要用同一个密码字段去混存不同体系的凭证信息。比如OAuth的access_token和密码哈希各存各的字段,混在一起会让后续哈希升级逻辑复杂化。

写在最后的一点心得

我接触过的很多项目,真正出问题的往往不是没有安全方案,而是方案太陈旧。密码存储这件事,选型的标准就三条:算法要经过公开社区验证、要有足够高的计算成本、要支持无感升级。Django这套机制恰好把这三条都做成了默认行为。我最想提醒你的一点是:密码存储策略不是“配置完就不管”的静态设置,它应该跟着Django版本和硬件水平持续演进。每次升级版本之后,顺手看一眼当前默认的哈希器顺序和迭代次数,养成这个习惯,你的用户密码存储就不会脱节太久。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进