Python 用户密码加密存储

作者: 潘峰 / 2021-09-04 / 分类: Work

Python, 安全

进行 Web 应用开发时,用户密码如何保存才足够安全是一个值得重视的问题,作为应用开发者,我们有责任保证用户的信息安全。对于金融行业来说,更是如此。

一、哈希算法

对于密码加密存储的问题,行业内基本上都是基于哈希加密的方式来解决,说到哈希加密,首先需要了解下什么是哈希算法。

维基百科-哈希算法
知乎:通俗地理解哈希函数

哈希算法不可逆的性质可以在发生拖库后有效地阻止黑客对用户密码进行破解。

虽然对用户密码做单向哈希后再存储会比明文存储稍微安全一些。但这种简单的加密方式仍能被黑客通过诸如查表法、彩虹表的方式进行低成本的破解,因此这种方式也是不可取的。

举例来说,md5 是一种应用广泛的哈希算法,速度很快。标准的 md5 输出是固定的 128bit,但通常截断为一个 32bit 的十六进制字符串来表示。但由于如今已经能很轻易攻破其碰撞抵抗,现在也仅被用于数据完整性校验等非数据安全领域。

Python 哈希算法库

hashlib 简介

hashlib 是 Python 内置的哈希算法标准库,针对不同的哈希算法实现了一个通用的接口。

>>> import hashlib
>>> hashlib.md5("test".encode("utf-8")).hexdigest()
'098f6bcd4621d373cade4e832627b4f6'
>>> hashlib.sha256("test".encode("utf-8")).hexdigest()
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'

>>> md5 = hashlib.md5()
md5.update("test".encode("utf-8"))
md5.hexdigest()
'3dd0cd797a7399b56c470612887108eb'

二、加盐哈希

加盐哈希是指在明文密码中混入随机字符串(即:密码 + 盐值)后再进行哈希加密。

这种“加盐”技术可以使彩虹表等攻击变得难以实现。不过值得注意的是,虽然盐值无需进行加密,但是必须避免盐值复用,或使用短盐值。

正确的方式是,在实现时应该对每一个用户都使用加密的安全伪随机数生成器来生成一个足够长的唯一盐值,在密码改变时也按相同方式重新生成盐值,并使用更为安全的哈希算法如 SHA-256 进行哈希加密,再将盐值和对应的哈希值(摘要)一起存入用户数据表中。

在硬件资源有所限制的过去,这种方式几乎可以使查表法、彩虹表攻击的方式完全失效。但在硬件资源充沛尤其是 GPU 并行计算能力爆表的今天,攻击者仍可以通过暴力破解等方式实施有效的攻击。

三、密码哈希算法

密码哈希算法是专门设计用于存储密码的,它在加盐的基础上使用了大量的特殊手段来消耗计算资源,用以拖慢攻击者的运算设备,使其无法在合理的时间内运算出某一散列值对应的密码。因此通常也会被称之为“慢哈希函数”。

并且,即使在未来设备运算能力得到进一步提升,也可以通过调整密码哈希算法某些强度因子,如增加迭代次数来大幅增加运算成本抵御攻击。

常见的密码哈希算法有 Argon2, Scrypt, Bcrypt, PBKDF2 等。

其中 Argon2 是发布最晚(2013 年左右)的一种算法,它借鉴了过往的一些算法的设计和经验教训,并在 2015 年 7 月赢得了密码哈希竞赛,大有成为下一代密码哈希算法标准的趋势。对于一个新系统来说它无疑是当前进行密码加密存储的最优选择。

Python 密码哈希算法库

Passlib 简介

Passlib 是一个方便易用的基于 Python 的密码哈希算法第三方库,它提供数十种密码哈希算法的跨平台实现。并且也可作为一种管理现有密码哈希的框架。

Passlib 安装

Passlib 自身集成了一些算法,但对于 Argon2 Scrypt Bcrypt 等算法,还需要按需安装实现这些算法的基础库,以 Argon2 为例,安装命令如下:

$ pip3 install 'passlib[argon2]'

passlib.hash 模块

Passlib 所支持的算法都可以从 passlib.hash 模块中导入,它们都继承于 passlib.ifc.PasswordHash 类,并定义了一些通用接口,用于生成和校验哈希值、管理算法配置及其辅助功能。

注:实际项目开发时通常不会用到该模块

# 哈希值生成
>>> from passlib.hash import argon2

>>> (h1 := argon2.hash("test"))
'$argon2id$v=19$m=102400,t=2,p=8$/F9rDUHovTcm5Fwr5fw/Bw$u/d80J3HSbr0khtF78YRag'
# ^^^^^^^^ ^^^^ ^^^^^^^^ ^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
# 类型      版本 内存占用   迭代 并行 随机盐值(每次都重新生成) 哈希值

>>> argon2.setting_kwds  # 查看可以修改的算法配置
('salt', 'salt_size', 'salt_len', 'rounds', 'time_cost', 'memory_cost', 'parallelism', 'digest_size', 'hash_len', 'type')

>>> (h2 := argon2.using(salt_size=20).hash("test"))
'$argon2id$v=19$m=102400,t=2,p=8$+b+Xcm4txdjbO6dUihGCEEKotZY$R7NHoTNIcrykCWcb22CIsA'
#                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                                使用 using() 可以修改随机盐值长度
# 哈希值校验
>>> argon2.verify("test", h1)
True
>>> argon2.verify("test", h2)
True
>>> argon2.verify("other", h2)
False
# 哈希值类型校验
>>> argon2.identify(h1)
True
>>> (h3 := passlib.hash.pbkdf2_sha256.hash("test"))
'$pbkdf2-sha256$29000$.38v5ZyzlnKOkXJujTGGEA$AzO7BQjDULT35w1d9aQDAShE7j/0/yOhUrgN1cD0KfI'
>>> argon2.identify(h3)
False

