本节摘要:认证回答"你是谁",授权回答"你能干什么"——两个词混用是新手常见的概念事故。本节给轻记账装上完整门禁:注册(密码哈希存储)、登录(会话维持登录态)、登出,再把"每条账目属于它的主人"落实到所有接口。HTTP 无状态的特性决定了这套机制的存在形式,4.1 的伏笔在此回收。
阅读完本节,你应当能够:
4.1 节埋过一个伏笔:HTTP 无状态,每个请求都是独立的,服务器转头就忘。那登录之后,服务器怎么知道后续请求还是你?答案是会话:登录成功时服务器发给你一张"通行证"(Cookie 里的小段标识),此后每个请求自动带上它,服务器凭标识查到"这是已登录的张三"。认证与授权在此接力:认证确定你是张三,授权检查这条账目是不是张三的——先认人,再查权限,顺序不能颠倒。

先立一条铁律:数据库里永远不存明文密码。万一库被拖走(安全圈的"拖库"假设是底线思维),明文密码等于把所有人的钥匙串白送。哈希与加密的区别要辨清:加密可逆、有钥匙能还原;哈希单向、不可逆——存的只是密码的"指纹",登录时把提交的密码同样哈希一遍比对指纹即可。Python 标准库就有合适的工具:
import hashlib, os def hash_password(plain): salt = os.urandom(16) # 每人一把独立盐 digest = hashlib.pbkdf2_hmac( "sha256", plain.encode(), salt, 200_000 ) return salt.hex() + ":" + digest.hex() # 盐与指纹一起存 def verify_password(plain, stored): salt_hex, digest_hex = stored.split(":") digest = hashlib.pbkdf2_hmac( "sha256", plain.encode(), bytes.fromhex(salt_hex), 200_000 ) return digest.hex() == digest_hex
加盐的用意:单纯哈希同一密码得到同一指纹,攻击者拿"彩虹表"(预计算好的常见密码指纹库)就能反查;每人生成一把独立盐混进哈希,指纹各不相同,彩虹表当场报废。pbkdf2 的迭代次数故意设高——正常登录慢几十毫秒无感,攻击者批量穷举则成本剧增。
from flask import session, request, jsonify @app.post("/api/register") def register(): data = request.get_json(silent=True) or {} username = str(data.get("username", "")).strip() password = str(data.get("password", "")) if len(username) < 2 or len(password) < 8: return jsonify({"error": "用户名至少2位,密码至少8位"}), 400 with Session(engine) as s: if s.scalars(select(User).where(User.username == username)).first(): return jsonify({"error": "用户名已被占用"}), 400 user = User(username=username, password_hash=hash_password(password)) s.add(user) s.commit() return jsonify({"id": user.id}), 201 @app.post("/api/login") def login(): data = request.get_json(silent=True) or {} with Session(engine) as s: user = s.scalars( select(User).where(User.username == data.get("username", "")) ).first() if user and verify_password(str(data.get("password", "")), user.password_hash): session["user_id"] = user.id # 写入会话,通行证发放 return jsonify({"msg": "登录成功"}) return jsonify({"error": "账号或密码不对"}), 401 @app.post("/api/logout") def logout(): session.pop("user_id", None) # 收回通行证 return jsonify({"msg": "已登出"})
两个反直觉的细节:错误提示统一说"账号或密码不对"而不说具体哪个错——给撞库攻击者减少线索;登录成功才写 session,Flask 的 session 对象本质是"加密签名的 Cookie",服务端不用自己建会话表,小型应用够用。密码长度校验放注册侧,8 位起步是底线。
把 5.3 留下的占位函数变成真的,并让每个账目接口都过两道关:
from functools import wraps def login_required(view): @wraps(view) def wrapper(*args, **kwargs): if "user_id" not in session: return jsonify({"error": "请先登录"}), 401 return view(*args, **kwargs) return wrapper def current_user_id(): return session["user_id"] @app.get("/api/records") @login_required # 认证关:登录了吗 def list_records(): # 授权关:只查你自己的账目 ...
装饰器把"必须登录"统一拦在门口,401 表示"没登录",403 表示"登录了但没权限"——状态码的语义精度又一次派上用场。数据隔离的最后一道闸在查询条件里:所有按 id 取账目的地方都带上 user_id == current_user_id(),别人的账目在你的世界里等于不存在(5.3 的 404 设计正是为这里准备的)。
全流程自测:注册两个用户 A 与 B;A 登录后记三笔账;B 登录,尝试用 A 那笔账的 id 执行 DELETE——期望 404;A 登出后再查列表——期望 401。四步全对,门禁才算装好。变式一:给登录接口加失败次数限制(同一用户名五分钟内错五次锁十分钟),想清楚计数存在哪。变式二:思考"记住我"功能的实现思路:会话有效期延长与安全之间的取舍。
门禁装好,v0.8 完整成形。收官之前,去看看另一条路——全家桶框架 Django 如何把这一切打包奉送。