零基础入门python23:Flask-Login登录、退出与会话

一、上一篇课后练习讲解
邮箱大小写测试应先用 Alice@Example.com 注册,再用 alice@example.com 登录,并断言数据库只有一条用户记录。归一化应发生在查询和写入之前。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:注册先规范化邮箱和校验密码,再生成带盐哈希;唯一约束冲突捕获 IntegrityError 后 rollback 并返回 409。
文件:app/auth.py
完整参考答案文件
完整文件:app/auth.py
python
from flask import Blueprint, request
from sqlalchemy.exc import IntegrityError
from werkzeug.security import generate_password_hash
from .extensions import db
from .models import User
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@bp.post("/register")
def register():
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().casefold()
password = str(data.get("password", ""))
if "@" not in email or len(password) < 8:
return {"error": "input_invalid"}, 400
db.session.add(User(email=email, password_hash=generate_password_hash(password)))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return {"error": "email_exists"}, 409
return {"email": email}, 201
完整参考答案文件
本篇对应的交付源码完整文件:flask-ledger/app/auth.py
python
from flask import Blueprint, request
from flask_login import current_user, login_required, login_user, logout_user
from .extensions import db
from .models import User
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@bp.post("/register")
def register():
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
password = str(data.get("password", ""))
if "@" not in email:
return {"message": "邮箱格式不正确"}, 400
if len(password) < 8:
return {"message": "密码至少8位"}, 400
if db.session.scalar(db.select(User).where(User.email == email)):
return {"message": "邮箱已注册"}, 409
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
return {"id": user.id, "email": user.email}, 201
@bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
user = db.session.scalar(db.select(User).where(User.email == email))
if not user or not user.check_password(str(data.get("password", ""))):
return {"message": "邮箱或密码错误"}, 401
login_user(user)
return {"id": user.id, "email": user.email}
@bp.post("/logout")
@login_required
def logout():
logout_user()
return {"message": "已退出"}
@bp.get("/me")
@login_required
def me():
return {"id": current_user.id, "email": current_user.email}
验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇完成什么
让注册用户登录并保持会话,完成 /me 查看当前用户和 /logout 退出。登录状态由 Flask-Login 从 session 恢复,受保护接口使用 @login_required。

