表单与安全
这一课你将能做到
- 用 Form 和 ModelForm 构建带完整校验的表单,渲染成 HTML 并处理提交
- 掌握 CSRF 防护的原理和实操,理解 SameSite Cookie、CSP、XSS 的关系
- 用 messages 框架给用户操作反馈,构建完整的「提交-校验-反馈」闭环
为什么要学
表单是 Web 应用最危险的入口——所有用户输入都可能携带恶意代码、注入攻击或非法数据。Django 的表单系统把「接收输入 → 校验 → 清洗 → 反馈」做成了一条流水线,配合 CSRF 防护和 XSS 过滤,帮你挡住 90% 的常见攻击。
和 Spring Boot 对比:Spring 用 @Valid + @RequestBody 做 Bean Validation,安全靠 Spring Security 配置。Django 的表单系统更「主动」:自动渲染 HTML、自动校验、自动回填错误。两种思路:Java 是「数据驱动,前端接管 UI」;Django 是「服务端主导,UI 由后端生成」。
先建立图像
用户提交表单
│
▼
POST 数据 → Form(data=request.POST)
│
├─ is_valid() = False
│ └─ 回填错误信息 → 重新渲染表单(带错误提示)
│
└─ is_valid() = True
├─ cleaned_data → 干净、类型转换后的数据
└─ 保存 / 业务处理 → redirect → messages 提示成功
CSRF 防护原理:
服务端生成 token → 塞进 session + 表单隐藏字段
│
用户提交表单 ←──────────┘
│
服务端比对 session 中的 token 和表单提交的 token
├─ 一致:放行
└─ 不一致:403 Forbidden
跟着做
步骤 1:基础 Form 类
blog/forms.py:
from django import forms
from django.core.validators import MinLengthValidator
class ContactForm(forms.Form):
name = forms.CharField(
max_length=50,
label="姓名",
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "请输入姓名"})
)
email = forms.EmailField(
label="邮箱",
widget=forms.EmailInput(attrs={"class": "form-control"})
)
subject = forms.ChoiceField(
choices=[("feedback", "反馈"), ("bug", "Bug 报告"), ("other", "其他")],
label="类型"
)
message = forms.CharField(
widget=forms.Textarea(attrs={"rows": 5, "class": "form-control"}),
validators=[MinLengthValidator(10, "内容至少 10 个字符")],
label="内容"
)
cc_myself = forms.BooleanField(required=False, label="抄送给自己")
# 自定义校验:以 clean_字段名 命名
def clean_message(self):
message = self.cleaned_data["message"]
if "傻逼" in message:
raise forms.ValidationError("内容包含敏感词")
return message
视图:
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ContactForm
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
# cleaned_data 是类型转换后的干净数据
name = form.cleaned_data["name"]
email = form.cleaned_data["email"]
# ... 发送邮件等逻辑 ...
messages.success(request, "提交成功!我们会尽快回复。")
return redirect("blog:contact")
# is_valid() 为 False 时,form 自动携带错误信息
else:
form = ContactForm()
return render(request, "blog/contact.html", {"form": form})
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
{% for field in form %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% if field.help_text %}
<small>{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">提交</button>
</form>
步骤 2:ModelForm——表单直连模型
from django.forms import ModelForm
from .models import Article
class ArticleForm(ModelForm):
class Meta:
model = Article
fields = ["title", "slug", "category", "tags", "content", "status"]
# exclude = ["author", "views"] # 或者用 exclude 排除字段
widgets = {
"content": forms.Textarea(attrs={"rows": 20}),
"tags": forms.CheckboxSelectMultiple(),
}
labels = {
"title": "文章标题",
"content": "正文",
}
help_texts = {
"slug": "URL 友好标识,只能包含字母、数字、连字符",
}
def clean_slug(self):
slug = self.cleaned_data["slug"]
# 编辑时排除自己
qs = Article.objects.filter(slug=slug)
if self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError("该 slug 已被使用")
return slug
from django.views.generic import CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = "blog/article_form.html"
success_url = reverse_lazy("blog:index")
def form_valid(self, form):
form.instance.author = self.request.user # 自动填充作者
return super().form_valid(form)
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
model = Article
form_class = ArticleForm
template_name = "blog/article_form.html"
success_url = reverse_lazy("blog:index")
def get_queryset(self):
# 只允许编辑自己的文章
return super().get_queryset().filter(author=self.request.user)
步骤 3:CSRF 防护深度解析
什么攻击:恶意网站诱导用户浏览器向目标站发请求,浏览器自动带 Cookie,服务端误以为是用户本人操作。
<!-- 模板中必须带 token -->
<form method="post">
{% csrf_token %}
...
</form>
<input type="hidden" name="csrfmiddlewaretoken" value="abc123...">
// 从 cookie 读取
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch("/api/articles/", {
method: "POST",
headers: {
"X-CSRFToken": getCookie("csrftoken"),
"Content-Type": "application/json",
},
body: JSON.stringify({title: "test"})
});
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # 仅用于无状态 API,生产环境用 token 认证代替
def api_endpoint(request):
...
步骤 4:Messages 框架
# settings.py(默认已配置)
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
# 视图里
from django.contrib import messages
messages.success(request, "文章发布成功!")
messages.info(request, "这是一条提示")
messages.warning(request, "请注意格式")
messages.error(request, "操作失败")
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
message.tags 是级别字符串(success/info/warning/error),可以直接当 CSS class 用。
步骤 5:其他安全要点
# settings.py 安全清单
SECURE_BROWSER_XSS_FILTER = True # X-XSS-Protection 头
SECURE_CONTENT_TYPE_NOSNIFF = True # X-Content-Type-Options: nosniff
X_FRAME_OPTIONS = "DENY" # 防点击劫持
CSRF_COOKIE_SECURE = True # 仅 HTTPS 传输 CSRF cookie
SESSION_COOKIE_SECURE = True # 仅 HTTPS 传输 session cookie
SECURE_SSL_REDIRECT = True # 强制跳转 HTTPS
SECURE_HSTS_SECONDS = 31536000 # HSTS 一年
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
讲清楚要点
- Form vs ModelForm:Form 是通用表单,字段手动定义。ModelForm 直接从 Model 生成字段,
save() 一键入库。90% 的 CRUD 场景用 ModelForm。
is_valid() 的副作用:调用后会填充 cleaned_data(干净数据)和 errors(错误信息)。必须先调用 is_valid() 才能访问 cleaned_data。
clean_<fieldname>() 是字段级校验钩子:在该字段的内置校验通过后执行。clean() 是表单级校验,可以跨字段比较(如「确认密码 == 密码」)。
- CSRF 的本质是「双重提交验证」:token 既存在 cookie 又存在表单字段,服务端比对两者。攻击者无法读到目标站的 cookie(同源策略),所以伪造不了。
- Messages 是一次性的:消息存 session,下一次请求读出后自动清除。适合「操作后跳转 + 提示」的场景,不适合常驻通知。
form.save(commit=False):返回未保存的实例,可以先修改字段(如填充 author)再 save()。CreateView 内部就是这么做的。
你可能会懵的地方
-
request.POST 和 request.FILES 为什么要分开传?
表单有文件上传时,HTML 必须用 enctype="multipart/form-data",Django 把普通字段放 request.POST,文件放 request.FILES。ModelForm 用法:ArticleForm(request.POST, request.FILES)。
-
校验错误信息能自定义吗?
能。字段定义时传 error_messages:forms.CharField(error_messages={"required": "必填", "max_length": "太长了"})。
-
CSRF token 会不会过期?
会。默认 token 和 session 绑定,session 过期 token 失效。Ajax 长时间停留的页面,建议每次请求前刷新 token。
-
{% csrf_token %} 和 cookie 里的 csrftoken 是一个吗?
值相同但用途不同。表单里的隐藏字段是 POST body 提交,cookie 是浏览器自动带。Django 比对两者,防止攻击者只伪造 cookie。
-
富文本编辑器怎么防 XSS?
不要用 |safe 直接输出用户 HTML。用 bleach 库白名单过滤:bleach.clean(user_html, tags=["p", "strong", "em"], strip=True)。
错法对照
| 错法 | 问题 | 正解 |
|---|
form = ArticleForm(request.POST) 后直接 form.save() | 没校验,脏数据入库 | 先 if form.is_valid(): |
文件上传忘记 request.FILES | 文件丢失 | ArticleForm(request.POST, request.FILES) |
API 视图全局 @csrf_exempt | 任意站点可调用 | 用 Token 认证(DRF 的 TokenAuthentication) |
| 用户输入直接 ` | safe` 输出 | XSS 漏洞 |
cleaned_data 当 dict 用 cleaned_data["field"] | KeyError | cleaned_data.get("field") 或确认 is_valid() 通过 |
| ModelForm 暴露所有字段 | 用户能改 author、views | 用 fields 白名单,不要 exclude |
卡点急救
- 表单提交后页面空白 / 永远走不到 is_valid:检查模板是否有
{% csrf_token %},表单 method 是否是 post。
- 文件上传后找不到文件:检查
MEDIA_ROOT 配置,settings.py 里的 MEDIA_URL,以及 urls.py 是否 serve 了 media(开发环境)。
This field is required 即使填了值:HTML 表单的 name 属性必须和 Form 字段名一致。检查浏览器开发者工具的 Network 里实际提交了什么。
面试常问
-
CSRF 攻击的原理是什么?Django 如何防护?
攻击者诱导用户浏览器向目标站发请求,浏览器自动携带 Cookie,服务端无法区分是用户主动操作还是被诱导。Django 用「同步器令牌模式」:服务端生成随机 token 同时写入 cookie 和表单,提交时比对两者。攻击者无法读到目标站的 cookie,所以伪造不了 token。
-
Form 和 ModelForm 的区别?什么时候用哪个?
Form 是通用表单(如登录、搜索、联系表单),字段手动定义。ModelForm 绑定到 Model,自动生成字段,save() 直接入库。CRUD 场景用 ModelForm,非数据库场景用 Form。
-
Django 的 XSS 防护机制?
模板默认对 {{ }} 输出做 HTML 转义。{% autoescape off %} 和 |safe 会关闭转义,慎用。富文本用户输入用 bleach 库做白名单过滤。响应头加 X-XSS-Protection、Content-Security-Policy。
自测三问
- 为什么 ModelForm 的
save(commit=False) 常用?给一个具体场景。
- CSRF token 在表单和 cookie 中各起什么作用?为什么必须两处都有?
- 如何只允许作者本人编辑自己的文章?写出 ModelForm + 视图的关键代码。
小结一句话
Django 表单系统把「接收 → 校验 → 清洗 → 反馈」做成流水线,配合 CSRF、XSS 防护和 messages 框架,让用户输入处理既安全又优雅。
延伸