Cookie 是浏览器的存储与传输机制。服务端会话则是一种应用设计,用 Cookie 的值查找服务器保存的状态。分开这两种责任,登录、过期和退出才容易验证。本文沿着不透明会话标识的生命周期展开,不再重复主机、路径或 SameSite 匹配规则;这些发送条件不能回答旧会话是否已经真正失效。

持有标识仍可能获得权限

在这种设计中,服务器生成不可预测的标识,并把它关联到一条会话记录。客户端不需要理解该值,但持有它仍可能获得访问资格:窃取有效 bearer 标识的人,可以尝试直接重放。称它“只是指针”不能让读者以为它无害。不要把它放入 URL、日志、诊断截图或支持材料中,随机字符串也可能是实际凭据。

不能直接信任客户端控制的明文角色字段。不过,Cookie 中含有有意义的数据,也不等于必然可以伪造:经过正确认证和签名的会话格式属于另一种设计。签名保护完整性,不提供保密性;无状态方案仍须明确过期、密钥管理与撤销策略。应选择维护良好的会话框架,不要仅为了省一次服务端查询就自行发明格式。

不透明方案需要使用密码学安全随机生成器和足够熵值。未知标识应被拒绝,而不能用客户端任意指定的值建立特权状态。服务器解析会话后,还须根据当前权限授权每项操作。某个会话已经认证,不代表它可以读取每一个对象、操作每一个账号,或自动获得所有管理功能;会话存在与具体动作获准是两项检查。

两种寿命需要两处控制

Cookie 的 Max-Age 与 Expires 管理客户端存储,不会自动使服务端会话记录过期。非浏览器客户端可以保留这个值,并在浏览器本该删除后继续重放。浏览器会话 Cookie 也可能通过会话恢复机制保留。因此必须在服务端强制执行绝对期限与空闲期限,使用明确的边界比较和一致的时间来源,而不能靠标签页里的倒计时作为安全判断。

退出时既要作废服务端状态,也要按匹配的作用域清除浏览器 Cookie,否则复制出来的令牌仍可能有效。认证后与权限变化后轮换标识,有助于防止会话固定,但前提是旧标识确实失去权限。并发请求和分布式存储需要额外协调,避免一个较晚完成的旧请求悄悄恢复已经撤销的会话;只覆盖单进程正常路径远远不够。

Secure 与 HttpOnly 分别约束传输和脚本读取;HttpOnly 不会阻止注入脚本发起认证请求。SameSite 是浏览器语境防御,不是服务端过期或权限检查。Cookie 与请求头限制也因浏览器和服务器而异,并不存在普遍适用的八千字节请求头上限。保持标识紧凑可以减少开销,但不能把体积当成判断会话是否安全的唯一依据。

坏掉的样子

登录时升级了认证前记录,却保留攻击者已经知道的标识;退出时只删除 Cookie,却让复制的值继续有效;空闲超时只由 JavaScript 检查,直接客户端仍能访问。这些都是服务端生命周期故障,即使开发工具中的 Cookie 标记完全正确,也不能弥补这些逻辑问题。排查时要重放旧的虚构标识,而不只是确认响应发出了哪些属性。

不要假定定期活动就应该无限延长访问。空闲计时可以随接受的活动滑动,而同一个会话的绝对截止时间仍然固定。是否在重新认证或提权时开始新的生命周期,应由明确策略决定。尤其要检查令牌刷新是否意外不断重置原本的硬期限,否则名义上的绝对过期可能在活跃客户端面前永远不会到来。

快速验证

将代码保存为 session-lifecycle.py,使用 Python 3.11 或更新版本执行 python3 session-lifecycle.py。它使用虚构用户、可预测的夹具标识与传入的时间值,不打开套接字,也不提供任何真实认证服务。绝不能把这个标识生成器或内存模型用于真实应用;可预测取值只是为了让本地断言清楚可复现。

from itertools import count

# Predictable identifiers are deliberate fixtures, never production tokens.
identifiers = count(1)
sessions = {}
IDLE = 30
ABSOLUTE = 100


def issue(user, role, now):
    sid = f"fixture-{next(identifiers)}"
    sessions[sid] = {"user": user, "role": role, "born": now, "last": now}
    return sid


def lookup(sid, now):
    session = sessions.get(sid)
    if session is None:
        return None
    if now - session["born"] >= ABSOLUTE or now - session["last"] >= IDLE:
        sessions.pop(sid)
        return None
    session["last"] = now
    return session.copy()


def rotate(sid, user, role, now):
    assert lookup(sid, now) is not None
    sessions.pop(sid)
    return issue(user, role, now)


def logout(sid):
    sessions.pop(sid, None)


assert lookup("attacker-chosen", 0) is None
anonymous = issue(None, "anonymous", 0)
authenticated = rotate(anonymous, "demo-user", "reader", 1)
assert authenticated != anonymous and lookup(anonymous, 1) is None
privileged = rotate(authenticated, "demo-user", "editor", 2)
assert lookup(authenticated, 2) is None
assert lookup(privileged, 2)["role"] == "editor"
logout(privileged)
assert lookup(privileged, 3) is None
print("PASS: unknown ID, login rotation, privilege rotation, and logout")

idle = issue("demo-user", "reader", 0)
assert lookup(idle, 20) is not None
assert lookup(idle, 50) is None
active = issue("demo-user", "reader", 0)
for now in (20, 40, 60, 80, 99):
    assert lookup(active, now) is not None
assert lookup(active, 100) is None
assert not sessions
print("PASS: idle and absolute expiry reject at the configured boundary")

第一行确认未知标识被拒绝、登录和提权后得到新标识、被替换的标识不能再使用,以及退出使记录失效。轮换在这个夹具里明确开始新的寿命,策略可直接从 issue 函数看出来。第二行验证空闲三十个时间单位和绝对一百个时间单位的精确边界;即使绝对期限前刚刚有活动,到期仍然被拒绝,不会因为刷新空闲时间而继续生效。

这些断言验证的是生命周期逻辑,不是随机熵、浏览器行为、持久存储、并发轮换或分布式撤销。生产验收应通过真实应用,在登录、提权、退出和超时之后分别重放旧的虚构会话,既检查请求被拒绝,也检查没有产生业务副作用。仅看到删除 Cookie 的响应成功,不能证明旧凭据已经失效,更不能证明所有服务实例都同步采用了撤销结果。

延伸阅读

来源