Flask-Login 实战:用户登录、登出与 Session 管理

📂 所属阶段:第三阶段 — 用户系统(安全篇)
🔗 相关章节:密码安全加密 · 数据验证


1. 前置准备与概述

1.1 为什么选 Flask-Login?

手动写 Session 管理、登录状态同步、路由保护太容易踩坑(比如 Cookie 劫持防护、未登录跳转、「记住我」功能的 Cookie 有效期)。Flask-Login 把这些通用逻辑封装好了,只需要我们实现核心的业务钩子基础路由就能搞定一套规范的登录系统。

1.2 核心能力列表

  • ✅ 用户 Session 的创建、销毁与维护
  • current_user 全局当前登录用户对象
  • @login_required 一键保护路由
  • ✅ 「记住我」功能的自动 Cookie 管理
  • ✅ 未登录/无权限时的统一重定向与提示
  • ✅ 匿名用户的默认实现

1.3 安装

pip install flask-login

2. 全局初始化与基础配置

2.1 创建独立的扩展管理器

为了避免循环依赖(比如 app/__init__.py 同时导入 modelsauth,而 auth 又导入 app),我们把所有第三方扩展的初始化放到单独的文件里:

# app/extensions.py
from flask_login import LoginManager

# 1. 实例化 LoginManager
login_manager = LoginManager()

# 2. 配置未登录行为
login_manager.login_view = "auth.login"  # 未登录跳转到 auth 蓝图的 login 路由
login_manager.login_message = "请先登录后再访问该页面"  # 未登录的提示文字
login_manager.login_message_category = "warning"  # 提示的 Bootstrap 风格(可选)

2.2 在应用工厂中绑定扩展并加载用户

Flask 推荐用应用工厂模式管理多环境,所以扩展要在工厂里动态绑定 app

# app/__init__.py
from flask import Flask
from datetime import timedelta
from app.extensions import db, login_manager  # 假设还有 SQLAlchemy
from app.routes import auth_bp, main_bp

def create_app(config_class=None):
    app = Flask(__name__)
    # 加载配置(生产环境一定要从环境变量取 SECRET_KEY!)
    app.config["SECRET_KEY"] = "dev-only-secret-key-please-change-this"
    app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

    # 3. Session 与「记住我」安全配置
    app.config["REMEMBER_COOKIE_DURATION"] = timedelta(days=14)  # 记住我有效期14天
    app.config["SESSION_COOKIE_SECURE"] = False  # 开发环境设False,生产必须True
    app.config["SESSION_COOKIE_HTTPONLY"] = True  # 禁止 JS 读取(防XSS基础)
    app.config["SESSION_COOKIE_SAMESITE"] = "Lax"  # 基础防CSRF

    # 4. 绑定扩展到当前 app
    db.init_app(app)
    login_manager.init_app(app)

    # 5. **必须实现的用户加载回调**
    # Flask-Login 每次处理请求时,会从 Session/Cookie 中取 user_id,调用这个函数获取完整用户对象
    @login_manager.user_loader
    def load_user(user_id):
        from app.models.user import User  # 延迟导入避免循环依赖
        return db.session.get(User, int(user_id))  # 用 SQLAlchemy 2.0 的 get 更高效

    # 6. 注册蓝图
    app.register_blueprint(main_bp)
    app.register_blueprint(auth_bp, url_prefix="/auth")

    return app

3. 准备带 UserMixin 的用户模型

Flask-Login 要求用户对象实现 4 个核心属性/方法:

  1. is_authenticated:是否已登录(返回 True/False)
  2. is_active:账户是否激活(返回 True/False)
  3. is_anonymous:是否是匿名用户(返回 True/False)
  4. get_id():返回用户的唯一字符串/数字 ID

UserMixin 已经帮我们实现了这 4 个(除了 is_active 可能需要根据业务覆盖),直接继承就行:

# app/models/user.py
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db

