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

登入就是「查得到这个邮箱」加「密码校验通过」,然后一句 login_user。 两种失败必须给同一句提示,否则别人能拿它试出哪些邮箱注册过。 next 参数能让人回到原处,但必须先确认它是站内地址,否则就是开放重定向漏洞。

📝 本章和前面几章的关系

这一篇对应教材 3.4 到 3.4.4:登入的表单类别、端点、模板、确认运作。 上一篇把账号造出来了,这一篇让账号真的能起作用。

一、登入表单

class LoginForm(FlaskForm):
    email = StringField("邮箱", validators=[DataRequired(), Email()])
    password = PasswordField("密码", validators=[DataRequired()])
    remember = BooleanField("记住我")
    submit = SubmitField("登入")

和注册表单的差别有三处:没有姓名、密码不限制长度、多一个记住我。 密码不限制长度是对的。登入时校验的是「像不像」,不是「合不合规」——长度规则只属于注册那一刻。 如果登入也加 Length(8, 60),老用户当年设的短密码就永远登不进来了。

二、两种失败给同一句话

u = _find(form.email.data)
if u is None or not u.check_password(form.password.data):
    flash("邮箱或密码不对", "danger")
    return render_template("auth/login.html", form=form)

实测密码错和邮箱不存在,返回的都是 200,页面提示都是「邮箱或密码不对」。 如果分开提示「用户不存在」和「密码错误」, 别人就能拿它批量试出哪些邮箱在你这里注册过——这叫用户枚举。 所以这不是偷懒,是安全要求。提示越含糊越好,日志里才写具体原因。

那个 or 的短路也有用:u is None 为真时不会去调 check_password,所以不用先判空。

三、login_user 做了什么

它把用户 id 写进会话,其余什么也不做——不查库、不改记录、不发请求。 实测登入成功后返回 302,Location 是 /,Set-Cookie 里出现 session,紧接着 GET /users 就是 200。 那个 session Cookie 是签名过的,内容仍然是明文。 所以里面只能放 id,不能放密码、不能放权限等级。

会话默认在关浏览器时失效。要它活得久,就得靠记住我。

四、next:登录完回到原处

未登入访问 /users,会被送去 /auth/login?next=%2Fusers。 实测带着这个 next 登入,返回的 Location 就是 /users,而不是默认的首页。 这段逻辑不用自己写:next 是 flask-login 加的,登入后要不要用它由你决定。

教材的写法是取出来、判一下、再跳。不判的写法在下面一节会出问题。

五、开放重定向

nxt = request.args.get("next")
if nxt and nxt.startswith("/"):
    return redirect(nxt)
return redirect(url_for("main.index"))

实测把 next 换成 https://evil.example/steal,登入后跳的还是首页。 startswith("/") 这一句就是全部防线:只允许站内相对地址。 漏了这一句会发生什么?攻击者发一条「登录后跳转到 evil.example」的链接, 用户看到的是自己站点的域名,登入完却被送去了钓鱼页。

这类漏洞叫开放重定向,很多真实案例就出在这三行代码上。

六、记住我

勾上之后,Set-Cookie 里多了一个 remember_token,会话 Cookie 也还在。 实测两个 Cookie 的名字是 session 与 remember_token,默认有效期一年。 会话 Cookie 关浏览器就没了,remember_token 还在——下次打开浏览器它替你把会话重建起来。

所以公共电脑上勾了它要记得登出,登出会同时清掉两个。 产品上一般还会给一个「登出所有设备」的入口。

七、动手 Demo:登入功能:校验、记住我与 next 回跳

这个 Demo 把登入的每条分支走一遍:密码错、账号不存在、都对、带 next、带恶意的 next、勾记住我。 第五节那一条是这一篇的重点,它演示的是一个真实漏洞怎么被三行代码挡住。

@bp.route("/login", methods=["GET", "POST"])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        u = _find(form.email.data)
        if u is None or not u.check_password(form.password.data):
            flash("邮箱或密码不对", "danger")
            return render_template("auth/login.html", form=form)
        login_user(u, remember=form.remember.data)
        nxt = request.args.get("next")
        if nxt and nxt.startswith("/"):
            return redirect(nxt)
        return redirect(url_for("main.index"))
    return render_template("auth/login.html", form=form)

实跑输出:

--------------------------------------------------------------------
登入功能:校验、记住我与 next 回跳
--------------------------------------------------------------------

  ① 密码输错
  --------------------------------------------------------------------
  密码错 -> 200
  页面提示 -> ['邮箱或密码不对']

  ② 邮箱根本不存在
  --------------------------------------------------------------------
  账号不存在 -> 200
  页面提示 -> ['邮箱或密码不对']
  ! 两种情况给的是同一句话。
  要是分开提示「用户不存在」与「密码错」,别人就能拿它试出哪些邮箱注册过——这叫用户枚举。

  ③ 邮箱密码都对
  --------------------------------------------------------------------
  登入成功 -> 302,Location=/
  Set-Cookie 里有 session -> True
    session=.eJwljjEOAyEMwP7C3CEBksB95hRConblelPVvxepm-XB8iedsfx6puO9bn-k8zX...
  登入后 GET /users -> 200

  ④ next 参数:登入后回到原来想去的地方
  --------------------------------------------------------------------
  未登入访问 /users -> 302,Location=/auth/login?next=%2Fusers
  带着 next 登入 -> 302,Location=/users
  ! next 里的地址被百分号编码过(%2Fusers 就是 /users),所以可以直接当参数传回去。
  这一段是 Flask-Login 自动加的,不用自己写。

  ⑤ 有人把 next 改成外站地址
  --------------------------------------------------------------------
  next 指到外站 -> 302,Location=/
  ! 端点里那句 nxt.startswith("/") 把它拦住了。
  只允许站内相对地址,否则登入完把人送去钓鱼站——这叫开放重定向,是常见漏洞。

  ⑥ 记住我
  --------------------------------------------------------------------
  勾了记住我,Set-Cookie 里多了 -> ['remember_token', 'session']
  ! remember_token 是另一个 Cookie,默认活一年。
  会话 Cookie 关了浏览器就没了,它还在——所以要给「在公共电脑上登入过」的用户一个撤销入口。

第一节与第二节的提示文字一模一样,这不是巧合,是故意的。 第四节那个 %2Fusers 就是 /users,百分号编码过的地址可以直接当参数传。 第五节跳的是 /,不是 evil.example——防线就是那句 startswith。 第六节多了 remember_token 这个 Cookie,会话 Cookie 仍然在。

30 秒实验

  1. 把 startswith 那一句改成 if nxt:,再试一次那个外站 next,看跳去哪。
  2. 把 login_user 里的 remember 参数去掉,勾着记住我再登入,看 Cookie 少了哪个。
  3. 想一想:为什么登入表单的密码字段不限制长度?

常见误区

  1. 分开提示「用户不存在」与「密码错」:等于开放用户枚举接口。
  2. next 不校验就跳:开放重定向,钓鱼链接的标准素材。
  3. 登入表单也加长度校验:老账号的短密码从此登不进来。
  4. 以为 session Cookie 加密了:它只是签名,内容是明文。
自测三问(点开对答案) 1. 两种登入失败为什么给同一句提示? —— 否则别人能靠提示试出哪些邮箱注册过,这叫用户枚举。 2. next 参数为什么必须校验? —— 不校验就是开放重定向,登入完可以被送去钓鱼站。 3. login_user 具体做了什么? —— 把用户 id 写进签名会话,别的什么也不做。

能进能出了,下一篇收尾:登出,以及页面上怎么显示当前状态。

相关笔记