Python 现代开发学习笔记

(十)用 Cookie 找回服务器会话

1. 先建立编号,再交回浏览器

图中用 s1、s2 表示随机会话编号。真实编号通过 secrets.token_urlsafe 生成;它与前一章用于复现实验的固定种子不同,用来让外部不能按既定序列推算登录编号。

# 项目文件:web_project/src/webbook/security.py
# Cookie只携带随机会话编号,身份和CSRF令牌保留在服务器数据库。
import secrets
from dataclasses import dataclass
from http.cookies import CookieError, SimpleCookie

from werkzeug.security import check_password_hash

from .http import Problem


@dataclass
class Session:
    sid: str
    csrf: str
    user_id: int | None
    username: str | None
    expires: int


def new_session(connection, now, user=None):
    session = Session(
        secrets.token_urlsafe(32),
        secrets.token_urlsafe(32),
        user["id"] if user else None,
        user["username"] if user else None,
        now + 3600,
    )
    connection.execute(
        "INSERT INTO sessions(sid,csrf,user_id,expires) VALUES (?,?,?,?)",
        (session.sid, session.csrf, session.user_id, session.expires),
    )
    return session

Session 保存编号、对应用户、过期时间与另一个用于写入核对的 csrf 令牌。新打开页面时可以先建立匿名会话,user_id 为 None;登录成功后再关联具体用户。过期时间按当前秒数加 3600 保存。

2. 收到编号后查询会话表

# 续写同一文件:web_project/src/webbook/security.py
# Cookie 只提供会话编号;查到过期记录时删除它,再让后续请求建立新会话。
def load_session(connection, raw_cookie, now):
    cookie = SimpleCookie()
    try:
        cookie.load(raw_cookie)
    except CookieError:
        return None
    if "reading_sid" not in cookie:
        return None
    row = connection.execute(
        """SELECT s.*, u.username FROM sessions AS s
                                LEFT JOIN users AS u ON u.id=s.user_id WHERE s.sid=?""",
        (cookie["reading_sid"].value,),
    ).fetchone()
    if row is None:
        return None
    if row["expires"] <= now:
        connection.execute("DELETE FROM sessions WHERE sid=?", (row["sid"],))
        return None
    return Session(
        row["sid"], row["csrf"], row["user_id"], row["username"], row["expires"]
    )

SimpleCookie 解释 Cookie 头。服务器按 reading_sid 找记录,找不到或已过期就不能从它取得登录身份。身份保存在服务器表里,修改浏览器中的用户名文本不会改变这次查询得到的 user_id。

第一次打开建立匿名会话s1,响应Set-Cookie保存编号,后续Cookie带回s1后再查服务器记录

3. 按 Cookie 字段理解浏览器行为

# 续写同一文件:web_project/src/webbook/security.py
# 把会话编号写入 HttpOnly Cookie;账户身份仍从服务器保存的会话中读取。
def session_cookie(session, now, secure=False):
    cookie = SimpleCookie()
    cookie["reading_sid"] = session.sid
    item = cookie["reading_sid"]
    item["path"], item["httponly"], item["samesite"] = "/", True, "Lax"
    item["max-age"] = max(0, session.expires - now)
    if secure:
        item["secure"] = True
    return "Set-Cookie", item.OutputString()


def require_user(session):
    if session.user_id is None:
        raise Problem(401, "先登录再访问书籍")

响应使用 Set-Cookie,后续请求使用 Cookie。Path 决定发送的路径范围,Max-Age 表示在浏览器保留的秒数;HttpOnly 阻止页面脚本通过 document.cookie 读取这个编号,浏览器仍能在请求中携带它。

SameSite=Lax 约束跨站请求携带 Cookie 的行为,Secure 则使 Cookie 随 HTTPS 连接发送。same-site 与后面按协议、主机、端口判断的 same-origin 含义不同;令牌与权限检查仍由服务器继续执行。Set-Cookie 字段

# 项目文件:demos/cookie_fields.py
# 此处固定文本只用于观察Cookie字段;真正会话编号由secrets随机生成。
from webbook.security import Session, session_cookie

session = Session("session-demo", "token-demo", None, None, 3700)
name, value = session_cookie(session, now=100)
print(name, value)
print("HTTPS标志", "Secure" in session_cookie(session, now=100, secure=True)[1])

示例只用固定编号观察这些头字段;实际应用中的编号来自上面的随机生成。Cookie 没有按端口隔离,同一主机的另一个端口也可能收到符合路径规则的 Cookie。

笔记正文与全部题解 · Ctrl / ⌘ K