🧪 第6章 建立注册与登录页面
📄 一句话概括

注册页只加一个「确认密码」,端点里一行都不用动。 模板改成遍历字段渲染,之后加字段页面自动跟着长。 少带 CSRF 令牌时,页面一条错误都不显示,数据也没写进去——这是最难查的一种。

📝 本章和前面几章的关系

这一篇对应教材 6.1 与 6.3:修改注册页面的端点、修改注册页面的模板。 第 3 章的注册功能只是「能用」,这一篇把它改成「像样」。

一、加一个字段,端点要不要改

confirm = PasswordField("确认密码",
    validators=[DataRequired(),
                EqualTo("password", "两次输入不一致")])

EqualTo 的第一个参数是「跟哪个字段比」,第二个是比不过时的提示。 端点里一行都没加。校验规则写进表单类,端点只看 validate_on_submit 的结论。 这是 WTForms 最省事的地方:加字段、加规则,端点不用动。 要是自己写 if 判断,每加一条规则就要改一次端点。

教材 6.1 节改端点时动的其实是模板那一侧——因为原来写死了几个字段,多一个就要多写一段。

二、模板改成遍历字段

<form method="post">
{{ form.hidden_tag() }}
{% for f in form if f.type != 'CSRFTokenField' %}
<p>{{ f.label }} {{ f() }}
  {% for e in f.errors %}<b>{{ e }}</b>{% endfor %}</p>
{% endfor %}
</form>

三行替代了原来一长串手写的字段段落。 实测注册页渲染出 6 个 input:csrf_token、name、email、password、confirm、submit。 登录页同样一张模板,渲出 4 个。 所以这两页可以共用一张模板,差别只在传进去的 title。这也是「加字段不用改模板」的原因。

那个 if 是为了跳过 CSRF 字段——它由 hidden_tag 单独渲染,再在循环里渲一次就重复了。

三、错误显示在字段后面

实测空表单提交,页面上出现四条 This field is required.,每个字段后面一条。 两次密码填不一样时,出现的是「两次输入不一致」——这句是中文,因为它来自 EqualTo 的第二个参数。 其余那些英文都是 WTForms 内置的。想统一成中文,得给每条内置提示配翻译,或者自己写 messages。

教材没展开这一点,但实际项目第一天就会碰到。

四、少带令牌会怎样

实测不带 csrf_token 直接 POST,返回码是 200,不是 400。 但库里行数没变——0 还是 0。请求被拒了,只是拒得不声不响。 原因是那个错误挂在 csrf_token 这个字段上,而模板只渲染有名字的字段,谁也没把这条错误显示出来。

要让缺令牌直接变成 400,得装 CSRFProtect(app)。实测装上之后同一个请求返回 400。 差别在拦的位置:CSRFProtect 在进入视图之前就拦了; FlaskForm 的字段级校验要等视图跑起来、表单开始校验。 实际项目里装的人更多,因为「静默失败」比「明着报错」难查得多。

五、测试客户端怎么过 CSRF

html = client.get("/auth/register").get_data(as_text=True)
tok = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html)
client.post("/auth/register",
            data={"csrf_token": tok.group(1), "name": "李四"})

浏览器不会做这件事——它是自动的:hidden_tag 渲出一个隐藏 input,提交时跟着表单一起发走。 测试客户端不解析 HTML,所以得自己从页面里把令牌抠出来,再当参数传回去。 实测带上令牌、两次密码一致,返回 302,库里多一行。

六、动手 Demo:注册页改版:多一个确认密码

这个 Demo 在注册页上加一个确认密码字段,然后把它会走的每条分支都试一遍。 第五节是个陷阱:不带令牌时返回 200,看起来一切正常,其实什么也没写进去。

class RegisterForm(FlaskForm):
    name = StringField("姓名", validators=[DataRequired(), Length(1, 20)])
    email = StringField("邮箱", validators=[DataRequired(), Email()])
    password = PasswordField("密码", validators=[DataRequired(),
                                               Length(8, 60)])
    confirm = PasswordField("确认密码",
                            validators=[DataRequired(),
                                        EqualTo("password",
                                                "两次输入不一致")])
    submit = SubmitField("注册")

实跑输出:

