Python 现代开发学习笔记

(十三)把 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_token

app.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,当前动作尚未写入。

GET 渲染时生成并签名保存表单令牌,隐藏字段随 POST 提交,框架先恢复会话再比较两份令牌,失败在业务写入之前结束

笔记正文与全部题解 · Ctrl / ⌘ K