0%

Flask和Django的密文生成分析--厉害的PBKDF2

概述

Flask以及Django内置的密文生成方式均采用PDKDF2(Password-Based Key Derivation Function),是一种基于迭代复杂度保证密码安全的密文生成方式,PBKDF2通过指定伪随机函数(伪随机数生成器是指通过特定算法生成一系列的数字,使得这一系列的数字看起来是随机的,但是实际是确定的如信息摘要算法MD5、SHA-256等)以及随机盐值处理输入值,并进行该过程的有限次迭代生成最终的密文。

PBKDF2需要以下输入值:
1.哈希函数,建议使用SHA-256或者更加安全的算法。
2.用户输入的密码(明文)。
3.盐,至少8位,应使用安全的随机数(Flask默认长度为8,Django默认长度为12)。
4.迭代的次数,因为需要消耗cpu,具体视情况而定,不少于100000次(Flask以及Django默认的次数均为150000次)。

PBKDF2通过将输入值以及随机产生的盐先进行哈希,再将输入值和该产生的哈希值进行哈希,将该过程迭代有限次生成最终的密文,迭代过程大大增加了破解难度。有效防止了通过碰撞以及彩虹表破解密码。

Python实现

Flask

Flask内部的密文生成函数为from werkzeug.security import generate_password_hashgenerate_password_hash(password, method="pbkdf2:sha256", salt_length=8)接受用户输入的密码,默认使用的哈希函数是SHA-256,盐的长度默认是8。

源码分析

1
2
3
4
def generate_password_hash(password, method="pbkdf2:sha256", salt_length=8):
salt = gen_salt(salt_length) if method != "plain" else ""
h, actual_method = _hash_internal(method, salt, password)
return "%s$%s$%s" % (actual_method, salt, h)

gen_salt函数用于生成指定长度的随机盐值:

1
2
3
4
5
6
7
# werkzeug/security.py
SALT_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

def gen_salt(length):
if length <= 0:
raise ValueError("Salt length must be positive")
return "".join(_sys_rng.choice(SALT_CHARS) for _ in range_type(length))

起主要作用函数是_hash_internal(method, salt, password),该函数内部通过分析参数method的值,决定是否生成密文、是否使用PBKDF2以及选用的哈希函数。下面看代码注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# werkzeug/security.py
def _hash_internal(method, salt, password):
if method == "plain": # 如果method值为'plain',则使用明文存储,不进行加密
return password, method

if isinstance(password, text_type): # 判断password值的类型,哈希函数需要使用bytes类型,此处进行转换
password = password.encode("utf-8")

if method.startswith("pbkdf2:"): # 判断method值的是否以'pbkdf2:'开头,如果是,表示使用PBKDF2,如果不是,如method='sha256',则会使用hmac生成密文,和PBKDF2的主要区别是少了迭代的步骤。
args = method[7:].split(":")
if len(args) not in (1, 2):
raise ValueError("Invalid number of arguments for PBKDF2")
method = args.pop(0)
iterations = args and int(args[0] or 0) or DEFAULT_PBKDF2_ITERATIONS # 获取迭代次数,如果没有传递,则使用默认的150000次。
is_pbkdf2 = True
actual_method = "pbkdf2:%s:%d" % (method, iterations) # 记录实际使用的哈希函数以及进行的迭代次数
else:
is_pbkdf2 = False
actual_method = method

if is_pbkdf2: # PBKDF2的运算分支,必须要提供盐值。
if not salt:
raise ValueError("Salt is required for PBKDF2")
rv = pbkdf2_hex(password, salt, iterations, hashfunc=method) # rv为PBKDF2运算的值。
elif salt: # 如果没有使用PBKDF2,但是提供了盐值,则使用hmac生成密文,hmac需要提供password、盐以及使用的哈希算法。
if isinstance(salt, text_type):
salt = salt.encode("utf-8")
mac = _create_mac(salt, password, method)
rv = mac.hexdigest()
else: # 如果既不使用PBKDF2,也没有提供盐值,则直接使用哈希函数进行生成。
rv = hashlib.new(method, password).hexdigest()
return rv, actual_method

_hash_internal(method, salt, password)的实现可以看到,进行PBKDF2的主要函数是pbkdf2_hex(password, salt, iterations, hashfunc=method),继续深入,发现最终产生值的函数是使用的Pythonn内置模块hashlib提供的pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None)函数。该函数完成了PBKDF2的运算过程:将密码以及随机产生的盐先进行哈希,再将输入值和该产生的哈希值进行哈希,将该过程迭代有限次生成最终的密文,这里对于代码的实现不做深究(代码的实现很简单,主要是原理的理解)。

产生值以及验证

通过源码分析可知:generate_password_hash生成的密文组成部分为:使用哈希算法名(15000表示的是迭代的次数),盐值,SHA-256值,通过$分隔:

1
pbkdf2:sha256:150000$2b8nQyP1$d6eb56ab9813aa4300809bea2e6b7a22143969fdae6818ce14cbdd158bd8436e

pbkdf2.png
这就是实际存储在数据库中的用户密码的密文,此时即使Flask也无法知晓用户的实际密码,需要对用户输入的密码进行验证时,只能再次进行一次该过程,动态演算后,将获得的密文和数据库的密文进行比较,判断是否一致。生成的密文之所以需要加上pbkdf2:sha256:150000$2b8nQyP1$就是为了在验证的时候能够使用相同的运算环境,尤其是盐值,因为是动态生成的,每次都不一样,所以必须保存起来。
Flask提供了对应的验证函数check_password_hash(pwhash, password),该函数需要传递存储的密文以及待验证的明文密码,验证通过返回True,反之返回False,代码如下:

1
2
3
4
5
6
werkzeug/security.py
def check_password_hash(pwhash, password):
if pwhash.count("$") < 2:
return False
method, salt, hashval = pwhash.split("$", 2) # 解析出method以及盐值
return safe_str_cmp(_hash_internal(method, salt, password)[0], hashval) # 使用相同的运算环境重新生成密文进行比较

从代码的实现可以看到,内部也是使用的_hash_internal来生成密文的。

用户认证模型

Flask中不提供数据库功能,这里使用Flask-SQLAlchemy作为数据库扩展,使用Flask-Login作为用户模型扩展,创建用户模型类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from werkzeug.security import check_password_hash, generate_password_hash
from flask_login import UserMixin

class User(db.Model, UserMixin):

__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique=True, index=True)
password_hash = db.Column(db.String(128))
# 此处需要注意,存储generate_password_hash生成的密码,长度需要根据PBKDF2选用的哈希函数以及盐值的长度而定

def __init__(self, email=None, username=None, password=None):
self.email = email
self.username = username
self.password = password

@property
def password(self):
raise AttributeError('password is not a readable attribute')

@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)

def validate_password(self, password):
return check_password_hash(self.password_hash, password)

def __repr__(self):
return f'<User {self.username}>'

Django

Django内的密文生成功能由from django.contrib.auth.hashers import make_password提供,该方法通过获取Django settings中的PASSWORD_HASHERS中配置的hasher进行密文的生成。

1
2
3
4
5
6
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]

默认使用django.contrib.auth.hashers.PBKDF2PasswordHasher进行计算,该类的不是实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# django/contrib/auth/hashers.py
class PBKDF2PasswordHasher(BasePasswordHasher):

algorithm = "pbkdf2_sha256"
iterations = 150000
digest = hashlib.sha256

def encode(self, password, salt, iterations=None):
assert password is not None
assert salt and '$' not in salt
iterations = iterations or self.iterations
hash = pbkdf2(password, salt, iterations, digest=self.digest) # 主要函数pbkdf2()
hash = base64.b64encode(hash).decode('ascii').strip() # 唯一的区别:django对PBKDF2产生的值使用base64进行了编码
return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash)


# django/utils/crypto.py
def pbkdf2(password, salt, iterations, dklen=0, digest=None):
"""Return the hash of password using pbkdf2."""
if digest is None:
digest = hashlib.sha256
dklen = dklen or None
password = force_bytes(password)
salt = force_bytes(salt)
return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen) # 最终生成函数和flask一样调用的hashlib.pbkdf2_hmac()

从结果来看,django中的默认使用的密文生成方式和Flask类似,默认使用的哈希函数为SHA-256,迭代次数为150000,随机产生的盐值默认长度为12(Flask的默认长度为8),在PBKDF2PasswordHasher的基类BasePasswordHasher中定义。都是通过Pythonn内置模块hashlib提供的pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None)函数完成PBKDF2的运算过程。唯一的不同是Django中对运算的结果进行了base64编码后再返回,这点从最终得到的密文值也可以发现:

1
pbkdf2_sha256$150000$sYt5dCzmVAXv$bbPcDOWeuIXIQjA/5eoCuI0C1mwvaIevfiUCh2hUolA=

SHA-256产生的是64个十六进制数,不可能出现=,而=在base64中常见,用来填补缺少的位数,可以猜测使用了base64编码。关于base64的问题参考Base64编码

此外,也提供了对应的check_password(password, encoded, setter=None, preferred='default')用于验证用户密码,作用的原理和Flaske的check_password(pwhash, password)基本相似。

用户认证

Django中一般不直接调用make_password()函数,其内置的用户模型类AbstractUser的基类AbstractBaseUser提供了两个方法set_password(self, raw_password)check_password(self, raw_pasword)用于保存和验证用户密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# user/views.py
class RegisterView(View):
def get(self, request, *args, **kwargs):
register_get_form = RegisterGetForm()
return render(request, "register.html", {
"register_get_form": register_get_form,
})

def post(self, request, *args, **kwargs):
register_post_form = RegisterPostForm(request.POST)
if register_post_form.is_valid():
mobile = register_post_form.cleaned_data["mobile"]
password = register_post_form.cleaned_data["password"]
user = UserProfile(username=mobile)
# 使用set_password的自带加密的
user.set_password(password)
user.mobile = mobile
user.save()
login(request, user)
return HttpResponseRedirect(reverse("index"))
else:
register_get_form = RegisterGetForm()
return render(request, "register.html", {
"register_get_form": register_get_form,
"register_post_form": register_post_form,
})

在创建用户的时候,直接调用用户对象的set_password()方法传递获取的明文密码,即可完成对应密文的获取。

此外,Django提供了完善的用户认证系统,提供django.contrib.auth.authenticate()方法用于用户的身份验证,默认使用的认证类django.contrib.auth.backends.ModelBackend中就是通过调用user.check_password(password)来验证用户的。
关于Django详细的认证流程参考Django自定义用户认证系统

小结

Flask以及Django默认的密文生成方式均采用PDKDF2,该函数通过增加迭代次数的方式增加的被强行破解的难度,此外Flask以及Django在实现的时候都采用的不固定式的随机盐,大大增强了安全性,这已经能够胜任绝大多数的使用场景了。
如果需要更安全的密码机制,两者都提供了采用Bcrypt生成密文的方法,Django中通过在PASSWORD_HASHERS中设置:

1
2
3
4
5
6
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', # 放在第一个位置
'django.contrib.auth.hashers.BCryptPasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
]

Flask中也提供了第三方扩展Flask-Bcrypt来实现,具体使用参考官方文档

需要注意的是,安全性越高的算法需要的计算量越大,相应的耗时越长,在使用的时候,注意取舍。