(十二)把写入请求与当前页面会话对应
1. Cookie 会自动携带,写入意图还要核对
浏览器可能替请求带上已有 Cookie,服务器因此知道账号,却还不知道这次写入是不是由当前书架页面发起。跨站请求伪造(CSRF)正是利用浏览器的这类自动携带行为,诱导用户对已登录网站发出操作。
本章让每份会话再保存一份随机令牌。书架页面把它放入隐藏字段,页面脚本把它放入自定义请求头;写入前先核对允许的 Origin,再核对令牌是否与会话一致。
# 续写同一文件:web_project/src/webbook/security.py
# 核对请求来源,并将提交的 CSRF 令牌与当前会话中的令牌比较。
def require_csrf(request, session, token, allowed_origins):
origin = request.headers.get("origin")
if origin is not None and origin not in allowed_origins:
raise Problem(403, "请求来源没有获得写入许可")
if not isinstance(token, str) or not secrets.compare_digest(
token.encode("utf-8"), session.csrf.encode("ascii")
):
raise Problem(403, "表单令牌已失效,请刷新页面后重试")compare_digest 用来比较令牌内容。这里先编码为字节,再比较本次输入与保存值。令牌缺失或已经换成另一份会话,就返回 403,让读者刷新后再操作。CSRF 的请求过程
2. 表单与 JSON 带回同一类证明
新增表单先把文本字段转换成业务类型:
# 续写同一文件:web_project/src/webbook/validation.py
# 把表单中的 0、1 和逗号分隔标签转换成业务对象,再复用同一套书籍校验。
def form_book(values):
flag = one_value(values, "finished", "0")
if flag not in {"0", "1"}:
raise Problem(422, "表单已读状态应为0或1")
tags = [tag.strip() for tag in one_value(values, "tags").split(",") if tag.strip()]
return validate_book(
{"title": one_value(values, "title"), "finished": flag == "1", "tags": tags}
)复选框明确提交 1,缺少该字段时按 0 处理;标签文本按逗号分开后交给共同校验。额外的 csrf_token 用于本次请求核对,不是书籍字段,也不写入 books 表。
<!-- 项目文件:web_project/src/webbook/templates/book_form.html -->
<!-- name决定请求字段名;未勾选的复选框不提交,服务器再转换为False。 -->
<form id="book-form" method="post" action="/books">
$csrf
<label>书名<input name="title" maxlength="100" value="$title" required placeholder="例如:Python Web 实践"></label>
<label>标签<input name="tags" value="$tags" placeholder="例如:Python,网络"></label>
<label class="check"><input type="checkbox" name="finished" value="1" $checked>已经读完</label>
<button type="submit">保存书籍</button>
</form>$csrf 是服务器稍后填入的隐藏字段片段,title、tags、checked 也是模板占位符。JSON 请求使用 X-CSRF-Token 头,书籍本身仍保持 title、finished、tags 三个业务字段。