使用Flask-Login实现用户认证

Flask-Login提供了实现用户认证需要的各类功能函数

  • 安装
    uv pip install flask-login
    
  • 初始化
    1. 实例化扩展类 LoginManager(app)
    2. 实现用户加载回调函数 @login_manager.user_loader
    3. 让User模型继承UserMixin类 class User(db.Model, UserMixin):
    4. 设置登录视图端点 login_manager.login_view = ‘login’,未登录用户访问对应URL会重定向到登录页,用login_manager.login_message来自定义错误提示消息。

核心对象
LoginManager, # Flask-Login 的核心管理器,负责初始化、配置登录视图等
UserMixin, # 简化用户模型,提供 Flask-Login 要求的核心属性 / 方法
login_user, # 登录核心函数:将用户 ID 存入加密 session(Cookie),标记用户为已登录
login_required,# 路由装饰器:访问该路由时,先检查current_user是否登录,未登录则跳转到配置的login_view
logout_user, # 登出核心函数:清除 session 中的用户 ID,current_user变回匿名用户
current_user # 表示当前登录用户的全局对象

from flask_login import LoginManager
from flask_login import UserMixin

login_manager = LoginManager(app)
@login_manager.user_loader
def load_user(user_id):
    user = db.session.get(User, int(user_id))
    return user

class User(db.Model, UserMixin):
     # ...
  • 登录
    用户输入账号密码点击登录后,视图函数里拿到参数与用户注册时写入数据库中信息进行对比,如果相等则以用户模型类对象作为参数,调用Flask-Login提供的login_user()函数。
    @app.route('/login', methods=['GET', 'POST'])
    def login():
      if request.method == 'POST':
          username = request.form.get('username')
          password = request.form.get('password')
    
          if not username or not password:
              flash('Invalid input.')
              return redirect(url_for('login'))
    
          user = db.session.execute(select(User).filter_by(username=username)).scalar()=
          if user is not None and user.validate_password(password):
              login_user(user)
              flash('Login success.')
              return redirect(url_for('index')) 
    
          flash('Invalid username or password.')
          return redirect(url_for('login'))
      return render_template('login.html')
    
  • 登出
    登出操作,调用 logout_user() 函数即可
    @app.route('/logout')
    @login_required
    def logout():
      logout_user()
      flash('Goodbye.')
      return redirect(url_for('index'))
    

存储密码

用户注册时将密码散列值存入数据库
Flask 的依赖 Werkzeug 内置了用于生成和验证密码散列值的函数

  • werkzeug.security.generate_password_hash() 用来为给定的密码生成密码散列值
  • werkzeug.security.check_password_hash() 则用来检查给定的散列值和密码是否对应
class User(db.Model):
    __tablename__ = 'user' 
    id: Mapped[int] = mapped_column(primary_key=True)   
    name: Mapped[str] = mapped_column(String(20)) 
    username: Mapped[str] = mapped_column(String(20))  
    password_hash: Mapped[Optional[str]] = mapped_column(String(255))  

    def set_password(self, password):  
        self.password_hash = generate_password_hash(password)  

    def validate_password(self, password):  
        return check_password_hash(self.password_hash, password)  

password_hash字段长度原文档上是128位,MySQL 5.7+ 默认为严格模式,当存入超过128位字符时会直接抛出错误,非严格模式下会截断;SQLite不会强制校验长度,会完整存储,不截断,不抛出错误。

认证保护

  • 视图保护,为不允许未登录用户访问的视图函数附加一个 login_required 装饰器
  • 模版内容保护,过滤掉不对未登录用户展示的内容

{ % if current_user.is_authenticated % }
<不对未登录用户展示的内容>
{ % endif % }

流程

-> 初始化LoginManager
-> 定义带ID的用户模型
-> 实现load_user回调
-> 用login_user登录
-> 用current_user/@login_required验证登录状态
-> 用logout_user登出

原理