passlib.context.CryptContext 类

CryptContext 类是我们在项目开发中主要用到的类,它主要提供了以下能力:

  • 生成哈希值,以及识别哈希值所用到的算法,并验证哈希值是否正确
  • 加载新算法,弃用旧算法,弃用旧算法后迁移原用哈希值
  • 配置默认算法及其参数以及进行配置管理
常规用法
>>> from passlib.context import CryptContext
>>> ctx = CryptContext(schemes=["argon2", "pbkdf2_sha256"])

# 默认会取列表中的第一个算法
>>> (h1 := ctx.hash("test"))
'$argon2id$v=19$m=102400,t=2,p=8$mDOGEAKgNMaYM2asFaK0Vg$baidGEZd9ptoUVEv3IXAAA'

# 使用 scheme 参数指定需要使用的算法
>>> (h2 := ctx.hash("test", scheme="pbkdf2_sha256"))
'$pbkdf2-sha256$29000$9F6LEcKY05pzbq1Vaq2Vsg$D30QsgdFfC.Ydngyutl2gvxYruHKvoV6hfPb7A2Tu50'

# 使用 default 参数修改默认算法
>>> ctx = CryptContext(schemes=["argon2", "pbkdf2_sha256"], default="pbkdf2_sha256")
>>> (h3 := ctx.hash("test"))
'$pbkdf2-sha256$29000$oTSGMAYghHAuxdi7V2rNuQ$hVzhFx47C5ndVtkLkdYPK1358Cch/FCs5Fc6nabv5qQ'

# 验证
>>> [ctx.identify(h) for h in (h1, h2, h3)]
['argon2', 'pbkdf2_sha256', 'pbkdf2_sha256']
>>> any([ctx.verify("test", h) for h in (h1, h2, h3)])
True
算法新增/弃用&哈希值迁移
>>> from passlib.context import CryptContext

# 通过 deprecated 参数定义弃用的算法
>>> ctx = CryptContext(schemes=["argon2", "pbkdf2_sha256"], deprecated=["pbkdf2_sha256"])

# 使用 needs_update() 方法可以判断哈希值是否需要迁移
>>> ctx.needs_update('$argon2id$v=19$m=102400,t=2,p=8$mDOGEAKgNMaYM2asFaK0Vg$baidGEZd9ptoUVEv3IXAAA')
False
>>> ctx.needs_update('$pbkdf2-sha256$29000$9F6LEcKY05pzbq1Vaq2Vsg$D30QsgdFfC.Ydngyutl2gvxYruHKvoV6hfPb7A2Tu50')
True

# 使用 verify_and_update() 方法在哈希值需要迁移时可以重新生成哈希值
>>> ctx.verify_and_update("test", '$argon2id$v=19$m=102400,t=2,p=8$mDOGEAKgNMaYM2asFaK0Vg$baidGEZd9ptoUVEv3IXAAA')
(True, None)
>>> ctx.verify_and_update("test", '$pbkdf2-sha256$29000$9F6LEcKY05pzbq1Vaq2Vsg$D30QsgdFfC.Ydngyutl2gvxYruHKvoV6hfPb7A2Tu50')
(True, '$argon2id$v=19$m=102400,t=2,p=8$qVUqJUTI+b93TmmNsZby3g$JXrotTH2hg+MXypb46wmVQ')
配置管理

注:需要遵循 scheme__setting 格式

>>> from passlib.context import CryptContext

# 定义类时通过硬编码设置
>>> ctx = CryptContext(schemes="argon2", argon2__salt_size=8)

# 使用 update() 硬编码修改配置
>>> ctx.update(argon2__memory_cost=10240, argon2__rounds=3)

# 查看当前自定义的配置
>>> ctx.to_dict()
{'schemes': ['argon2'], 'argon2__memory_cost': 10240, 'argon2__rounds': 3, 'argon2__salt_size': 8}

>>> ctx.hash("test")
'$argon2id$v=19$m=10240,t=3,p=8$/38vhXAO4dw$YH5Tl8IbPc3ix6t/58X6Qg'

# 自定义配置输出成 INI 格式的文本内容
>>> ctx = CryptContext(schemes=["argon2", "pbkdf2_sha256"], default="argon2", deprecated="pbkdf2_sha256", argon2__salt_size=8, argon2__rounds=3)
>>> print(ctx.to_string())
[passlib]
schemes = argon2, pbkdf2_sha256
default = argon2
deprecated = pbkdf2_sha256
argon2__rounds = 3
argon2__salt_size = 8
# 配置加载
>>> myctx2 = CryptContext.from_string(ctx.to_string())  # 直接从字符串加载
>>> myctx3 = CryptContext.from_path("/some/path/on/local/system")  # 从 INI 配置文件加载

另外,当我们更新限制性的配置后,也可以通过哈希值迁移方式进行哈希值的重新生成。

注:限制性配置指的是以 max_min_ 开头的配置

>>> ctx = CryptContext("argon2", argon2__min_rounds=3)
>>> ctx.needs_update("$argon2id$v=19$m=102400,t=2,p=8$Sun9HwPgfC9lTMl5T8nZew$vpB18D2PGY8M+gPpnL39LA")
True

参考来源:
https://www.infoq.cn/article/how-to-encrypt-the-user-password-correctly
https://www.zhihu.com/question/20479856
https://blog.jianguoyun.com/?p=438
https://www.zhihu.com/question/283698074/answer/1068866550
https://passlib.readthedocs.io/en/stable/index.html