--------------------------------------------------------------------
注册页改版:多一个确认密码
--------------------------------------------------------------------

  ① 端点只多了一件事
  --------------------------------------------------------------------

  [代码原样]
  | confirm = PasswordField("确认密码",
  |     validators=[DataRequired(),
  |                 EqualTo("password", "两次输入不一致")])
  EqualTo 的第一个参数是「跟哪个字段比」,第二个是比不过时的提示。

  端点里一行都没加。校验规则写进表单类,端点只看 validate_on_submit 的结论。
  ! 这是 WTForms 最省事的地方:加一个字段、加一条规则,端点不用动。
  要是自己写 if 判断,每加一条规则都要改一次端点。

  ② 模板把四个字段各渲一遍
  --------------------------------------------------------------------
  input name=csrf_token     type=hidden
  input name=name           type=text
  input name=email          type=text
  input name=password       type=password
  input name=confirm        type=password
  input name=submit         type=submit
  ! 那段 for 循环遍历 form,跳过 CSRF 字段,其余各渲一个 label 加一个 input。
  字段加在表单类里,页面自动跟着多一行——模板不用改。

  ③ 空提交:错误显示在字段后面
  --------------------------------------------------------------------
  POST 空表单 -> 200
  页面上的错误 -> ['This field is required.', 'This field is required.', 'This field is required.', 'This field is required.']

  ④ 两次密码不一致
  --------------------------------------------------------------------
  两次密码不一样 -> 200
  页面上的错误 -> ['两次输入不一致']
  库里行数 -> 0
  ! 这条提示是自己写的那句中文字,因为它来自 EqualTo 的第二个参数。
  其余那些英文是 WTForms 内置的——想统一成中文,得给每条内置提示配翻译。

  ⑤ CSRF:不先取令牌会怎样
  --------------------------------------------------------------------
  不带 csrf_token 直接 POST -> 200(不是 400)
  库里行数 0 -> 0,说明确实没写进去
  ! 页面上一条错误都没有,但数据也没写进去。
  这就是最难查的一种:那个错误挂在 csrf_token 字段上,而模板只渲染有名字的字段,谁也没显示它。
  同一个请求,装上 CSRFProtect 之后 -> 400
  ! 差别在拦的位置:CSRFProtect 在进入视图之前就把请求拦了,所以是 400;
  FlaskForm 的字段级校验要等视图跑起来、表单开始校验,所以只是一个校验错误。
  ! 教材没有装它,只用 FlaskForm 自带的那一层。
  两种都能防住,实际项目里装的人更多——因为「静默失败」比「明着报错」难查得多。
  ! 浏览器里这事是自动做的:{{ form.hidden_tag() }} 渲染出一个隐藏 input,名字叫 csrf_token,
  提交时跟着表单一起发走。测试客户端不会自动填,得自己从页面里抠。

  ⑥ 带上令牌就通了
  --------------------------------------------------------------------
  带令牌且两次密码一致 -> 302,Location=/
  库里行数 -> 1

第二节那六个 input 里,csrf_token 是 hidden,其余按表单类里的顺序排。 第四节那条中文提示是唯一一条中文——它是我们自己写在 EqualTo 里的。 第五节那两个 0,和「返回码 200」放在一起看,才是完整的事实。

30 秒实验

  1. 把 EqualTo 的第一个参数改成 "email",提交一次,看会发生什么。
  2. 把模板里遍历字段的那三行删掉,改回手写四个字段,再给表单类加一个字段,看页面变不变。
  3. 想一想:为什么 CSRF 的错误不显示在页面上?

常见误区

  1. 以为返回 200 就是成功:CSRF 失败也是 200,数据一个字没进库。
  2. 模板里手写每个字段:加字段要改三处(表单类、端点、模板)。
  3. 把 CSRF 字段也放进循环:页面上会出现两个同名隐藏域。
  4. 只靠前端校验两次密码:绕过前端就是空的确认密码也能提交。
自测三问(点开对答案) 1. 加一个字段,端点要改吗? —— 不用,规则写在表单类里,端点只看 validate_on_submit 的结论。 2. 为什么模板可以遍历字段渲染? —— 表单类里每个字段都是可迭代对象的一部分,模板按顺序渲就行。 3. 少带 CSRF 令牌时返回什么? —— 只用 FlaskForm 时是 200 加一条看不见的错误;装了 CSRFProtect 才是 400。

注册页改完了,下一篇把标头抽出来,顺手把登录页也对齐。

相关笔记