_config.yml Flask-Login间接使用Cookie,不直接存用户信息,生成安全的session的前提是设置了secret_key

  1. Flask的session本质是加密的Cookie;
  2. Flask-Login将用户ID存入加密session;
    • 设置Session数据:视图函数login中验证用户成功后,将user_id存入Session中,即session[‘user_id’] = user_id;
    • 序列化Session数据:Flask将session字典(比如{‘user_id’: 1})序列化成JSON 字符串(默认格式)
    • 生成签名(防篡改):Flask 会用SECRET_KEY和HMAC 算法(默认 HMAC-SHA1) 对序列化后的数据生成 “签名”;签名公式:签名 = HMAC(SECRET_KEY, 序列化数据, 算法=SHA1)
    • 拼接并编码Cookie值:把 “签名 + 序列化数据” 拼接,再做 Base64 编码,最终生成 Cookie 的value;示例 cookie_value = Base64(签名 + “.” + 序列化数据),最终Cookie值可能长这样eyJ1c2VyX2lkIjoxfQ.Xw_8ZQ.9f8d7s6a5b4c3d2e1f0a9s8d7f6g5h4j3k2l1
    • 响应时发送Set-Cookie头:# 示例 Set-Cookie: session=eyJ1c2VyX2lkIjoxfQ.Xw_8ZQ.9f8d7s6a5b4c3d2e1f0a9s8d7f6g5h4j3k2l1; HttpOnly; Secure; SameSite=Lax; Path=/
  3. 每次请求时,Flask先解密Cookie中的session,取出用户ID,再通过load_user回调函数加载用户对象,赋值给current_user;
    • 从请求头中提取session Cookie值;
    • Base64解码,拆分出”签名”和”序列化数据”;
    • 用SECRET_KEY重新计算序列化数据的签名;
    • 对比”重新计算的签名”和”Cookie中的签名”;若不一致,说明数据被篡改,Flask清空Session,视为无效请求;若一致,验证通过,则反序列化JSON字符串,恢复成session字典;
    • Flask-Login 会读取session[‘user_id’],调用前面定义的load_user回调函数,加载用户对象并赋值给current_user,完成登录状态验证;

浏览器对Set-Cookie的默认处理

  1. 当浏览器收到服务端返回的Set-Cookie响应头后,解析Set-Cookie中的键值对(如session=abc123)和属性(如Domain、Path、Max-Age等);若未设置Max-Age/Expires,则Cookie是”会话Cookie”,将存储在内存中,关闭浏览器后立即失效;若设置了则为”持久Cookie”,存储到硬盘中,直到过期才被删除。
  2. 当浏览器向同一个服务器发起新请求时,会自动筛选符合条件的Cookie,添加到请求头的Cookie字段中;请求的URL必须匹配Cookie的Domain(域名)和Path(路径);多个Cookie会用;分割,拼在Cookie请求头中。

常见Cookie属性

  • Domain=example.com: 仅向example.com或其子域名(如sub.example.com)请求时携带;未设置则默认是当前请求的域名(如www.example.com,仅向该域名请求时携带)
  • Path=/admin:仅向example.com/admin/路径请求时携带;未设置则默认是当前请求的路径(如/login,仅向/login/请求时携带)
  • Secure:仅在 HTTPS 请求中携带;HTTP 请求会忽略该 Cookie
  • HttpOnly:禁止 JS 读取 Cookie(防 XSS),但不影响请求头携带(浏览器仍会自动加)
  • SameSite=Lax:限制跨站请求携带:
    -> Strict:完全禁止跨站携带(如从baidu.com跳转到example.com,请求不带 Cookie)
    -> Lax:允许部分跨站 GET 请求携带(默认值)
    -> None:允许跨站携带,但必须配合Secure
  • Max-Age=3600:Cookie 有效期(秒);过期后浏览器自动删除,不再携带

如前端通过AJAX发起跨域请求(比如从a.com请求b.com),浏览器默认不携带Cookie,需要前端 AJAX 请求开启withCredentials=true,且服务端响应头允许携带凭证

// 前端请求
fetch('https://b.com/api', {
  credentials: 'include'  // 等价于withCredentials=true
});
// 服务端响应头
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://a.com  // 不能是*,必须指定具体域名

基于Token方式登录

_config.yml


参考