class User(UserMixin, db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    username = db.Column(db.String(50), unique=True, index=True)
    password_hash = db.Column(db.String(256), nullable=False)
    is_active = db.Column(db.Boolean, default=True)  # 可以禁用账户
    is_admin = db.Column(db.Boolean, default=False)  # 可选:自定义权限字段
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def __repr__(self):
        return f"<User {self.email}>"

4. 核心路由:登录与登出

4.1 登录路由(处理表单验证、密码比对、Session 创建)

假设我们用了 Flask-WTF 做表单(如果没有,用原生 request.form 也可以):

# app/routes/auth.py
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.extensions import db
from app.models.user import User
from app.forms import LoginForm  # 提前用 Flask-WTF 写好的登录表单

auth_bp = Blueprint("auth", __name__, template_folder="templates")

@auth_bp.route("/login", methods=["GET", "POST"])
def login():
    # 1. 已登录用户直接跳转
    if current_user.is_authenticated:
        return redirect(url_for("main.index"))

    form = LoginForm()
    # 2. 表单验证通过后处理登录逻辑
    if form.validate_on_submit():
        # 3. 根据邮箱找用户(唯一索引查询快)
        user = User.query.filter_by(email=form.email.data).first()

        # 4. 密码比对与账户检查
        if user and check_password_hash(user.password_hash, form.password.data):
            if not user.is_active:
                flash("您的账户已被禁用,请联系管理员。", "danger")
                return render_template("auth/login.html", form=form)

            # 5. 核心登录逻辑:创建 Session,记住我可选
            login_user(user, remember=form.remember_me.data)

            # 6. 处理「登录后跳转到来源页」(防止 open redirect 漏洞!!!)
            # 只允许相对路径(以 / 开头),否则跳转到首页
            next_page = request.args.get("next")
            return redirect(next_page) if (next_page and next_page.startswith("/")) else redirect(url_for("main.index"))
        else:
            flash("邮箱或密码错误,请重试。", "danger")

    # 7. GET 请求或表单验证失败,渲染登录页
    return render_template("auth/login.html", form=form)

4.2 登出路由(一键清除 Session)

@auth_bp.route("/logout")
@login_required  # 只有登录的人才能登出
def logout():
    logout_user()  # 自动清除当前用户的 Session 和「记住我」Cookie
    flash("您已安全退出登录。", "info")
    return redirect(url_for("auth.login"))

5. 路由保护的进阶玩法

5.1 基础保护:@login_required 装饰器

直接加在路由函数的最前面(注意顺序:装饰器从下往上执行):

# app/routes/auth.py
@auth_bp.route("/profile")
@login_required
def profile():
    # current_user 是全局可用的当前登录用户对象
    return render_template("auth/profile.html", user=current_user)

5.2 局部保护:仅特定请求方式需要登录

比如评论功能,看评论公开,写评论必须登录

# app/routes/main.py
from flask import Blueprint, request
from flask_login import login_required, current_user
from app.models import Comment

main_bp = Blueprint("main", __name__)

# GET 请求公开,POST 请求受保护
@main_bp.route("/posts/<int:post_id>/comments", methods=["GET", "POST"])
def post_comments(post_id):
    if request.method == "POST":
        # 只在 POST 时加保护逻辑(或者单独写一个 POST 路由)
        if not current_user.is_authenticated:
            return redirect(url_for("auth.login", next=request.url))
        # 处理添加评论...
    # 处理 GET 请求看评论...

5.3 自定义权限装饰器

比如管理员专属编辑专属的路由:

# app/decorators.py
from functools import wraps
from flask import abort
from flask_login import current_user

def admin_required(f):
    """仅 is_admin=True 的用户可访问"""
    @wraps(f)  # 保留原函数的元信息(比如 __name__)
    def decorated(*args, **kwargs):
        if not current_user.is_authenticated:
            # 未登录:401 Unauthorized
            abort(401)
        if not current_user.is_admin:
            # 已登录但无权限:403 Forbidden
            abort(403)
        return f(*args, **kwargs)
    return decorated

# 使用示例
@auth_bp.route("/admin")
@login_required
@admin_required  # 先登录,再管理员权限
def admin_dashboard():
    return render_template("admin/dashboard.html")

6. 模板中使用 current_user

current_user 同样在 Jinja2 模板中全局可用,可以用来动态渲染导航栏:

<!-- templates/base.html -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
        <a class="navbar-brand" href="{{ url_for('main.index') }}">My Flask App</a>
        <div class="collapse navbar-collapse">
            <ul class="navbar-nav ms-auto">
                {% if current_user.is_authenticated %}
                    <!-- 登录后的导航 -->
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.profile') }}">
                            {{ current_user.username or current_user.email }}
                        </a>
                    </li>
                    {% if current_user.is_admin %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{ url_for('auth.admin_dashboard') }}">管理后台</a>
                        </li>
                    {% endif %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.logout') }}">退出登录</a>
                    </li>
                {% else %}
                    <!-- 未登录的导航 -->
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
                    </li>
                {% endif %}
            </ul>
        </div>
    </div>
</nav>

7. 关键安全提示

  1. SECRET_KEY 必须保密且随机:生产环境用 secrets.token_hex(32) 生成,放在环境变量(比如 .env + python-dotenv)中,绝对不能上传到代码仓库
  2. 防止 open redirect 漏洞:处理 next_page 时,只允许以 / 开头的相对路径,禁止外部 URL。
  3. HTTPS 必须开启:生产环境设置 SESSION_COOKIE_SECURE=True,否则 Cookie 可能被中间人劫持。
  4. 密码不能明文存储:必须用 werkzeug.security.generate_password_hash 加密存储。
  5. 注意装饰器的顺序:自定义权限装饰器要放在 @login_required 后面(从下往上执行:先检查登录,再检查权限)。

8. 速查总结

# 初始化与配置
login_manager = LoginManager()
login_manager.login_view = "auth.login"

# 用户加载(必须)
@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))

# 用户模型
class User(UserMixin, db.Model): ...

# 登录登出
login_user(user, remember=False)
logout_user()

# 当前用户
current_user.is_authenticated  # 登录状态
current_user.id                # 用户ID
current_user.username          # 自定义字段

# 路由保护
@login_required
@admin_required
def protected(): ...

🔗 扩展阅读