三、为什么不用客户端传 user_id
/me 从 current_user.id 读取身份,而不是接受 JSON 中的 user_id。客户端提供的 id 只是普通输入,不能作为授权依据;身份必须来自已经验证的 session。
四、验收流程
powershell
curl -c cookies.txt -X POST http://127.0.0.1:5000/api/auth/login -H "Content-Type: application/json" -d "{\"email\":\"alice@example.com\",\"password\":\"secret123\"}"
curl -b cookies.txt http://127.0.0.1:5000/api/auth/me
curl -b cookies.txt -X POST http://127.0.0.1:5000/api/auth/logout
curl -b cookies.txt http://127.0.0.1:5000/api/auth/me
预期登录和 /me 为 200,退出后 /me 为 401。测试必须覆盖错误密码、未登录访问和退出后会话失效。课后练习:增加会话过期配置,并解释开发环境和生产环境的 Cookie 安全属性差异。
项目增量:Flask-Login 会话边界
登录成功后使用安全 cookie 保存会话标识,服务器通过 user_loader 从数据库加载用户;注销必须清除会话,禁用用户不能继续访问。
python
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
@auth_bp.post('/login')
def login():
user = User.query.filter_by(email=request.form['email'].strip().casefold()).first()
if user is None or not user.check_password(request.form['password']):
abort(401)
login_user(user)
return redirect(url_for('ledger.index'))
验收未登录访问账目返回 401 或重定向,登录后只能看到自己的数据;课后练习测试 session cookie 和禁用用户。
五、会话到底保存了什么
Flask-Login 默认在 cookie 中保存一个不可直接信任的用户标识,服务端每次请求通过 user_loader 查询用户。cookie 被篡改时签名校验会失败,current_user 变成匿名用户;因此不能把"是否登录"交给前端传来的 user_id。
python
@login_manager.user_loader
def load_user(user_id: str):
# 统一从数据库加载,找不到用户时返回 None,框架会按匿名用户处理。
try:
return db.session.get(User, int(user_id))
except (TypeError, ValueError):
return None
@login_manager.unauthorized_handler
def unauthorized():
# API 客户端需要 JSON 和 401,而不是浏览器登录页的 302 跳转。
return {"error": "login_required"}, 401
登录接口不应区分"邮箱不存在"和"密码错误",否则攻击者可以枚举注册用户。remember 会生成更长寿命的 cookie,只有在用户明确勾选时才启用。若项目改成前后端分离并使用 JWT,仍然需要保留同样的认证边界,只是令牌存储位置不同。
六、用测试客户端证明 cookie 生效
python
def test_login_session(client, user):
response = client.post("/api/auth/login", json={
"email": user.email, "password": "correct-horse"
})
assert response.status_code == 200
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json["id"] == user.id
client.post("/api/auth/logout")
assert client.get("/api/auth/me").status_code == 401
测试中必须复用同一个 client,因为 cookie 存在客户端对象里。出现"明明登录了仍是 401"时,检查 SECRET_KEY 是否在每次创建应用时稳定,以及 user_loader 是否把字符串 id 转成整数;如果每次请求都创建新 client,会话当然不会保留。
七、禁用用户和会话失效
在 User 增加 is_active 字段后,is_authenticated 仍然可以为真,但 is_active 应在 user_loader 中检查。管理员禁用用户后,下一次请求应立即失效,而不是等 cookie 自然过期:
python
@login_manager.user_loader
def load_user(user_id):
user = db.session.get(User, int(user_id))
return user if user and user.is_active else None
八、上一篇练习讲解与本篇练习
上一篇的并发注册测试应使用两个独立 Session;本篇练习增加 GET /api/auth/me 和禁用用户测试,要求退出后再次访问返回 401。下一篇会在这个身份基础上实现分类 CRUD,并演示越权访问必须返回 404 还是 403。
九、Cookie 安全实验
在浏览器开发者工具的 Application → Cookies 中确认会话 cookie 具有 HttpOnly 和 SameSite=Lax;若线上使用 HTTPS,再打开 Secure。把 cookie 的值改一个字符后重新访问 /api/auth/me,应得到 401,说明签名校验生效。不要把 Flask 的签名 cookie 当作"加密数据库",其中内容仍可能被读取;敏感资料应放服务端 Session 或数据库。
如果采用前后端分离,跨域请求还要正确设置 CORS 的 supports_credentials,并限制允许的 origin;不能写 * 同时携带 cookie。完成实验后清理浏览器 cookie,避免旧会话干扰下一次测试。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 flask-ledger/app/auth.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
python
from flask import Blueprint, request
from flask_login import current_user, login_required, login_user, logout_user
from .extensions import db
from .models import User
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@bp.post("/register")
def register():
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
password = str(data.get("password", ""))
if "@" not in email:
return {"message": "邮箱格式不正确"}, 400
if len(password) < 8:
return {"message": "密码至少8位"}, 400
if db.session.scalar(db.select(User).where(User.email == email)):
return {"message": "邮箱已注册"}, 409
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
return {"id": user.id, "email": user.email}, 201
@bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
user = db.session.scalar(db.select(User).where(User.email == email))
if not user or not user.check_password(str(data.get("password", ""))):
return {"message": "邮箱或密码错误"}, 401
login_user(user)
return {"id": user.id, "email": user.email}
@bp.post("/logout")
@login_required
def logout():
logout_user()
return {"message": "已退出"}
@bp.get("/me")
@login_required
def me():
return {"id": current_user.id, "email": current_user.email}