Flask-WTF 插件:表单类定义与 CSRF 防护

📂 所属阶段:第二阶段 — 交互与数据(核心篇)
🔗 相关章节:数据验证 · Jinja2 模板引擎(下)


1. 为什么需要 Flask-WTF?

1.1 原始表单的问题

# ❌ 原始表单:手动处理,数据脏乱
@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        name = request.form.get("name")
        email = request.form.get("email")
        # 没有验证、没有类型转换、容易出错

1.2 Flask-WTF 的优势

✅ Flask-WTF 解决方案:
1. 类定义表单:结构清晰、可复用
2. 自动 CSRF 防护:防止跨站请求伪造
3. 字段验证器:内置 + 自定义验证规则
4. 表单渲染:Jinja2 自动集成
5. 错误处理:每个字段独立的错误信息

2. 安装与配置

2.1 安装

pip install flask-wtf email-validator

2.2 配置

# app/__init__.py
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect()

def create_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "your-secret-key"  # 必须设置!用于 CSRF 签名
    csrf.init_app(app)

3. 定义表单

3.1 用户注册表单

# app/forms/auth.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.models import User

class RegisterForm(FlaskForm):
    name = StringField("用户名", validators=[
        DataRequired(message="用户名不能为空"),
        Length(min=2, max=20, message="用户名长度需在 2-20 个字符之间"),
    ])
    email = StringField("邮箱", validators=[
        DataRequired(message="邮箱不能为空"),
        Email(message="请输入有效的邮箱地址"),
    ])
    password = PasswordField("密码", validators=[
        DataRequired(message="密码不能为空"),
        Length(min=8, message="密码至少 8 个字符"),
    ])
    password_confirm = PasswordField("确认密码", validators=[
        DataRequired(message="请再次输入密码"),
        EqualTo("password", message="两次输入的密码不一致"),
    ])
    agree_terms = BooleanField("我已阅读并同意《用户协议》", validators=[
        DataRequired(message="请勾选用户协议"),
    ])
    submit = SubmitField("注册")

    # 自定义验证:检查邮箱是否已被注册
    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first():
            raise ValidationError("该邮箱已被注册")

3.2 登录表单

class LoginForm(FlaskForm):
    email = StringField("邮箱", validators=[
        DataRequired(message="邮箱不能为空"),
        Email(message="请输入有效的邮箱地址"),
    ])
    password = PasswordField("密码", validators=[
        DataRequired(message="密码不能为空"),
    ])
    remember_me = BooleanField("记住我")
    submit = SubmitField("登录")

3.3 文章表单

class ArticleForm(FlaskForm):
    title = StringField("标题", validators=[
        DataRequired(message="标题不能为空"),
        Length(max=200, message="标题最多 200 个字符"),
    ])
    summary = StringField("摘要", validators=[
        Length(max=300, message="摘要最多 300 个字符"),
    ])
    content = TextAreaField("正文", validators=[
        DataRequired(message="正文不能为空"),
        Length(min=10, message="正文至少 10 个字符"),
    ])
    category = SelectField("分类", coerce=int, validators=[
        DataRequired(message="请选择分类"),
    ])
    tags = StringField("标签", description="用逗号分隔多个标签")
    is_published = BooleanField("立即发布")
    submit = SubmitField("保存草稿")

    # 动态设置 category 选项
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        from app.models import Category
        self.category.choices = [(c.id, c.name) for c in Category.query.all()]

4. 在路由中使用表单

4.1 基础用法

# app/routes/auth.py
from flask import render_template, redirect, url_for, flash
from flask_login import login_user, logout_user
from app.forms.auth import LoginForm, RegisterForm
from app.models import User
from app.extensions import db

@auth_bp.route("/register", methods=["GET", "POST"])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        # 表单数据全部验证通过
        user = User(
            name=form.name.data,
            email=form.email.data,
            password_hash=generate_password_hash(form.password.data),  # 后续章节
        )
        db.session.add(user)
        db.session.commit()
        flash("注册成功!请登录。", "success")
        return redirect(url_for("auth.login"))
    return render_template("auth/register.html", form=form)

@auth_bp.route("/login", methods=["GET", "POST"])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user and check_password_hash(user.password_hash, form.password.data):
            login_user(user, remember=form.remember_me.data)
            flash("登录成功!", "success")
            next_page = request.args.get("next")
            return redirect(next_page or url_for("main.index"))
        else:
            flash("邮箱或密码错误", "danger")
    return render_template("auth/login.html", form=form)

4.2 validate_on_submit vs validate

