🧪 第3章 建立验证功能
📄 一句话概括

注册端点就三件事:查邮箱重不重、把密码哈希、写库并建立会话。 库里那一列叫 password_hash,只有哈希没有密码,丢了也找不回来。 给 CRUD 页面挂上 login_required,整套后台才算真正关上。

📝 本章和前面几章的关系

这一篇对应教材 3.3.4 到 3.3.7:注册的端点、注册的模板、改成必须登入、确认运作。 上一篇把工具装好了,这一篇用它写出第一个完整功能。

一、注册表单多一个密码字段

class RegisterForm(FlaskForm):
    name = StringField("姓名", validators=[DataRequired(), Length(1, 20)])
    email = StringField("邮箱", validators=[DataRequired(), Email()])
    password = PasswordField("密码",
        validators=[DataRequired(), Length(8, 60)])
    submit = SubmitField("注册")

PasswordField 和 StringField 的差别只在渲染出来的 input type:一个 password、一个 text。 别指望它保护什么。type=password 只是不显示字符,表单内容在传输时一样是明文, 真正的保护是 HTTPS。 长度限制写在校验器里,不写在前端的 maxlength。前端那一个随手就能绕过。

二、端点三件事

if form.validate_on_submit():
    if _find(form.email.data):
        flash("这个邮箱已经注册过了", "warning")
        return render_template("auth/register.html", form=form)
    u = User(name=form.name.data, email=form.email.data)
    u.set_password(form.password.data)
    db.session.add(u)
    db.session.commit()
    login_user(u)
    return redirect(url_for("main.index"))

顺序是有讲究的:先查重、再建对象、再哈希、再写库、最后建立会话。 set_password 那一行是关键。 少了它,password_hash 是空字符串,而表上那一列是 nullable=False,插入直接报错。 报出来的错是 NOT NULL constraint failed,不会提醒你「忘了哈希」。

三、库里存的不是密码

实测注册成功后那一行:password_hash 以 scrypt:32768:8:1 开头,表里的列是 id、name、email、 password_hash——没有 password。 明文密码在哈希里搜不到,因为哈希是不可逆的运算。 这个设计的直接后果:密码丢了谁也找不回来,只能重置。所以注册页最好写一句「密码丢了只能重置」, 省掉后面的扯皮。

四、重复注册与短密码

实测同一个邮箱再注册一次,返回码 200(不是 302),页面上出现「这个邮箱已经注册过了」, 库里行数还是 1。 返回 200 意味着没跳转——留在原页面让用户改,这是对的。 密码填 abc,页面上出现的是 Field must be between 8 and 60 characters long.

这些英文提示是 WTForms 自带的。要中文得配 locale 或自己写 messages,教材没展开, 实际项目第一天就要处理。

五、给 CRUD 挂上登录门

教材 3.3.6 把 main 蓝图的两个页面都加上 @login_required。 加完之后,未登入访问 /users 会 302 到 /auth/login?next=%2Fusers。 注意 next 里带的是原始地址,所以登入后会回到 /users, 而不是回到首页——这一条是上一篇讲的那个机制在起作用。

首页也挂上之后,未登入的人打开网站第一眼看到的就是登入页。 要不要这样取决于产品,但功能站一般都这么干。

六、注册完为什么不让他再登一次

端点最后那句 login_user(u) 直接把会话建起来,用户注册完就是登录状态。 实测注册之后紧接着 GET /users 返回 200,说明真的进得去。 把那一行删掉,同一个请求就变成 302——这一行就是「自动登入」的全部代码。

有些产品故意不自动登入,让用户走一遍登入流程,顺便确认密码记住了。 两种都行,取决于你想让用户少点几下还是多点几下。

七、动手 Demo:注册功能:查重、哈希、自动登入

这个 Demo 走完注册的每条分支:成功、重复邮箱、密码太短,每一支都看返回码和库里的行数。 最后一节验证「注册完就已经是登录状态」这件事。

@bp.route("/register", methods=["GET", "POST"])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        if _find(form.email.data):
            flash("这个邮箱已经注册过了", "warning")
            return render_template("auth/register.html", form=form)
        u = User(name=form.name.data, email=form.email.data)
        u.set_password(form.password.data)
        db.session.add(u)
        db.session.commit()
        login_user(u)
        return redirect(url_for("main.index"))
    return render_template("auth/register.html", form=form)

实跑输出:

--------------------------------------------------------------------
注册功能:查重、哈希、自动登入
--------------------------------------------------------------------

  ① 注册成功:302 而不是 200
  --------------------------------------------------------------------
  POST /auth/register -> 302,Location=/
  库里行数 -> 1
  ! 和新增用户一样走重定向。注册成功之后浏览器跟过去的是 GET /,刷新一百次也不会多出第二个账号。

  ② 库里存的是哈希,不是密码
  --------------------------------------------------------------------
  password_hash 前 34 个字符 -> scrypt:32768:8:1$neNCbFoN2Rr2CMvl$
  明文密码在哈希里吗 -> False
  user 表的列 -> ['id', 'name', 'email', 'password_hash']
  ! 表里没有 password 这一列,只有 password_hash。这个设计意味着:密码丢了谁也找不回来,只能重置。

  ③ 同一个邮箱再注册一次
  --------------------------------------------------------------------
  再注册 -> 200,页面里有「已经注册过了」吗 -> True
  库里行数 -> 1(没变)
  ! 查重是「先查一次再插入」,两个人同时提交仍有窗口。
  真正保险的是 email 上那个 unique 约束,加 IntegrityError 兜底。本篇两个都做了。

  ④ 密码太短
  --------------------------------------------------------------------
  密码填 abc -> 200
  页面上的错误 -> ['Field must be between 8 and 60 characters long.']
  ! Length(8, 60) 在这里才起作用。长度限制必须写在校验器里,不能只靠前端那个 maxlength。

  ⑤ 注册完就已经是登录状态
  --------------------------------------------------------------------
  紧接着 GET /users -> 200
  (要是没登入,这一行会是 302 跳到 /auth/login)
  ! 端点里那句 login_user(u) 就是全部:注册完直接建立会话,用户不用再登一次。
  把那一行删掉,上面这个 200 立刻变成 302。

第一节那个 302 是注册成功的标志;200 反而说明被拦下来了。 第二节打印了库里的列名——没有 password 这一列,只有 password_hash。 第四节那两句英文提示是 WTForms 给的,不是我们写的。

30 秒实验

  1. 把 login_user(u) 注释掉,再注册一个新账号,看紧接着访问 /users 变成几。
  2. 把 email 字段的校验器里的 Email() 去掉,注册一个不存在的域名,看能不能过。
  3. 想一想:为什么查重之后还要在 email 列上加 unique?

常见误区

  1. 忘了 set_password:password_hash 是空值,插入时撞 NOT NULL 约束。
  2. 只查重不加唯一索引:两个人同时提交,两个都能过。
  3. 密码长度只靠前端 maxlength:绕过前端就是空密码也能注册。
  4. 返回 302 当成失败:注册成功才是 302,失败留在原页是 200。
自测三问(点开对答案) 1. 注册端点里三件事的顺序是什么? —— 先查重,再哈希,再写库并建立会话。 2. 注册成功为什么返回 302? —— 重定向到首页,避免刷新时重复提交表单。 3. 为什么密码列叫 password_hash? —— 存的是不可逆哈希,提醒你这里没有明文可查。

注册通了,下一篇写登入:校验、限流思路、记住我与 next 回跳。

相关笔记