Python后端权限系统02:数据库泄露之后,密码还能守住什么——scrypt 与统一登录失败
假设采购系统有一天泄露了用户表。攻击者得到的若是明文口令,事件几乎立即扩展到邮件、办公平台及其他被重复使用口令的系统。若数据库里只有一次普通 SHA-256 的结果,也不能因此放心:常见口令可以被快速批量计算,攻击者不必再经过我们的登录接口和失败次数限制。
本课讨论的是这个具体场景:让数据库保存可以验证口令、但不直接提供口令的信息,并尽量提高离线猜测的成本。它与限制在线登录次数是两条独立防线。密码散列做得再慢,仍然需要保护登录入口;登录限流做得再严,也保护不了已经被复制到攻击者机器上的散列数据库。
上一课 E01:边界矩阵的完整答案
参考实现位于lessons/02/answer_previous.py。它创建实际采购对象,对七个读取或创建场景逐项断言,并确认 platform 的元信息权限没有延伸成业务读取权限。运行方式是从专题根目录执行python lessons/02/answer_previous.py。本节末尾的完整源码与交付文件相同,可直接保存运行。
本地实际运行输出如下,任意断言失败都会使脚本以错误退出:
E01 PASS: 7 boundary checks需要特别解释两个拒绝:Mallory 根本没有 acme 成员关系,所以在租户入口返回 403;Carol 有 acme 成员关系,但这张工程申请不在她的资源可见范围内,所以详情返回 404。两者都不能变成“请先登录”,因为她们已经有合法身份。这样写断言还能防止后续重构把所有异常一律转换为 401,让前端不断跳回登录页。
为什么不能把加密和散列混为一谈
加密通常要求将来能够恢复原文,例如系统必须代表用户调用一个第三方接口时,可能需要保存可解密的外部凭据。用户登录口令没有这个要求:服务端只需要判断这次输入是否匹配以前设置的口令。保存能恢复原文的密文,会把解密密钥变成一个可以同时打开所有用户口令的集中风险。
散列验证的方向不同。我们保存计算结果与计算所需参数,将来把新输入按照相同参数再算一次,比较结果。结果相同表明输入匹配,系统不需要还原原口令。但“不能反推”不等于“不能猜测”:攻击者可以不断尝试候选口令。因此通用散列函数的高速优势,在密码验证场景里会变成离线破解优势。
ProcureSafe 使用 Python 标准库提供的 scrypt。它刻意消耗计算与内存,使大量并行猜测的成本提高。本项目的参数是 N=16384、r=8、p=1,随机盐 16 字节,计算时的 maxmem 为 64 MiB。仅主要内存项约为 128×N×r,也就是 16 MiB,运行时还有额外开销;maxmem 是一次调用的内存上限参数,不代表每次一定使用 64 MiB,更不是整个 Web 服务的内存限额。
这些值是为了让普通本地环境可以顺利完成教学实验。生产选择需要在实际机器、预期登录并发和资源预算下测量,制定允许的验证耗时,再确定参数和迁移方案。把教程数字原封不动复制进容量不同的服务,可能导致防护偏弱,也可能被大量验证请求拖垮。安全算法的名字不能替代容量评估。
盐不保密,但必须独立生成
为同一个口令生成两份记录,记录应该不同。这不是算法不稳定,而是每次生成时使用了独立随机盐。盐让相同口令不再在数据库中产生相同结果,也让预计算表不能对所有账号直接复用。盐和散列一起保存在数据库里是正常设计;把盐藏在源码里、让全站共用一个固定盐,会丢掉它最重要的作用。
记录采用scrypt$16384$8$1$<salt hex>$<digest hex>格式。开头说明算法,中间记录参数,后面是盐和结果。十六进制编码只是把字节转成便于保存的文本,不是又增加了一层密码保护。格式中保存参数,是为了让未来的验证代码知道旧记录如何生成,也为将来的升级判断留出依据。
验证函数先解析记录,再使用记录约定的参数与盐计算候选结果,最后进行恒定时间比较。这里的“恒定时间”特指比较两个结果时不因第一个不同字节的位置明显提前结束,并不意味着整个登录请求在所有分支上具有完全相同的耗时。查库、参数检查、锁定状态和应用调度都可能带来差异,不能把一个安全比较函数包装成“彻底没有计时侧信道”的保证。
对于格式损坏的记录,验证函数应返回失败,不应把详细解析异常直接暴露给登录调用者。算法字段或成本参数同样需要受约束;不能让一条意外写入的散列记录要求任意高的计算资源。审查本课完整模块时,请同时看合法输入如何通过,以及异常格式如何收敛到失败结果,不能只阅读生成散列的那几行。
口令长度限制也属于资源边界
本项目要求口令至少十二个字符,并限制 UTF-8 编码后最多 1024 字节。最小长度是教学策略,不能被误读为“十二个字符必然安全”;一串重复字符仍然很好猜。上限则用来限制极端输入消耗。字符数与字节数是不同单位,中文或其他 Unicode 字符的编码通常比单个 ASCII 字符占用更多字节,因此错误提示与校验实现必须说清楚自己限制的是什么。
不要在服务端擅自对口令执行strip()或转小写。空格和大小写可能就是用户选择的秘密的一部分;悄悄转换会使实际验证的口令与用户以为设置的口令不同。如果某个产品明确采用 Unicode 规范化策略,应在设置与验证两端保持同样规则并向用户说明,而不是在某次排查登录问题时临时加入“清洗输入”。
演示答案使用secrets.token_urlsafe生成临时字符串,不把测试口令写在文件里。这里的随机口令服务于可重复、低泄露风险的实验环境,并不表示真实用户应该记住这种随机文本。真实产品可以配合密码管理器、受控重置流程与多因素认证;这些是另有边界的能力,本课只承诺密码记录与验证路径。
登录为什么不告诉用户“这个账号不存在”
POST /auth/login接收用户名和口令,成功返回本地会话凭据;未知用户名和错误口令都返回 401。错误文本不应该让未认证访问者轻松区分“账户不存在”与“账户存在但口令错误”。否则采购系统的登录页会变成一个可以逐个枚举员工账户的接口。
统一状态码只是起点。某些系统虽然返回同样文字,却在不存在的用户上立即结束、在存在的用户上计算昂贵散列,造成明显的耗时区别。ProcureSafe 对未知用户也执行一次预先生成的虚拟 scrypt 记录验证,减少这个明显分支差异;如何配合有界速率限制,仍要结合容量分析设计。本专题不会声称仅凭一个 TestClient 断言就证明两类请求的时间分布完全不可区分;它能验证的是对外错误契约与锁定行为。
ProcureSafe 还把失败计数与临时锁定放到数据库:五次失败锁定六十秒,锁内正确口令也被拒绝。第十三课会用注入时钟验证恢复,避免为了测试真的等一分钟。账号级锁定能够限制猜测,却也可能被恶意请求用于干扰别人登录,所以生产环境需要结合来源限制、告警和恢复策略。这里的规则是一条清晰可测的课程边界,不是完整反滥用系统。
日志为什么不能帮助你“看一下到底输的是什么”
登录失败时最危险的排查习惯之一,是临时打印完整请求体。口令会进入终端历史、日志采集平台、截图或分享给同事的错误报告,保留时间往往远长于数据库中的失败记录。即使日志只发到开发环境,也可能来自真实浏览器或被复制的生产请求。
更好的记录是请求编号、受控的结果类别与必要的时间信息。是否记录用户名需要结合隐私要求和日志访问范围决定;无论如何,原始口令、完整散列、access token 和 refresh token 都不属于排查登录所需的普通日志字段。后面的审计课程会用字段白名单进一步限制写入内容,而不会依赖维护一张永远补不齐的敏感字段黑名单。
还要避免把整个登录响应放进断言失败消息。像assert response.status_code == 200, response.json()这样的便捷写法,在接口部分成功或结构异常时可能泄露令牌。配套 helper 只在错误时展示状态码。测试确实需要比较敏感值时,可以在内存中比较,但最后输出只报告通过的性质。
本课练习 E02:验证安全性质,不检查固定散列字符串
编写一个独立答案,生成一次临时口令,对它调用两次hash_password。断言两份记录不同,算法字段和参数符合约定,两份盐均为十六字节且不同;再断言原口令能验证通过,另一个临时口令不能通过,损坏格式也不能通过。不要把随机盐或散列写死成预期快照,否则正确的随机实现反而会导致测试失败。
随后在沙盒中对错误口令和不存在的用户名分别登录,确认都返回 401,并确认响应不包含任何 token 字段。最后使用正确口令成功登录,以免测试实际上验证的是“服务根本无法登录”。最终只打印E02 PASS: salted scrypt and uniform login rejection,不要输出任何秘密值。
这份练习区分了三个层次:散列函数的性质、错误输入的处理、HTTP 登录的对外契约。它们都属于密码课,但责任并不相同。下一课会给出完整可运行答案,再追问登录成功以后最容易被跳过的问题:服务器发出去的 access token 如何存储、什么时候失效、退出登录究竟撤销了什么。
上一课答案源码与本课完整密码模块
"""E01 完整答案:用真实请求把身份、组织与资源可见性写成矩阵。"""frompathlibimportPathimportsys sys.path.insert(0,str(Path(__file__).resolve().parents[1]))fromsupportimportlogin,sandboxdefmain():withsandbox()as(client,credentials):tokens={name:login(client,credentials,name)fornamein("alice","bob","carol","mallory","platform")}headers={name:{"Authorization":"Bearer "+value["access_token"]}forname,valueintokens.items()}body={"title":"工程显示器","amount_cents":129900,"department":"engineering"}# 先确认匿名入口关闭,再建立一个真实资源供矩阵的其余行引用。assertclient.post("/tenants/acme/requests",json=body).status_code==401created=client.post("/tenants/acme/requests",json=body,headers=headers["alice"])assertcreated.status_code==201item=created.json()assertitem["owner_id"]=="alice"anditem["tenant_id"]=="acme"path="/tenants/acme/requests/"+item["id"]own=client.get(path,headers=headers["alice"])assertown.status_code==200andown.json()["id"]==item["id"]same_department=client.get(path,headers=headers["bob"])assertsame_department.status_code==200andsame_department.json()["id"]==item["id"]assertclient.get(path,headers=headers["carol"]).status_code==404assertclient.get("/tenants/acme/requests",headers=headers["mallory"]).status_code==403# 平台权限只覆盖元信息,不能被角色名称误解成所有租户的业务权限。metadata=client.get("/platform/tenants",headers=headers["platform"])assertmetadata.status_code==200assert{row["id"]forrowinmetadata.json()["items"]}=={"acme","beta"}assertclient.get("/tenants/acme/requests",headers=headers["platform"]).status_code==403print("E01 PASS: 7 boundary checks")if__name__=="__main__":main()"""标准库scrypt示范参数;生产成本须按目标机器重新测量。"""importhashlibimporthmacimportsecrets N,R,P=16384,8,1MAX_BYTES=1024defhash_password(password:str)->str:"""每次生成独立盐;参数随hash保存,为以后迁移保留依据。"""raw=password.encode("utf-8")iflen(password)<12orlen(raw)>MAX_BYTES:raiseValueError("Demo password requires 12+ characters and at most 1024 UTF-8 bytes")salt=secrets.token_bytes(16)derived=hashlib.scrypt(raw,salt=salt,n=N,r=R,p=P,dklen=32,maxmem=64*1024*1024)returnf"scrypt${N}${R}${P}${salt.hex()}${derived.hex()}"defverify_password(password:str,encoded:str)->bool:"""只接受本项目已知参数,损坏的存储值不能要求服务器进行无限昂贵计算。"""try:name,n,r,p,salt_hex,digest_hex=encoded.split("$")ifname!="scrypt"or(int(n),int(r),int(p))!=(N,R,P):returnFalseraw,salt,expected=password.encode("utf-8"),bytes.fromhex(salt_hex),bytes.fromhex(digest_hex)iflen(raw)>MAX_BYTESorlen(salt)!=16orlen(expected)!=32:returnFalseactual=hashlib.scrypt(raw,salt=salt,n=N,r=R,p=P,dklen=32,maxmem=64*1024*1024)returnhmac.compare_digest(actual,expected)except(ValueError,TypeError,AttributeError):returnFalse专题目录:《认证、授权与企业级权限系统:课程目录》 · 上一篇:《Python后端权限系统01:先画边界,再写登录——采购申请中的身份、动作与资源》 · 下一篇:《Python后端权限系统03:登录之后还要继续核验——不透明令牌、过期与退出登录》