# validate_on_submit() = (request.method == "POST") and form.validate()
# 推荐用法:GET 请求时返回 False,不做验证

# validate() 单独使用:每次请求都验证(调试用)

5. 在模板中渲染表单

5.1 自动渲染

<!-- templates/auth/register.html -->
{% extends "base.html" %}

{% block content %}
<div class="auth-form">
    <h2>注册</h2>

    <!-- 显示表单的非字段错误 -->
    {% if form.errors %}
        <div class="alert alert-danger">
            {% for errors in form.errors.values() %}
                {% for error in errors %}
                    <p>{{ error }}</p>
                {% endfor %}
            {% endfor %}
        </div>
    {% endif %}

    <form method="POST" action="{{ url_for('auth.register') }}">
        <!-- CSRF Token(必须!) -->
        {{ form.hidden_tag() }}

        <!-- 字段渲染 -->
        <div class="form-group">
            {{ form.name.label }}
            {{ form.name(class="form-control") }}
            {% if form.name.errors %}
                <small class="text-danger">{{ form.name.errors[0] }}</small>
            {% endif %}
        </div>

        <div class="form-group">
            {{ form.email.label }}
            {{ form.email(class="form-control", placeholder="your@email.com") }}
            {% if form.email.errors %}
                <small class="text-danger">{{ form.email.errors[0] }}</small>
            {% endif %}
        </div>

        <div class="form-group">
            {{ form.password.label }}
            {{ form.password(class="form-control") }}
            {% if form.password.errors %}
                <small class="text-danger">{{ form.password.errors[0] }}</small>
            {% endif %}
        </div>

        <div class="form-group">
            {{ form.password_confirm.label }}
            {{ form.password_confirm(class="form-control") }}
            {% if form.password_confirm.errors %}
                <small class="text-danger">{{ form.password_confirm.errors[0] }}</small>
            {% endif %}
        </div>

        <div class="form-group">
            {{ form.agree_terms() }}
            {{ form.agree_terms.label }}
        </div>

        {{ form.submit(class="btn btn-primary btn-block") }}
    </form>
</div>
{% endblock %}

5.2 使用宏自动渲染

<!-- templates/macros/form_macros.html -->
{% macro render_field(field) %}
    <div class="form-group{% if field.errors %} has-error{% endif %}">
        {{ field.label }}
        {% if field.type == "BooleanField" %}
            {{ field() }} {{ field.label }}
        {% else %}
            {{ field(class="form-control", **kwargs)|safe }}
        {% endif %}
        {% if field.description %}
            <small class="form-text text-muted">{{ field.description }}</small>
        {% endif %}
        {% if field.errors %}
            <small class="text-danger">{{ field.errors[0] }}</small>
        {% endif %}
    </div>
{% endmacro %}

<!-- 使用宏 -->
{% from "macros/form_macros.html" import render_field %}
<form method="POST">
    {{ form.hidden_tag() }}
    {{ render_field(form.name) }}
    {{ render_field(form.email) }}
    {{ render_field(form.password) }}
    {{ form.submit(class="btn btn-primary") }}
</form>

6. CSRF 防护详解

6.1 CSRF 攻击原理

攻击者网站:
<form action="https://your-blog.com/delete/42" method="POST">
    <input name="_method" value="DELETE">
</form>
<script>document.forms[0].submit();</script>

用户已登录 → 访问攻击者网站 → 自动提交删除表单 → 文章 42 被删除!

6.2 Flask-WTF 如何防护

CSRF Token 流程:
1. 服务器生成随机 Token,存入 Session
2. 渲染表单时通过 {{ form.hidden_tag() }} 注入 Token
3. 用户提交表单时,Token 必须匹配,否则 400 错误
4. 攻击者网站无法获取目标站点的 Cookie,无法伪造 Token

6.3 全局禁用 CSRF(API 场景)

# 仅对特定端点禁用
@csrf.exempt
@app.route("/api/webhook", methods=["POST"])
def webhook():
    # Webhook 来自第三方,无法提供 CSRF token
    pass

7. 小结

Flask-WTF 使用流程:

1. 安装:pip install flask-wtf email-validator
2. 配置:app.config["SECRET_KEY"] = "..."
3. 定义表单:class XxxForm(FlaskForm)
4. 路由使用:form = XxxForm(); if form.validate_on_submit(): ...
5. 模板渲染:{{ form.hidden_tag() }} + {{ form.field() }}

💡 最佳实践:所有涉及数据修改的表单(POST/PUT/DELETE)必须使用 Flask-WTF 并开启 CSRF 防护。只读页面可以用普通表单。


🔗 扩展阅读