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 成员关系所以在租户入口返回 403Carol 有 acme 成员关系但这张工程申请不在她的资源可见范围内所以详情返回 404。两者都不能变成“请先登录”因为她们已经有合法身份。这样写断言还能防止后续重构把所有异常一律转换为 401让前端不断跳回登录页。为什么不能把加密和散列混为一谈加密通常要求将来能够恢复原文例如系统必须代表用户调用一个第三方接口时可能需要保存可解密的外部凭据。用户登录口令没有这个要求服务端只需要判断这次输入是否匹配以前设置的口令。保存能恢复原文的密文会把解密密钥变成一个可以同时打开所有用户口令的集中风险。散列验证的方向不同。我们保存计算结果与计算所需参数将来把新输入按照相同参数再算一次比较结果。结果相同表明输入匹配系统不需要还原原口令。但“不能反推”不等于“不能猜测”攻击者可以不断尝试候选口令。因此通用散列函数的高速优势在密码验证场景里会变成离线破解优势。ProcureSafe 使用 Python 标准库提供的 scrypt。它刻意消耗计算与内存使大量并行猜测的成本提高。本项目的参数是 N16384、r8、p1随机盐 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,jsonbody).status_code401createdclient.post(/tenants/acme/requests,jsonbody,headersheaders[alice])assertcreated.status_code201itemcreated.json()assertitem[owner_id]aliceanditem[tenant_id]acmepath/tenants/acme/requests/item[id]ownclient.get(path,headersheaders[alice])assertown.status_code200andown.json()[id]item[id]same_departmentclient.get(path,headersheaders[bob])assertsame_department.status_code200andsame_department.json()[id]item[id]assertclient.get(path,headersheaders[carol]).status_code404assertclient.get(/tenants/acme/requests,headersheaders[mallory]).status_code403# 平台权限只覆盖元信息不能被角色名称误解成所有租户的业务权限。metadataclient.get(/platform/tenants,headersheaders[platform])assertmetadata.status_code200assert{row[id]forrowinmetadata.json()[items]}{acme,beta}assertclient.get(/tenants/acme/requests,headersheaders[platform]).status_code403print(E01 PASS: 7 boundary checks)if__name____main__:main()标准库scrypt示范参数生产成本须按目标机器重新测量。importhashlibimporthmacimportsecrets N,R,P16384,8,1MAX_BYTES1024defhash_password(password:str)-str:每次生成独立盐参数随hash保存为以后迁移保留依据。rawpassword.encode(utf-8)iflen(password)12orlen(raw)MAX_BYTES:raiseValueError(Demo password requires 12 characters and at most 1024 UTF-8 bytes)saltsecrets.token_bytes(16)derivedhashlib.scrypt(raw,saltsalt,nN,rR,pP,dklen32,maxmem64*1024*1024)returnfscrypt${N}${R}${P}${salt.hex()}${derived.hex()}defverify_password(password:str,encoded:str)-bool:只接受本项目已知参数损坏的存储值不能要求服务器进行无限昂贵计算。try:name,n,r,p,salt_hex,digest_hexencoded.split($)ifname!scryptor(int(n),int(r),int(p))!(N,R,P):returnFalseraw,salt,expectedpassword.encode(utf-8),bytes.fromhex(salt_hex),bytes.fromhex(digest_hex)iflen(raw)MAX_BYTESorlen(salt)!16orlen(expected)!32:returnFalseactualhashlib.scrypt(raw,saltsalt,nN,rR,pP,dklen32,maxmem64*1024*1024)returnhmac.compare_digest(actual,expected)except(ValueError,TypeError,AttributeError):returnFalse专题目录《认证、授权与企业级权限系统课程目录》 · 上一篇《Python后端权限系统01先画边界再写登录——采购申请中的身份、动作与资源》 · 下一篇《Python后端权限系统03登录之后还要继续核验——不透明令牌、过期与退出登录》