(十三)把 CSRF 令牌放进每份写入表单
登录 Cookie 由浏览器自动发送,表单还需要证明它确实来自用户打开的页面。我们在会话中保存一个随机 CSRF 令牌,渲染时放进隐藏字段;POST 到达时,把提交值与会话值比较。
# 项目文件:shelf_project/src/shelfweb/security.py
import hashlib
import secrets
from flask import abort, request, session
def digest_token(raw):
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def csrf_token():
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return session["csrf_token"]
def check_csrf():
# 不存在的路由或不支持的方法仍交给框架返回 404/405。
if request.method != "POST" or request.endpoint is None:
return
supplied = request.form.getlist("csrf_token")
expected = session.get("csrf_token")
if (
len(supplied) != 1
or not isinstance(expected, str)
or not secrets.compare_digest(
supplied[0].encode("utf-8"), expected.encode("utf-8")
)
):
abort(400, description="表单验证失败,请刷新页面后重试")
def init_app(app):
app.before_request(check_csrf)
app.jinja_env.globals["csrf_token"] = csrf_tokenapp.before_request(check_csrf) 让已匹配路由的 POST 先执行检查。request.endpoint 在找不到路由或方法不匹配时为空,交回框架原本的 404 或 405。getlist 同时检查令牌有没有重复提交;compare_digest 逐项比较字节,提交中文错误值也能正常得到失败响应。
app.jinja_env.globals 把 csrf_token 函数放进模板可调用的名称中。下面的宏生成隐藏字段,另一宏显示字段错误:
<!-- 项目文件:shelf_project/src/shelfweb/templates/_macros.html -->
<!-- 宏接收参数,生成重复使用的小段表单标记。 -->
{% macro csrf_field() -%}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{%- endmacro %}
{% macro field_error(errors, name) -%}
{% if errors.get(name) %}
<p class="field-error" role="alert">{{ errors[name] }}</p>
{% endif %}
{%- endmacro %}令牌在同一份会话中复用,避免打开第二个页面后使第一个表单立即失效;登录成功切换身份时重新生成。缺少令牌、重复令牌或旧的登录前令牌都会得到 400,当前动作尚未写入。