5.4 用户认证与授权


5.4 用户认证与授权

本节摘要:认证回答"你是谁",授权回答"你能干什么"——两个词混用是新手常见的概念事故。本节给轻记账装上完整门禁:注册(密码哈希存储)、登录(会话维持登录态)、登出,再把"每条账目属于它的主人"落实到所有接口。HTTP 无状态的特性决定了这套机制的存在形式,4.1 的伏笔在此回收。

动手前先定目标

阅读完本节,你应当能够:

  1. 区分认证与授权,说出两者的先后关系
  2. 解释 HTTP 无状态与会话机制的因果
  3. 说明为什么密码必须哈希存储、哈希与加密的区别
  4. 实现注册、登录、登出三个接口并维持会话
  5. 把资源归属校验织入账目接口,实现数据隔离

从"你是谁"说起

4.1 节埋过一个伏笔:HTTP 无状态,每个请求都是独立的,服务器转头就忘。那登录之后,服务器怎么知道后续请求还是你?答案是会话:登录成功时服务器发给你一张"通行证"(Cookie 里的小段标识),此后每个请求自动带上它,服务器凭标识查到"这是已登录的张三"。认证与授权在此接力:认证确定你是张三,授权检查这条账目是不是张三的——先认人,再查权限,顺序不能颠倒。

图 5-4 登录到操作:一次完整的门禁流程

图 5-4 登录到操作:一次完整的门禁流程

注册:密码从进门那一刻起就不该是明文

先立一条铁律:数据库里永远不存明文密码。万一库被拖走(安全圈的"拖库"假设是底线思维),明文密码等于把所有人的钥匙串白送。哈希与加密的区别要辨清:加密可逆、有钥匙能还原;哈希单向、不可逆——存的只是密码的"指纹",登录时把提交的密码同样哈希一遍比对指纹即可。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。四步全对,门禁才算装好。变式一:给登录接口加失败次数限制(同一用户名五分钟内错五次锁十分钟),想清楚计数存在哪。变式二:思考"记住我"功能的实现思路:会话有效期延长与安全之间的取舍。

易错点清单

  • 明文或弱哈希存密码:单纯 md5 一轮也算弱哈希,盐加高迭代是底线
  • 登录失败提示太具体:"密码错误"等于告诉攻击者账号存在
  • 只在页面层拦、接口层裸奔:API 是真正的数据门,装饰器一个不落
  • 用 id 顺序遍历越权:不校验归属直接按 id 操作,等于门户大开

本节要点回顾

  • 认证在前、授权在后:先认人,再查这条数据是不是他的
  • 会话是无状态 HTTP 的登录态方案:Cookie 带标识,服务端凭标识认人
  • 哈希加盐存储密码:盐随机、迭代高、指纹不可逆
  • 401 与 403 语义分明,装饰器统一拦截,查询条件兜底隔离
  • 错误提示模糊是特性不是 bug:少给攻击者一条线索

门禁装好,v0.8 完整成形。收官之前,去看看另一条路——全家桶框架 Django 如何把这一切打包奉送。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U