第一章我们手写校验:request.form.get('username')、if len < 3: return error……字段一多(注册页 8 个字段、每种规则 5 行)——校验逻辑散落在视图里、模板无法复用错误提示、还容易漏掉 CSRF。
直觉类比:手写校验像"每次进小区人工登记",表单类像"门禁系统"——规则一次配置,来人自动核验,违规自动拦下。
💡 关键直觉:表单 = 数据的"声明式验证规则"。字段有哪些、必须多长、什么格式——全部集中在一个表单类里声明,视图只需"校验通过则处理,失败则重新渲染"。

pip install flask-wtf
from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, SubmitField from wtforms.validators import DataRequired, Length, Email class RegisterForm(FlaskForm): username = StringField('用户名', validators=[ DataRequired(message='用户名必填'), Length(min=3, max=20, message='长度 3~20') ]) email = StringField('邮箱', validators=[ DataRequired(), Email(message='邮箱格式不正确') ]) password = PasswordField('密码', validators=[ DataRequired(), Length(min=8, message='密码至少 8 位') ]) submit = SubmitField('注册')
| 校验器 | 作用 | 示例 |
|---|---|---|
| DataRequired | 必填 | DataRequired(message='必填') |
| Length | 长度范围 | Length(min=3, max=20) |
| 邮箱格式 | Email() |
|
| URL | URL 格式 | URL() |
| NumberRange | 数值范围 | NumberRange(min=1, max=100) |
| Regexp | 正则匹配 | Regexp(r'^[a-z0-9]+$') |
| EqualTo | 与另一字段相等 | EqualTo('password', message='两次密码不一致') |
| Optional | 可空 | Optional() |
from flask import render_template, flash, redirect, url_for @app.route('/register', methods=['GET', 'POST']) def register(): form = RegisterForm() if form.validate_on_submit(): # POST + 所有校验通过 # 取出已校验的数据 username = form.username.data email = form.email.data # ... 创建用户、发邮件等 flash('注册成功!') return redirect(url_for('login')) # GET 或校验失败:重新渲染,模板显示错误 return render_template('register.html', form=form)
validate_on_submit() 是核心:POST 请求且校验通过返回 True,否则 False。失败时 form.username.errors 自动收集所有错误信息。
CSRF(跨站请求伪造)攻击:用户登录银行后,黑客诱导用户访问恶意页面,页面偷偷向银行发"转账" POST 请求——浏览器自动带上用户 Cookie,银行以为是本人操作。
Flask-WTF 的防护:渲染表单时生成一次性 csrf_token 隐藏字段(存在 session 中),提交时校验匹配。攻击者的页面拿不到这个 token,请求被拒。
# 全局开启(推荐,所有表单自动带 CSRF 保护) from flask_wtf import CSRFProtect csrf = CSRFProtect() csrf.init_app(app) # 需要 SECRET_KEY 支持 app.config['SECRET_KEY'] = '...'
模板中必须在表单里带上 token:
<form method="post"> {{ form.hidden_tag() }} <!-- 自动渲染 csrf_token 隐藏字段 --> ... </form>
⚠️ 若测试时关闭 CSRF(如上一章 TestingConfig 里
WTF_CSRF_ENABLED = False),只允许在测试配置中关闭,生产必须开启。
from wtforms.validators import ValidationError class RegisterForm(FlaskForm): username = StringField('用户名', validators=[DataRequired()]) def validate_username(self, field): """用户名不能叫 admin(示例)""" if field.data.lower() == 'admin': raise ValidationError('该用户名不可用')
命名规则:validate_<字段名> 方法,抛 ValidationError 即校验失败。
也可以做跨库唯一性校验:
def validate_email(self, field): from .models import User if User.query.filter_by(email=field.data).first(): raise ValidationError('该邮箱已被注册')
<form method="post" novalidate> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }} {% for err in form.username.errors %} <span class="error">{{ err }}</span> {% endfor %} </p> <p>{{ form.password.label }}<br>{{ form.password(size=32) }}</p> {{ form.submit() }} </form>
from wtforms import FileField class UploadForm(FlaskForm): file = FileField('选择文件', validators=[DataRequired()])
@app.route('/upload', methods=['GET', 'POST']) def upload(): form = UploadForm() if form.validate_on_submit(): f = form.file.data f.save(f'uploads/{f.filename}') # 注意生产需校验文件名/大小 return '上传成功' return render_template('upload.html', form=form)
| 误区 | 现象 | 正解 |
|---|---|---|
| 忘了 hidden_tag | CSRF 校验 400 | 模板里渲染 form.hidden_tag() |
| 只在 POST 里建表单 | GET 渲染时表单不存在 | 同一视图 GET/POST 共用 |
| 手写 if 替代校验器 | 代码重复、难维护 | 用声明式 validators |
| 生产关 CSRF | 安全漏洞 | 仅测试配置关闭 |
| 错误信息英文 | 用户体验差 | message 参数写中文 |
| 表单里放敏感校验 | 客户端可绕过 | 服务端校验为准 |
# forms.py from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, SubmitField from wtforms.validators import DataRequired, Length, Email, EqualTo class RegisterForm(FlaskForm): username = StringField('用户名', validators=[ DataRequired(), Length(min=3, max=20)]) email = StringField('邮箱', validators=[DataRequired(), Email()]) password = PasswordField('密码', validators=[ DataRequired(), Length(min=8)]) confirm = PasswordField('确认密码', validators=[ DataRequired(), EqualTo('password', message='两次密码不一致')]) submit = SubmitField('注册')
# views.py @app.route('/register', methods=['GET', 'POST']) def register(): form = RegisterForm() if form.validate_on_submit(): # 实际项目里密码要哈希后入库(第四章讲) flash(f'欢迎 {form.username.data}!请登录') return redirect(url_for('login')) return render_template('register.html', form=form)
你只需要写两处:表单类(规则声明)+ 视图(通过则处理)。校验、错误收集、CSRF 全部交给框架——这就是"声明式"的力量。
validate_<字段> 方法抛 ValidationError,支持查库唯一性。WTF 的价值不止于"少写代码",更在于它强制了"声明式"的思维。
第一,声明式 vs 命令式。 手写校验是"命令式":每一步怎么取值、怎么判断、怎么报错都要你写。WTF 是"声明式":字段有什么规则,写在表单类里,框架负责执行。声明式的优势是"规则即文档"——打开表单类,一眼看清这个表单收什么、怎么校验,比翻视图函数找校验逻辑高效得多。这也是为什么表单类放在单独文件(forms.py)里更清晰。
第二,validate_on_submit 的完整行为。 它做三件事:判断请求方法是否为 POST(GET 直接返回 False)、校验 CSRF token(不匹配直接 400)、运行所有字段校验器并收集错误。理解这个顺序很重要:CSRF 失败连校验都不会进行——安全优先。所以测试时关 CSRF(测试配置)才能测到校验逻辑。
第三,字段类型决定渲染与校验。 StringField 渲染成 text input,PasswordField 渲染成 password input,BooleanField 渲染成 checkbox,SelectField 渲染成下拉——字段类型同时决定了"HTML 形态"与"数据校验"。自定义渲染用 widget 参数(如 TextArea 用 TextAreaField),自定义校验用 validators 或 validate_<字段> 方法。
第四,表单与模型的双向转换。 实际项目中表单经常和 ORM 模型打交道:编辑用户时把模型数据填进表单(form = EditForm(obj=user)),提交后把表单数据写回模型(form.populate_obj(user))。WTF 支持这种"模型绑定"模式,让"查出来→展示→改→存回"的流程一气呵成。
第五,CSRF 的适用范围。 不只表单,所有修改类请求都应该有 CSRF 防护。AJAX 提交 JSON 的场景,前端需要从服务端获取 token(通常在 cookie 或页面 meta 里)并放到请求头。Flask-WTF 提供 CSRFProtect 全局保护:开启后,未带 token 的 POST/PUT/DELETE 都会被拒。规则很简单:凡是会改数据的请求,都要带 token。
第六,测试表单的正确姿势。 测试表单时(第二章 2.6):配置 WTF_CSRF_ENABLED=False,然后直接构造表单对象调用 validate 方法(不经过 HTTP),或通过 test_client POST 数据断言错误信息。表单校验是业务规则的载体,必须写测试——用户名长度、邮箱格式、密码强度,这些规则被改坏一个都是线上事故。