
前言「简单的用户系统」通常指只做三件事注册、登录、查当前登录的是谁。不做角色、不做审计、不接 Web就是一段能在命令行跑起来的代码。本文的目标是把这三件事用最短的篇幅写到正确。要预先纠正一个很危险的想法「简单」指的是功能少不是安全可以省。功能可以有取舍但「密码怎么存」「登录状态怎么表示」这两个环节没有任何可以偷懒的空间。哪怕只有十个用户把密码裸存进 JSON 也是错的——一旦这份文件被同步到网盘、被误提交进代码仓库、被随手发给同事用户的真实口令就泄露了而用户往往在别处复用同一个口令。另一个误解是「反正本地小工具密码存明文方便调试」。调试可以用日志看「第几个用户登录成功」没有任何理由需要看到真实口令。本文的实现要点用hashlib.pbkdf2_hmac加随机盐派生口令3.4 起提供不依赖 OpenSSL 1.1兼容性比scrypt更广用hmac.compare_digest做恒定时间比较用secrets生成会话令牌数据存在一个 JSON 文件里写入用「临时文件 原子替换」避免写坏原文件。最后照例提醒Python 2.7 已于 2020 年 1 月 1 日停止维护。老写法里的raw_input()在 Python 3 里是input()print xxx语句要写成print(xxx)而且hashlib.pbkdf2_hmac在 Python 2 标准库里压根不存在。一、最小设计三样东西一个能用的用户系统只需要想清楚三件事问题本文答案为什么账号数据存哪一个 JSON 文件无需数据库直接能看口令怎么存pbkdf2_hmac派生值 随机盐不可逆抗彩虹表登录状态怎么表示随机令牌映射到用户名不把用户名当凭据第三点尤其重要。很多简单实现是「登录成功后logged_in True」——这样只能记录有人登录了却记不住是谁更没法同时支持多个会话。换成「发一个随机令牌token给客户端服务端保存 令牌→用户名 的映射」才是真正可用的会话模型。存储格式大致长这样盐和哈希一起存因为校验时必须用同一份盐{alice: {algo: pbkdf2_sha256,iterations: 200000,salt: 9f2c...,hash: 4b7d...}}之所以把算法名和迭代次数一起写进去是为了以后能平滑升级新注册的用户用更高的迭代次数老用户下次登录成功时再顺手重算一遍不需要一次性全员重置密码。二、为什么用 pbkdf2_hmachashlib.pbkdf2_hmac的签名是hashlib.pbkdf2_hmac(hash_name, password, salt, iterations, dklenNone)全都是位置参数hash_name传字符串如sha256password和salt必须是 bytes-like。dklen为None时使用所选哈希的摘要长度sha256 即 32 字节。它和hashlib.scrypt都是标准库里的密钥派生函数。区别在于scrypt的内存消耗可调对抗 GPU 更有利但它依赖 OpenSSL 1.1.0 及以上pbkdf2_hmac的可用性更广几乎所有 CPython 构建都带。两者都远好于直接md5/sha1。迭代次数是「慢」的调节旋钮。次数越高攻击者每次猜测的成本越高同时你自己的登录也越慢需要在体验和强度之间找平衡。具体取多少参考你所在组织或行业的安全基线来定不要照抄一个数字了事。三、完整实现以下代码可直接存成simple_users.py运行需要 Python 3.8全部依赖标准库# 适用于 Python 3.8import getpassimport hashlibimport hmacimport jsonimport osimport secretsfrom pathlib import PathDATA_FILE Path(users.json)ITERATIONS 200_000DKLEN 32SESSIONS {} # token - username进程内存态def make_record(password, iterationsITERATIONS):生成一条可 JSON 序列化的口令记录。salt os.urandom(16)dk hashlib.pbkdf2_hmac(sha256, password.encode(utf-8), salt,iterations, dklenDKLEN)return {algo: pbkdf2_sha256,iterations: iterations,salt: salt.hex(),hash: dk.hex(),}def check_password(password, record):if record.get(algo) ! pbkdf2_sha256:return Falsesalt bytes.fromhex(record[salt])expected bytes.fromhex(record[hash])dk hashlib.pbkdf2_hmac(sha256, password.encode(utf-8), salt,record[iterations], dklenlen(expected))return hmac.compare_digest(dk, expected)def load_users():if not DATA_FILE.exists():return {}with DATA_FILE.open(r, encodingutf-8) as fp:return json.load(fp)def save_users(users):先写临时文件再原子替换避免写到一半把原文件毁掉。tmp DATA_FILE.with_suffix(.tmp)with tmp.open(w, encodingutf-8) as fp:json.dump(users, fp, ensure_asciiFalse, indent2)os.replace(tmp, DATA_FILE) # 同一文件系统内是原子操作def register(users, username, password):username username.strip().lower()if not username:raise ValueError(用户名不能为空)if len(password) 8:raise ValueError(密码至少 8 位)if username in users:raise ValueError(用户名已存在)users[username] make_record(password)save_users(users)def login(users, username, password):username username.strip().lower()record users.get(username)if record is None:make_record(password) # 空跑一次抹平时间差return Noneif not check_password(password, record):return Nonetoken secrets.token_urlsafe(32)SESSIONS[token] usernamereturn tokendef current_user(token):return SESSIONS.get(token)def logout(token):return SESSIONS.pop(token, None)def main():users load_users()while True:print(\n1注册 2登录 3我是谁 4登出 0退出)choice input(请选择: ).strip()if choice 0:breakif choice 1:try:register(users, input(用户名: ), getpass.getpass(密码: ))print(注册成功)except ValueError as exc:print(注册失败:, exc)elif choice 2:token login(users, input(用户名: ), getpass.getpass(密码: ))if token is None:print(用户名或密码错误)else:print(登录成功令牌:, token)elif choice 3:token input(令牌: ).strip()print(当前用户:, current_user(token))elif choice 4:logout(input(令牌: ).strip())print(已登出)else:print(无效选项)if __name__ __main__:main()常见坑点1. 明文存密码❌users[username] password✅users[username] make_record(password)这是本文唯一一条零容忍的坑其余都可以商量这条不行。2. 用比较哈希❌return dk expected✅return hmac.compare_digest(dk, expected)字节比较的短路行为会泄露前缀信息compare_digest专为恒定时间设计。3. 盐写死或用固定值❌salt bmyapp_salt✅salt os.urandom(16)每个用户一份 固定盐会让两个相同口令产生相同哈希也方便彩虹表预计算。4. 把哈希直接写回原文件❌json.dump(users, open(DATA_FILE, w))✅ 写临时文件再用os.replace(tmp, DATA_FILE)原子替换 写入过程中断电或异常原文件就变成半截 JSON所有人的账号一起丢。5. 忘记处理文件不存在❌json.load(open(users.json))首次运行时FileNotFoundError✅ 先判断DATA_FILE.exists()或try/except FileNotFoundError返回空字典 第一次运行必然没有数据文件这条一定要处理。6. 用random生成会话令牌❌secrets换成random.choice/random.getrandbits✅secrets.token_urlsafe(32)random的官方文档明确写着它不适用于安全用途输出可被预测。7. 把令牌当用户名用❌ 客户端直接上传用户名当已登录凭据 ✅ 服务端用 令牌→用户名 的映射反查身份 用户名是公开信息冒充成本为零。8. 用户名不规范化❌ 用户Alice和alice被当成两个账号 ✅ 注册和登录都先strip().lower()或明确采用大小写敏感策略并写进文档 否则用户会在我明明注册过和用户名已存在之间反复横跳。总结环节简单但不安全的做法正确的做法口令存储明文 / MD5pbkdf2_hmac或scrypt 随机盐口令比较hmac.compare_digest会话状态一个布尔标志随机令牌映射到用户名令牌生成randomsecrets.token_urlsafe文件写入直接覆写临时文件 os.replace一个简单的用户系统功能上可以只有注册、登录、查身份这三件事但这三件事底下压着的口令存储、恒定时间比较、随机令牌、原子写文件四条原则一条都不能省。省掉它们并不会让代码更简单只会让问题推迟到出事那天才爆发。