零基础入门python23:Flask-Login登录、退出与会话
一、上一篇课后练习讲解
邮箱大小写测试应先用Alice@Example.com注册,再用alice@example.com登录,并断言数据库只有一条用户记录。归一化应发生在查询和写入之前。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:注册先规范化邮箱和校验密码,再生成带盐哈希;唯一约束冲突捕获 IntegrityError 后 rollback 并返回 409。
文件:app/auth.py
完整参考答案文件
完整文件:app/auth.py
fromflaskimportBlueprint,requestfromsqlalchemy.excimportIntegrityErrorfromwerkzeug.securityimportgenerate_password_hashfrom.extensionsimportdbfrom.modelsimportUser bp=Blueprint("auth",__name__,url_prefix="/api/auth")@bp.post("/register")defregister():data=request.get_json(silent=True)or{}email=str(data.get("email","")).strip().casefold()password=str(data.get("password",""))if"@"notinemailorlen(password)<8:return{"error":"input_invalid"},400db.session.add(User(email=email,password_hash=generate_password_hash(password)))try:db.session.commit()exceptIntegrityError:db.session.rollback()return{"error":"email_exists"},409return{"email":email},201完整参考答案文件
本篇对应的交付源码完整文件:flask-ledger/app/auth.py
fromflaskimportBlueprint,requestfromflask_loginimportcurrent_user,login_required,login_user,logout_userfrom.extensionsimportdbfrom.modelsimportUser bp=Blueprint("auth",__name__,url_prefix="/api/auth")@bp.post("/register")defregister():data=request.get_json(silent=True)or{}email=str(data.get("email","")).strip().lower()password=str(data.get("password",""))if"@"notinemail:return{"message":"邮箱格式不正确"},400iflen(password)<8:return{"message":"密码至少8位"},400ifdb.session.scalar(db.select(User).where(User.email==email)):return{"message":"邮箱已注册"},409user=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")deflogin():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))ifnotuserornotuser.check_password(str(data.get("password",""))):return{"message":"邮箱或密码错误"},401login_user(user)return{"id":user.id,"email":user.email}@bp.post("/logout")@login_requireddeflogout():logout_user()return{"message":"已退出"}@bp.get("/me")@login_requireddefme():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。
四、验收流程
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 从数据库加载用户;注销必须清除会话,禁用用户不能继续访问。
@login_manager.user_loaderdefload_user(user_id):returndb.session.get(User,int(user_id))@auth_bp.post('/login')deflogin():user=User.query.filter_by(email=request.form['email'].strip().casefold()).first()ifuserisNoneornotuser.check_password(request.form['password']):abort(401)login_user(user)returnredirect(url_for('ledger.index'))验收未登录访问账目返回 401 或重定向,登录后只能看到自己的数据;课后练习测试 session cookie 和禁用用户。
五、会话到底保存了什么
Flask-Login 默认在 cookie 中保存一个不可直接信任的用户标识,服务端每次请求通过user_loader查询用户。cookie 被篡改时签名校验会失败,current_user变成匿名用户;因此不能把“是否登录”交给前端传来的user_id。
@login_manager.user_loaderdefload_user(user_id:str):# 统一从数据库加载,找不到用户时返回 None,框架会按匿名用户处理。try:returndb.session.get(User,int(user_id))except(TypeError,ValueError):returnNone@login_manager.unauthorized_handlerdefunauthorized():# API 客户端需要 JSON 和 401,而不是浏览器登录页的 302 跳转。return{"error":"login_required"},401登录接口不应区分“邮箱不存在”和“密码错误”,否则攻击者可以枚举注册用户。remember会生成更长寿命的 cookie,只有在用户明确勾选时才启用。若项目改成前后端分离并使用 JWT,仍然需要保留同样的认证边界,只是令牌存储位置不同。
六、用测试客户端证明 cookie 生效
deftest_login_session(client,user):response=client.post("/api/auth/login",json={"email":user.email,"password":"correct-horse"})assertresponse.status_code==200me=client.get("/api/auth/me")assertme.status_code==200assertme.json["id"]==user.idclient.post("/api/auth/logout")assertclient.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 自然过期:
@login_manager.user_loaderdefload_user(user_id):user=db.session.get(User,int(user_id))returnuserifuseranduser.is_activeelseNone八、上一篇练习讲解与本篇练习
上一篇的并发注册测试应使用两个独立 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。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
fromflaskimportBlueprint,requestfromflask_loginimportcurrent_user,login_required,login_user,logout_userfrom.extensionsimportdbfrom.modelsimportUser bp=Blueprint("auth",__name__,url_prefix="/api/auth")@bp.post("/register")defregister():data=request.get_json(silent=True)or{}email=str(data.get("email","")).strip().lower()password=str(data.get("password",""))if"@"notinemail:return{"message":"邮箱格式不正确"},400iflen(password)<8:return{"message":"密码至少8位"},400ifdb.session.scalar(db.select(User).where(User.email==email)):return{"message":"邮箱已注册"},409user=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")deflogin():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))ifnotuserornotuser.check_password(str(data.get("password",""))):return{"message":"邮箱或密码错误"},401login_user(user)return{"id":user.id,"email":user.email}@bp.post("/logout")@login_requireddeflogout():logout_user()return{"message":"已退出"}@bp.get("/me")@login_requireddefme():return{"id":current_user.id,"email":current_user.email}