数据验证:筑牢 Flask 用户交互的安全防线

📂 所属阶段:第二阶段 — 交互与数据(核心篇)
🔗 相关章节:Flask-WTF 插件 · 密码安全加密

用户提交的数据,是 Web 应用从外界接收的第一手材料。空指针崩溃、XSS 注入、SQL 注入前置漏洞、业务逻辑错误,往往都因为缺少一道严格的数据验证。与其后期排查,不如在入口就把规则立好。

今天我们就用 Flask-WTF 这个拓展,看看如何通过内置验证器快速搭建通用规则,再通过自定义验证器覆盖业务专属需求,最后配合 HTML5 原生和 AJAX 让验证体验更友好。


1. WTForms 验证器核心入门

WTForms 的验证逻辑都写在每个字段的 validators 列表里,按顺序执行。一旦某个验证器抛出 ValidationError,后续验证器就不会再跑,错误信息会被加到对应字段的 errors 属性上。

1.1 高频内置验证器速查表

验证器适用场景核心差异点
DataRequired必填文本、密码、下拉选会忽略仅含空格的字符串(比 InputRequired 更贴近实际需求)
Email邮箱格式验证依赖 email-validator 库(Flask-WTF 会自动安装)
Length(min, max)文本、URL 别名、简介的长度限制可以只设 min 或只设 max
NumberRange(min,max)数字输入(配合 IntegerField / DecimalField支持整数和小数范围
EqualTo(fieldname)确认密码、确认邮箱场景字段名区分大小写
Regexp(pattern)手机号、用户名、身份证号等固定格式文本支持 re 库的修饰符(可选参数)
Optional可选字段,允许为空时跳过后续验证一定要放在所有验证器的最前面

2. 常用内置验证器组合实战

2.1 基础表单(通用内容管理)

下面是一个文章投稿表单的示例,涵盖了文本、URL 别名、年龄和博客链接的验证组合。

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, IntegerField, TextAreaField
from wtforms.validators import (
    DataRequired, Email, Length, EqualTo,
    Regexp, URL, NumberRange, Optional
)


class ArticleSubmissionForm(FlaskForm):
    # 标题:必填、5-100 字符、仅允许中文/英文/数字/下划线/空格
    title = StringField(
        "投稿标题",
        validators=[
            DataRequired(message="标题可不能空着哦😯"),
            Length(
                min=5, max=100,
                message="标题太长太短都不行,请控制在 5-100 个字符之间"
            ),
            Regexp(
                r"^[\u4e00-\u9fa5a-zA-Z0-9_ ]+$",
                message="标题只能包含中文、英文字母、数字、下划线和空格"
            ),
        ],
    )

    # URL 别名:必填、5-50 字符、仅允许小写字母/数字/连字符(SEO 友好)
    url_slug = StringField(
        "URL 别名(用于生成链接)",
        validators=[
            DataRequired(),
            Length(max=50, message="URL 别名太长啦,不利于分享"),
            Regexp(
                r"^[a-z0-9-]+$",
                message="只能用小写字母、数字和连字符哦"
            ),
        ],
    )

    # 作者年龄:可选、18-100 岁
    author_age = IntegerField(
        "您的年龄(选填)",
        validators=[
            Optional(),
            NumberRange(
                min=18, max=100,
                message="如果填写年龄,请确保是 18-100 岁之间的有效数字"
            ),
        ],
    )

    # 个人博客:可选、仅允许 http/https 开头
    personal_blog = StringField(
        "个人博客链接(选填)",
        validators=[
            Optional(),
            URL(message="请输入有效的博客链接,记得加上 http:// 或 https:// 前缀"),
        ],
    )

2.2 正则表达式的专属场景(固定格式验证)

内置验证器搞不定的规则,就用 Regexp 上场:

# 中国手机号验证
phone = StringField(
    "手机号",
    validators=[
        Regexp(
            r"^1[3-9]\d{9}$",
            message="请输入 11 位有效的中国手机号"
        ),
    ],
)

# 合规用户名(字母开头、字母数字下划线、3-20 位)
username = StringField(
    "用户名",
    validators=[
        Regexp(
            r"^[a-zA-Z]\w{2,19}$",
            message="用户名需以字母开头,3-20 个字符,仅允许字母、数字和下划线"
        ),
    ],
)

3. 自定义验证器:覆盖业务专属需求

当内置验证器和正则都不够用(比如需要查数据库、检查敏感词),就该上自定义验证器了。

3.1 字段级验证(表单内部定义)

适合只给当前表单用的逻辑,方法名必须遵守 validate_字段名 的命名规则:

# 假设已定义 User 模型和 db 实例
# from app.models import User
# from app import db

class SignUpForm(FlaskForm):
    username = StringField("用户名", validators=[
        DataRequired(), Length(3, 20),
    ])
    bio = TextAreaField("个人简介", validators=[
        Length(max=200, message="简介最多 200 个字符"),
    ])

    # 同时检查敏感词和唯一性
    def validate_username(self, field):
        sensitive_words = ["admin", "root", "system", "test"]
        if any(word in field.data.lower() for word in sensitive_words):
            raise ValidationError("该用户名太敏感啦,换一个试试😉")

        if User.query.filter_by(username=field.data).first():
            raise ValidationError("这个用户名已经被别人注册了哦")

    # 简介里禁止出现外部链接
    def validate_bio(self, field):
        if field.data and ("http://" in field.data.lower() or "https://" in field.data.lower()):
            raise ValidationError("为了社区安全,简介中暂时禁止添加链接")

3.2 独立自定义验证器(通用复用)

适合多个表单都会用到的逻辑,单独放到 app/validators.py 里:

# app/validators.py
import re
from wtforms.validators import ValidationError


# 通用敏感词检查
def no_common_sensitive_words(form, field):
    """检查文本是否包含通用敏感词(示例列表)"""
    sensitive_words = ["诈骗", "赌博", "色情"]
    if any(word in field.data for word in sensitive_words):
        raise ValidationError("文本中包含违规内容,请修改后再提交")


# 通用外部链接禁止
def no_external_links(form, field):
    """检查文本是否包含以 http/https 开头的外部链接"""
    url_pattern = r"https?://[^\s]+"
    if re.search(url_pattern, field.data, re.IGNORECASE):
        raise ValidationError("文本中暂时禁止添加外部链接")

然后在任意表单中直接导入使用:

from app.validators import no_common_sensitive_words, no_external_links

class CommentForm(FlaskForm):
    content = TextAreaField("评论内容", validators=[
        DataRequired(), Length(1, 500),
        no_common_sensitive_words, no_external_links,
    ])

4. 优化验证体验:客户端辅助验证

⚠️ 核心安全准则:客户端验证只是提升体验的“甜点”,真正保护数据安全的是服务器端验证(WTForms)。攻击者可以轻易禁用 JavaScript 或发送伪造请求,所以后端防线绝对不能少。

4.1 HTML5 原生验证(零代码基础体验)

可以配合 WTForms 生成的字段,或者在 Jinja2 模板中手动添加 HTML5 属性:

<form method="POST" action="{{ url_for('auth.signup') }}">
    {{ form.hidden_tag() }} <!-- Flask-WTF 必须:CSRF 保护 -->

    <div>
        {{ form.email.label }}
        <!-- 手动添加 HTML5 原生属性 -->
        <input
            type="email"
            name="{{ form.email.name }}"
            id="{{ form.email.id }}"
            required
            minlength="5"
            maxlength="100"
            placeholder="your@email.com"
            {% if form.email.data %}value="{{ form.email.data }}"{% endif %}
        >
        {% if form.email.errors %}
            <span style="color: red;">{{ form.email.errors[0] }}</span>
        {% endif %}
    </div>

    <button type="submit">注册</button>
</form>

4.2 AJAX 实时验证(进阶体验)

让用户在输入过程中就看到反馈,减少提交后再报错的挫败感。

后端 API 路由:

# app/routes/api.py
from flask import Blueprint, jsonify
from app.models import User

api_bp = Blueprint("api", __name__, url_prefix="/api")


@api_bp.route("/check/username/<username>")
def check_username(username):
    exists = User.query.filter_by(username=username).first() is not None
    return jsonify({"available": not exists, "username": username})


@api_bp.route("/check/email/<email>")
def check_email(email):
    import re
    if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
        return jsonify({"valid": False, "available": False})
    exists = User.query.filter_by(email=email).first() is not None
    return jsonify({"valid": True, "available": not exists})

前端 JavaScript(实时校验用户名):

document.getElementById("username").addEventListener("blur", async function() {
    const username = this.value.trim();
    if (!username) return; // 为空时不发送请求

    const response = await fetch(`/api/check/username/${username}`);
    const data = await response.json();
    const errorEl = document.getElementById("username-error");

    if (!data.available) {
        errorEl.textContent = "这个用户名已经被注册啦";
        errorEl.style.color = "red";
        errorEl.style.display = "block";
    } else {
        errorEl.textContent = "这个用户名可以用哦🎉";
        errorEl.style.color = "green";
        errorEl.style.display = "block";
    }
});

5. 全文总结

  1. 验证器运行规则

    • 按列表顺序执行,任何一个验证失败就停止。
    • Optional() 要放在验证器列表的最前面,否则空值也会触发后续验证。
    • 使用 Email 验证器前确认 email-validator 库已安装。
  2. 典型场景的组合套路

    • 文本类DataRequired + Length + 可选 Regexp
    • 数字类DataRequired / Optional + NumberRange
    • 确认类EqualTo 配合对应字段自己的规则。
    • URL / 邮箱类Optional / DataRequired + URL / Email
  3. 安全第一原则

    • 服务器端验证(WTForms)是底线,必须写且不能省略
    • 客户端验证(HTML5 / AJAX)只是锦上添花,绝不能替代后端校验。
    • 注册、登录、支付等敏感操作,务必加入数据库唯一性检查和额外业务规则。

🔗 扩展阅读