登入就是「查得到这个邮箱」加「密码校验通过」,然后一句 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 秒实验
- 把
startswith那一句改成if nxt:,再试一次那个外站 next,看跳去哪。 - 把 login_user 里的 remember 参数去掉,勾着记住我再登入,看 Cookie 少了哪个。
- 想一想:为什么登入表单的密码字段不限制长度?
常见误区
- 分开提示「用户不存在」与「密码错」:等于开放用户枚举接口。
- next 不校验就跳:开放重定向,钓鱼链接的标准素材。
- 登入表单也加长度校验:老账号的短密码从此登不进来。
- 以为 session Cookie 加密了:它只是签名,内容是明文。
自测三问(点开对答案)
1. 两种登入失败为什么给同一句提示? —— 否则别人能靠提示试出哪些邮箱注册过,这叫用户枚举。 2. next 参数为什么必须校验? —— 不校验就是开放重定向,登入完可以被送去钓鱼站。 3. login_user 具体做了什么? —— 把用户 id 写进签名会话,别的什么也不做。能进能出了,下一篇收尾:登出,以及页面上怎么显示当前状态。
- ☐ (本篇完,下一篇 3.5 登出与登录状态显示)