密码存储入门
了解本课程的目标受众和内容。
你真的应该存储密码吗?
很抱歉以这种方式开头,但我建议你尽量避免我接下来要讲的所有关于存储密码的内容。身份验证本身已经足够复杂了,而你还得处理账户创建、密码重置以及双因素认证等问题。等你完成这些工作时,公司的销售团队可能已经了解到通过 Active Directory 实现单点登录了。最终,你不应该自己存储密码。不过,如果确实有必要,本课程将教你如何尽可能安全地存储密码。
如果你正在从零开始做一个简单的项目,可以考虑使用 Google 登录 或 GitHub OAuth。对于更复杂的项目,建议使用独立的身份认证应用,比如 Keycloak,或者直接采用完整的身份服务,如 Auth0。这些工作早已有人完成,不如直接利用现成的解决方案。

谁需要了解这些内容?
本课程面向的是开发需要用户登录的 Web 应用的开发者,或接手此类系统的开发者。现实中存在大量这类应用,其中一些质量非常差。你通常可以通过其不合理的最短密码长度限制和无意义的字符规则来判断一个系统是否糟糕。但我的目的不是批评任何人,而是让你掌握事实并制定改进方案。因此,如果你正在维护这样的应用,请不必担心。本课程将揭示问题所在,并告诉你如何修复它们。
如何安全存储密码
在讨论错误的密码存储方式之前,我们先来看看正确的做法。目前最优秀的密码哈希算法是 Argon2,而在 Python 中最佳实现是 PyNaCl。你暂时不需要完全相信我,我会在课程中逐步解释原因。
以下是使用 PyNaCl 构建用户认证系统的代码核心部分。本课程中的所有示例均使用 Python 编写,并以 SQLite 作为数据存储。如果你使用其他语言,libsodium 提供了多种语言的绑定。
1import sqlite3 2from nacl import pwhash 3from nacl.exceptions import InvalidkeyError 4 5OPS_LIMIT = pwhash.OPSLIMIT_MODERATE 6MEM_LIMIT = pwhash.MEMLIMIT_MODERATE 7 8conn = sqlite3.connect('users.db') 9cursor = conn.cursor().execute(''' 10CREATE TABLE IF NOT EXISTS users ( 11 username VARCHAR(16) PRIMARY KEY, 12 nacl_pwhash VARCHAR(100) 13) 14''') 15 16def create_account(username, password): 17 if len(password) < 8: 18 raise Exception('密码太短') 19 20 cursor = conn.cursor() 21 cursor.execute('SELECT count(*) FROM users WHERE username=?', (username,)) 22 result = cursor.fetchone() 23 if result[0] > 0: 24 raise Exception('用户名已被占用') 25 26 hashed = pwhash.str(bytes(password, 'UTF-8'), 27 opslimit=OPS_LIMIT, 28 memlimit=MEM_LIMIT, 29 ) 30 cursor.execute('INSERT INTO users (username, nacl_pwhash) VALUES (?, ?)', (username, hashed)) 31 conn.commit() 32 33def login(username, password): 34 cursor = conn.cursor() 35 cursor.execute('SELECT nacl_pwhash FROM users WHERE username=?', (username,)) 36 result = cursor.fetchone() 37 if result == None: 38 # 用户不存在。确保登录过程仍然缓慢。 39 pwhash.str(b'', 40 opslimit=OPS_LIMIT, 41 memlimit=MEM_LIMIT, 42 ) 43 raise Exception('用户名或密码错误') 44 45 try: 46 pwhash.verify(result[0], bytes(password, 'UTF-8')) 47 except(InvalidkeyError): 48 raise Exception('用户名或密码错误') 49 50# 创建几个示例账户 51create_account('jim', 'password') 52create_account('sue', 'another-password') 53 54try: 55 # 尝试登录 56 login('jim', 'password') 57 print('登录成功') 58except Exception as e: 59 print(f'登录错误: {e}')
该示例创建了一个用户数据库,并模拟了登录流程。你可以尝试修改传给 login 的用户名或密码,确认登录会失败。你还可以模拟密码泄露的情况:
1for row in conn.cursor().execute('SELECT * FROM users'): 2 print(row)
这将输出类似如下内容:
1('jim', b'$argon2id$v=19$m=65536,t=2,p=1$vKUwc4GXTdWFxbmdfm7rew$5x6dY7g2sDquntdSQuzxL1S3mU5Wnfpr+LTTsvunrew') 2('sue', b'$argon2id$v=19$m=65536,t=2,p=1$Gyshn0jEtyyrPjQS2hBP3A$4YkTNdqaBHNMv00jn4j65Z8y8AMfOrHnHEWdXEXovZE')
注意:导出的数据中并不包含任何真实密码。
nacl.pwhash.str返回的是一个包含所有验证所需参数的字符串,可直接存入数据库,非常方便。
这个认证示例正是我们最终希望达到的目标,因此在课程结束时我们会再次回顾它。