
前言「用户管理系统」这个词经常被简化理解成一个登录框其实它是一组围绕「身份identity」的完整功能集合注册、登录、改密、停用、角色划分、操作留痕。一个能真正跑起来的最小系统至少要有四层——数据存储、密码处理、业务规则、交互界面。很多人一上手就想套 Web 框架结果卡在更本质的问题上密码到底怎么存权限到底怎么判。另一个流传很广的误解是「密码要加密保存」。密码不应该被加密可逆而应该被哈希不可逆并加盐。历史上大量数据泄露事故就是把口令存成 MD5 或 SHA-1库被拿走后用彩虹表rainbow table几秒钟就能还原出常见口令。正确做法是使用专门的密钥派生函数KDFkey derivation function标准库给出的选择是hashlib.scrypt3.6 起提供内部依赖 OpenSSL 1.1.0 及以上或hashlib.pbkdf2_hmac。本文用纯标准库写一个命令行用户管理系统把整条链路走通sqlite3存数据、hashlib.scrypt存密码、hmac.compare_digest做恒定时间比较、secrets生成一次性令牌。代码不需要安装任何第三方库。顺带说明Python 2.7 已于 2020 年 1 月 1 日停止维护。网上大量用户系统教程还是print xxx语句、raw_input()、dict.iteritems()的老写法那些都是 Python 2 语法在 Python 3 里根本跑不起来本文一律用 Python 3 写法。一、先定表结构与分层写代码前先把「存什么」想清楚。用户表至少要有唯一用户名、口令哈希、角色、启用状态、创建时间。审计表记录「谁在什么时候做了什么」出事时是唯一的追责依据。层次职责本文对应存储层建表、连接、事务sqlite3connect()密码层派生、校验不碰数据库hash_password/verify_password业务层注册、鉴权、停用、留痕UserService交互层读输入、打印结果main()菜单循环关键原则密码层永远不知道数据库的存在。把「怎么存口令」和「怎么存用户」拆开以后要把 scrypt 换成 bcrypt 时改动面只有两个函数。二、密码要用 scrypt 加盐比较要用 compare_digesthashlib.scrypt的签名是hashlib.scrypt(password, *, salt, n, r, p, maxmem0, dklen64)除第一个参数外全部是仅关键字参数参数名写错会直接TypeError。salt必须是 bytes-like 对象推荐用os.urandom(16)每用户生成一份独立随机盐。n是 CPU/内存代价因子r是块大小p是并行度。内存占用大致是128 * n * r字节取n2**14、r8时约 16 MiB低于 OpenSSL 默认的 32 MiBmaxmem上限所以可以不传maxmem如果把n提到2**20内存需求会超过默认上限并抛出错误那时必须显式调大maxmem。校验时不能写dk expected。会在第一个不同字节处短路返回攻击者可以用响应时间逐字节猜出哈希。标准库提供了恒定时间比较hmac.compare_digest(a, b)两个参数都必须是 bytes-like 或 ASCII 字符串。另外用户不存在和密码错误应该返回同一种失败结果并且两条路径都做一次同样开销的哈希运算否则攻击者能靠时间差枚举出哪些用户名真实存在。三、完整实现下面这段完整代码可以直接存成user_sys.py运行需要 Python 3.8 及以上sqlite3、getpass、hmac、secrets都是标准库。# 适用于 Python 3.8import getpassimport hashlibimport hmacimport osimport sqlite3from datetime import datetime, timezoneDB_PATH users.dbSCRYPT_N, SCRYPT_R, SCRYPT_P 2 ** 14, 8, 1def now_iso():统一的 UTC 时间戳避免本地时区造成的先后顺序混乱。return datetime.now(timezone.utc).isoformat(timespecseconds)def hash_password(password):返回 scrypt$salt_hex$dk_hex盐和派生密钥一起存。salt os.urandom(16)dk hashlib.scrypt(password.encode(utf-8), saltsalt,nSCRYPT_N, rSCRYPT_R, pSCRYPT_P, dklen32)return scrypt${}${}.format(salt.hex(), dk.hex())def verify_password(password, stored):try:algo, salt_hex, dk_hex stored.split($)except ValueError:return Falseif algo ! scrypt:return Falsesalt bytes.fromhex(salt_hex)expected bytes.fromhex(dk_hex)dk hashlib.scrypt(password.encode(utf-8), saltsalt,nSCRYPT_N, rSCRYPT_R, pSCRYPT_P,dklenlen(expected))return hmac.compare_digest(dk, expected)def connect():# timeout 让并发写入时等待锁而不是立刻抛 database is lockedconn sqlite3.connect(DB_PATH, timeout5)conn.row_factory sqlite3.Rowconn.execute(PRAGMA foreign_keys ON)return conndef init_db():with connect() as conn:conn.executescript(CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT,username TEXT NOT NULL UNIQUE,password_hash TEXT NOT NULL,role TEXT NOT NULL DEFAULT user,is_active INTEGER NOT NULL DEFAULT 1,created_at TEXT NOT NULL);CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT,actor TEXT NOT NULL,action TEXT NOT NULL,target TEXT,ts TEXT NOT NULL);)class UserService:def __init__(self, conn):self.conn conndef _log(self, actor, action, targetNone):self.conn.execute(INSERT INTO audit_log (actor, action, target, ts) VALUES (?, ?, ?, ?),(actor, action, target, now_iso()),)def create_user(self, username, password, roleuser):if not username or len(password) 8:raise ValueError(用户名不能为空密码至少 8 位)try:self.conn.execute(INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?),(username, hash_password(password), role, now_iso()),)except sqlite3.IntegrityError:raise ValueError(用户名已存在) from Noneself._log(system, create_user, username)def authenticate(self, username, password):row self.conn.execute(SELECT id, password_hash, is_active FROM users WHERE username ?,(username,),).fetchone()if row is None:hash_password(password) # 空跑一次抹平时间差return Noneif not row[is_active]:return Noneif verify_password(password, row[password_hash]):self._log(username, login_ok)return row[id]self._log(username, login_fail)return Nonedef set_active(self, actor, username, active):cur self.conn.execute(UPDATE users SET is_active ? WHERE username ?,(1 if active else 0, username),)self._log(actor, set_active, username)return cur.rowcountdef list_users(self):return self.conn.execute(SELECT id, username, role, is_active, created_at FROM users ORDER BY id).fetchall()def main():init_db()conn connect()try:svc UserService(conn)while True:print(\n1注册 2登录 3用户列表 4停用 0退出)choice input(请选择: ).strip()if choice 0:breakif choice 1:name input(用户名: ).strip()pwd getpass.getpass(密码: )try:svc.create_user(name, pwd)conn.commit()print(注册成功)except ValueError as exc:print(注册失败:, exc)elif choice 2:name input(用户名: ).strip()pwd getpass.getpass(密码: )uid svc.authenticate(name, pwd)conn.commit()if uid is None:print(用户名或密码错误)else:print(登录成功用户 id , uid)elif choice 3:for row in svc.list_users():print(dict(row))elif choice 4:target input(要停用的用户名: ).strip()if svc.set_active(admin, target, False):conn.commit()print(已停用)else:print(没有这个用户)else:print(无效选项)finally:conn.close()if __name__ __main__:main()常见坑点1. 用比较哈希❌if dk expected: return True✅return hmac.compare_digest(dk, expected)短路比较会泄露前缀匹配长度compare_digest才是恒定时间。2. 以为with sqlite3.connect(...)会关闭连接❌with sqlite3.connect(DB_PATH) as conn: ...以为出了 with 连接就关了 ✅with connect() as conn: ...只负责提交/回滚事务长期使用的连接仍要显式conn.close()或配合contextlib.closing()。 连接没关在 Windows 上文件会被占住删不掉。3. SQL 用字符串拼接❌conn.execute(fSELECT * FROM users WHERE username {name})✅conn.execute(SELECT * FROM users WHERE username ?, (name,))值一律走参数位?是 sqlite3 的占位符也有具名:name绝不要把输入拼进 SQL 文本。4. 所有用户共用一个写死的盐❌SALT bmyapp全库同一个盐 ✅ 每个用户在注册时用os.urandom(16)生成独立盐并把盐和哈希一起存。 共用盐等于给彩虹表省事还会让两个相同口令产生相同哈希一眼看出谁和谁同密码。5. 用hashlib.md5/sha1存口令❌hashlib.md5(password.encode()).hexdigest()✅hashlib.scrypt(...)或hashlib.pbkdf2_hmac(sha256, ...)通用哈希函数设计目标是「快」正好帮了暴力破解的忙KDF 设计目标是「慢」。6. scrypt 参数调太大又不给 maxmem❌hashlib.scrypt(pw, salts, n2**20, r8, p1)→ 抛内存上限错误 ✅ 用n2**14先跑起来要调大就得同时把maxmem显式调大单位字节。 内存需求约为128 * n * rn每翻一倍内存翻一倍。7. 用random生成重置令牌❌token .join(random.choice(...) for _ in range(32))✅token secrets.token_urlsafe(32)random用的是可预测的伪随机数生成器官方文档明确写了它不应用于安全用途secrets基于os.urandom。8. 时间戳用不带时区的datetime.now()❌datetime.now().isoformat()直接入库 ✅datetime.now(timezone.utc).isoformat(...)存带时区的 UTC 时间。 否则服务器换时区后审计日志的先后顺序会错乱。总结关注点错误做法正确做法口令存储MD5 / SHA1 直接哈希hashlib.scrypt加独立随机盐口令比较比较hmac.compare_digestSQL 查询f-string 拼字符串?参数化占位令牌生成random.choicesecrets.token_urlsafe状态字段字符串True/False整数 0/1时间记录无时区的本地时间带时区的 UTC 时间用户系统的难点不在界面而在「口令怎么存、比较怎么做、SQL 怎么写、时间怎么记」这四件小事上。把密码层和存储层彻底解耦未来无论换哈希算法还是换数据库改动都是局部的。