(十)用 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 sessionSession 保存编号、对应用户、过期时间与另一个用于写入核对的 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。
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。