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

flask-login 要四样:LoginManager、user_loader、UserMixin、一个 SECRET_KEY。 会话里只存一个用户 id,每个请求用 user_loader 把对象现查回来。 密码存哈希而不是密码本身;未登入时 current_user 是匿名对象,不是 None。

📝 本章和前面几章的关系

这一篇对应教材 3.3 到 3.3.3:启动 flask-login、建立注册表单类别、修改 User 模型。 这是全书的第二处三颗星,因为「状态」第一次真正进到应用里。

一、四样东西各管什么

要准备什么 写在哪 作用
LoginManager() web/init.py 绑定应用,配登录页地址
user_loader web/init.py 按 id 把用户取回来
UserMixin web/models.py 给 User 补属性和方法
SECRET_KEY config 签名会话,缺了登不上

四样缺一不可,而且缺哪一样报的错都不在同一个地方。 最隐蔽的是 SECRET_KEY:缺了它注册能成功、库里也有记录,但会话一写就抛错,看起来像「登入没反应」。 这四样就是 flask-login 的全部门槛。跨过去之后剩下的都是业务逻辑。

二、UserMixin 给的是什么

继承它之后,User 多了四个属性和一个方法:is_authenticated、is_active、is_anonymous、 is_anonymous 与 get_id()。 实测:这三个布尔值都是写死的,is_authenticated 是 True、is_active 是 True、is_anonymous 是 False。 get_id() 返回的是字符串 '1',不是整数 1。会话里存的就是这个字符串。

要做「封号不许登入」,重写 is_active 就行,别处一行不用改。 这正是 Mixin 这种设计的价值:钩子已经留好了。

三、user_loader:会话里只有一个 id

@login_manager.user_loader
def load_user(uid):
    return db.session.get(User, int(uid))

实测 login_user 之后,会话里只有三个键:_user_id、_fresh、_id。其中 _user_id 的值就是字符串 '1'。 每个请求进来,flask-login 拿这个 id 调一次 user_loader,把对象查回来。 所以改了用户名,下一个请求立刻生效——不用让用户重新登入。 如果它把整个对象存进会话,改了名字还得等会话过期。

反过来也有代价:每个请求多一次数据库查询。用户量大的系统会加缓存。

四、密码怎么存

存哈希,不存密码。werkzeug 给了两个函数:generate_password_hash 与 check_password_hash。 实测同一个密码哈希两次,结果不一样,长度都是 162,前缀是 scrypt:32768:8:1。 因为每次都加了不同的盐。所以没法靠比对哈希值判断两个人密码是否相同,只能拿明文去 check。

前缀里那串数字是算法与参数。将来换算法,旧哈希照样能校验——因为它自己带着参数,不用你记。 表里那一列叫 password_hash,没有 password 这一列。这意味着密码丢了谁也找不回来,只能重置。

五、login_required 与那个 next

@bp.route("/users")
@login_required
def users():
    return render_template("users.html")

装饰器一挂,未登入访问就返回 302,Location 是 /auth/login?next=%2Fusers。 那个 next 是 Flask-Login 自动加的:它记住了你本来想去哪,登入成功后把你送回去。 %2Fusers 就是 /users 的百分号编码。所以它可以当一个普通参数传回去,不用自己解析。

注意装饰器顺序:route 在上、login_required 在下。反了会报错,因为函数还没变成视图就被包住了。

六、未登入时 current_user 是谁

实测它的外壳是 LocalProxy,背后真正的对象是 AnonymousUserMixin,is_authenticated 是 False。 它不是 None。所以模板里可以直接写 current_user.is_authenticated, 不用先判空——这也是 Flask-Login 最贴心的一处设计。 外壳那层 LocalProxy 和 request、session 是同一个套路:只在有请求上下文时才有值。 在请求外面直接读它,报的错一模一样。

七、动手 Demo:flask-login:让应用认识谁在登录

这个 Demo 把 flask-login 接进上一篇的项目,然后逐项验证四样东西各自的作用。 最后一节故意不登入就去访问受保护页面,看它把人送去哪。

login_manager = LoginManager()

def create_app(config=None):
    app = Flask(__name__)
    db.init_app(app)

    login_manager.init_app(app)
    login_manager.login_view = "auth.login"
    login_manager.login_message = "请先登入"

    @login_manager.user_loader
    def load_user(uid):
        return db.session.get(User, int(uid))
    return app

实跑输出:

--------------------------------------------------------------------
flask-login:让应用认识谁在登录
--------------------------------------------------------------------

  ① 四样东西各在哪
  --------------------------------------------------------------------

| 要准备什么 | 写在哪 | 作用 |
|---|---|---|
| LoginManager() | web/__init__.py | 绑定应用,配登录页地址 |
| user_loader | web/__init__.py | 按 id 把用户取回来 |
| UserMixin | web/models.py | 给 User 补四个属性和一个方法 |
| SECRET_KEY | config | 签名会话,缺了它登不上 |

  login_manager.login_view -> auth.login
  login_manager.login_message -> 请先登入
  ! login_view 写的是端点名,不是地址。写成 "/auth/login" 会在跳转时抛 BuildError。

  ② UserMixin 补了什么
  --------------------------------------------------------------------
  User 的继承链 -> ['User', 'Model', 'Model', 'UserMixin', 'object']
  MRO 里有 UserMixin 吗 -> True
  is_authenticated -> True
  is_active -> True
  is_anonymous -> False
  get_id() -> '1'(注意是字符串)
  ! 这四个属性全写死在 UserMixin 里,值都是固定 True 或 False。
  要做「封号不许登入」,重写 is_active 就行,别处不用改。

  ③ user_loader 存的是 id,不是对象
  --------------------------------------------------------------------
  login_user 之后 session 里的键 -> ['_fresh', '_id', '_user_id']
  _user_id 的值 -> '1'
  ! 会话里只有这一个字符串 id,每次请求用 user_loader 现查一次。
  所以改了用户名,下一个请求立刻生效——不用重新登入。

  ④ 密码怎么存
  --------------------------------------------------------------------
  哈希长度 -> 162
  前 22 个字符 -> scrypt:32768:8:1$SMWB1
  同一个密码哈希两次,结果相同吗 -> False
  校验对的密码 -> True
  校验错的密码 -> False
  ! 两次结果不同,是因为每次加了不同的盐。所以没法靠比对哈希值判断两个人密码一样。
  前缀里那串数字是算法与参数,换算法时旧密码照样能校验。

  ⑤ 没登入就想进受保护的页面
  --------------------------------------------------------------------
  GET /users -> 302,Location=/auth/login?next=%2Fusers
  跟过去之后页面里有「请先登入」吗 -> True

  ⑥ 没登入时 current_user 是谁
  --------------------------------------------------------------------
  看到的外壳 -> LocalProxy
  背后真正的对象 -> AnonymousUserMixin
  is_authenticated -> False
  ! 它不是 None。
  Flask-Login 塞了一个匿名用户对象进来,所以模板里可以直接写 current_user.is_authenticated,
  不用先判空。
  ! 外面那层 LocalProxy 是 Flask 的上下文代理,和 request、
  session 是同一个套路——它只在有请求上下文时才有值。
  在请求外面直接读 current_user,报的错和读 request 一样。

第二节那个继承链里出现了两个 Model,一个是 Flask-SQLAlchemy 的、一个是 SQLAlchemy 的, UserMixin 排在它们后面。 第三节的会话键只有三个,_id 那一长串是会话自己的标识,不是用户 id。 第四节哈希长度 162、两次结果不同——这两条合起来才说明「加了盐」。 第六节那个 LocalProxy 会让人以为类型判断错了,其实背后是 AnonymousUserMixin。

30 秒实验

  1. 把 user_loader 改成返回 None,看登入之后会发生什么。
  2. 给 User 加一个 is_active 属性返回 False,再登入一次,看 flask-login 怎么处理。
  3. 想一想:为什么把整个 User 对象塞进会话是个坏主意?

常见误区

  1. 忘了 SECRET_KEY:注册能成、库里有数据,但会话写不进去,表现像「登入没反应」。
  2. 把密码明文存进库:一旦库泄露就是全量泄露,而且没法补救。
  3. user_loader 返回整个对象:占会话空间,改了资料还要等过期。
  4. 装饰器顺序写反:login_required 写在 route 上面,启动就报错。
自测三问(点开对答案) 1. flask-login 在会话里存了什么? —— 只存用户 id(一个字符串),每个请求用 user_loader 现查。 2. 为什么同一个密码两次哈希结果不同? —— 每次加了不同的盐,所以只能拿明文去 check,不能比对哈希值。 3. 未登入时 current_user 是什么? —— 一个匿名用户对象(AnonymousUserMixin),is_authenticated 为 False,不是 None。

四样东西齐了,下一篇写注册:查重、哈希、顺手建立会话。

相关笔记