build-web-application-with-golang 第 6.1 节精读:Cookie 与 Session 原理及 Go 实战
build-web-application-with-golang 第 6.1 节精读:Cookie 与 Session 原理及 Go 实战
导读
HTTP 是无状态协议,服务器无法天然记住"上一个请求是否已通过登录验证"。本篇文章基于开源项目 build-web-application-with-golang 的第 6.1 节(pt-br/06.1.md),系统讲解 Web 开发中最基础也最容易混淆的两个概念——Cookie 与 Session:它们分别解决了什么、如何配合、在 Go 的 net/http 标准库中如何设置与读取,以及它们各自的安全弱点。读完后你将掌握:用 http.SetCookie 下发 Cookie、用 r.Cookie / r.Cookies 读取 Cookie,并理解 Session 为何必须以"唯一且随机"的 session id 作为客户端标识,为后续章节实现自己的 Session 管理器打下基础。
HTTP 的无状态困境:为什么需要 Cookie 与 Session
设想你要用程序(而不是人肉浏览器)爬取一个需要登录才能访问的页面,例如某个 Twitter 用户的主页。浏览器里你只需输入用户名和密码,点击"登录",浏览器就会向服务器发送 POST 请求;服务器校验通过后返回响应并跳转到用户主页。这里的关键问题是:服务器如何知道你现在有权限访问这个页面?
因为 HTTP 是无状态的,服务器无法得知你在"上一步"是否通过了校验。原文档给出了两种朴素思路:
- 把用户名和密码拼进 URL——虽然可行,但服务器必须在每次请求时都到数据库里重新校验一次,压力巨大,用户体验也很差;
- 借助 Cookie 和 Session 保存用户身份——把身份信息保存在服务端或客户端,这正是现代 Web 的标准做法。
第 6 章开篇(pt-br/06.0.md)进一步点明了全章脉络:Cookie 是客户端侧机制,Session 保存在服务端并给每个用户一个唯一标识;session id 可以通过 URL、Cookie 甚至数据库传递(存数据库更安全,但可能拖累性能)。
Cookie:存储在客户端的"历史信息"
Cookie 的要点可以概括为一句话:把历史信息(包括用户登录信息)保存在客户端的计算机上。浏览器在每次访问同一网站时自动携带这些 Cookie,从而替用户自动完成"登录"这一步。
下图展示了 Cookie 的工作原理:客户端第一次请求时,服务器页面 1 为客户端设置 Cookie 值;此后客户端再次访问服务器页面 2 时,请求会自动带上该 Cookie,服务器据此识别客户端身份。
Cookie 的生命周期:会话 Cookie 与持久化 Cookie
Cookie 都有过期时间,依据生命周期可以分成两类:
- 会话 Cookie(session cookies):应用不设置过期时间时,浏览器关闭后不会把 Cookie 写入本地文件系统,而是保存在内存中。这类 Cookie 随浏览器进程存活而存在,关闭即失效。
- 持久化 Cookie(persistent cookies):应用设置了过期时间(例如
setMaxAge(60*60*24),在 Go 中对应Cookie.MaxAge字段),浏览器会把 Cookie 写入本地文件系统,直到到达设定的过期时间才删除。
两者还有一个差异值得注意:写入本地文件系统的 Cookie 可以被不同的浏览器进程共享(例如两个 IE 窗口);而保存在内存中的 Cookie 由各个浏览器进程各自处理,互不共享。你可以在浏览器的 Cookie 管理界面中查看这些已保存的 Cookie 数据:
Session:存储在服务端的"会话状态"
Session 与 Cookie 相对,把历史信息保存在服务端。它的核心机制是:
- 服务器为每个客户端生成一个 session id,用它来标识不同的会话;
- 这个 session id 必须始终是随机且唯一的;
- 客户端通过 Cookie 或 URL 参数把自己的身份(session id)带给服务器。
下图展示了 Session 的原理:客户端首次访问时,服务器建立 session id 与会话数据的关联(步骤 1);后续请求携带 session id,服务器据此找到对应的会话数据完成交互(步骤 2、3)。会话数据留在服务端,客户端只保存一个"钥匙"。
从会话管理的视角看,Session 是一系列动作或消息的集合(可以类比"拿起电话到挂断电话"之间的一通电话),在网络协议语境下更多指浏览器与服务器之间的连接状态。Session 是服务端机制,通常用哈希表(或类似数据结构)保存到达的信息。当应用需要为客户端分配新 Session 时,服务器会先检查该客户端是否已存在唯一的 session id:
- 已存在:直接把同一个 Session 返回给客户端;
- 不存在:创建全新的 Session(这种情况通常发生在服务端已删除对应 session id、而用户手动把旧的 session id 又带了回来的场景)。
原文档特别强调:"Session 本身并不复杂,但它的实现与部署很复杂,不存在'一招通吃'的方案。"这也是后续章节要自研 Session 管理器的原因——Go 标准库目前并没有内置 Session 支持。
在 Go 中设置 Cookie
Go 通过 net/http 包中的 SetCookie 函数下发 Cookie:
http.SetCookie(w ResponseWriter, cookie *Cookie)
其中 w 是当前请求的响应,cookie 是一个 Cookie 结构体。该结构体的完整定义如下(字段含义即注释所写):
type Cookie struct {
Name string
Value string
Path string
Domain string
Expires time.Time
RawExpires string
// MaxAge=0 means no 'Max-Age' attribute specified.
// MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
// MaxAge>0 means Max-Age attribute present and given in seconds
MaxAge int
Secure bool
HttpOnly bool
Raw string
Unparsed []string // Raw text of unparsed attribute-value pairs
}
各字段的作用可以归纳为:
Name/Value:Cookie 的键值对,是最核心的信息载体;Path/Domain:限定 Cookie 的生效范围(哪些路径、哪些域名下的请求会携带它);Expires/MaxAge:控制过期时机。MaxAge语义特殊:等于 0 表示不指定Max-Age属性;小于 0 表示立即删除(等价于Max-Age: 0);大于 0 表示Max-Age属性存在,单位是秒;Secure:为 true 时 Cookie 仅在 HTTPS 连接中传输;HttpOnly:为 true 时禁止客户端脚本(JavaScript)通过document.cookie读取该 Cookie,是防御 XSS 窃取会话的关键开关(后续 6.4 节会重点用到)。
设置一个有效期为 1 年的 Cookie 的完整示例:
expiration := time.Now().Add(365 * 24 * time.Hour)
cookie := http.Cookie{Name: "username", Value: "astaxie", Expires: expiration}
http.SetCookie(w, &cookie)
这段代码会通过响应头的 Set-Cookie 字段把名为 username、值为 astaxie 的 Cookie 下发给浏览器。
在 Go 中获取 Cookie
读取客户端携带过来的 Cookie 同样简单。按名字取单个 Cookie:
cookie, _ := r.Cookie("username")
fmt.Fprint(w, cookie)
或者遍历请求中的所有 Cookie,打印每个 Cookie 的名字:
for _, cookie := range r.Cookies() {
fmt.Fprint(w, cookie.Name)
}
r.Cookie(name) 在找不到对应 Cookie 时会返回 http.ErrNoCookie 错误(上面的示例忽略了错误处理,实际代码中建议显式判断);r.Cookies() 则返回本次请求携带的全部 *http.Cookie 切片。两个 API 都位于 net/http 标准库的 Request 类型上,使用起来非常便捷。
Session 与 Cookie 的差异及安全风险
原文档在 Summary 中给出了二者的本质对比:
| 对比项 | Cookie | Session |
|---|---|---|
| 存储位置 | 客户端(浏览器) | 服务端(内存、文件或数据库) |
| 客户端保存内容 | 全部信息(含用户名、密码等) | 仅一个 session id(通常放在 Cookie 或 URL 中) |
| 解决的核心问题 | 克服 HTTP 无状态 | 克服 HTTP 无状态 |
| 安全弱点 | 信息明文暴露在客户端,易被窃取 | 依赖 session id 的保密性与随机性 |
一句话概括:两者目的相同(都是为了克服 HTTP 的无状态性),但实现方式不同——Session 用 Cookie 在客户端保存 session id,把其余所有信息留在服务端;Cookie 则把所有客户端信息都保存在客户端。
由于 Cookie 明文保存在客户端,它天然存在安全隐患:用户名和密码等信息可能被恶意第三方网站破解并收集。原文档列举了两个常见的攻击场景:
- 跨站设置 Cookie:appA 给 appB 设置一个意想不到的 Cookie;
- XSS 攻击:appA 利用 JavaScript 的
document.cookie读取 appB 的 Cookie。
这正是为什么会话类敏感信息应放入服务端 Session,且会话 Cookie 必须开启 HttpOnly 并保证 session id 随机性的原因。
从本节到 Session 管理器:仓库中的下一步
6.1 节只解决了"概念"问题,动手实现则从下一节开始。仓库中 pt-br/06.2.md 会基于本节的设计思想,手把手实现一个 Go Session 管理器,其中与本节点题直接相关的设计包括:
Provider接口:抽象 Session 的底层存储(内存、文件、数据库),定义SessionInit、SessionRead、SessionDestroy、SessionGC四个方法;Session接口:只保留四个操作——Set(设值)、Get(取值)、Delete(删值)、SessionID()(取当前 session id);- 唯一 session id 的生成:用
crypto/rand读取 32 字节随机数再经base64.URLEncoding编码,保证随机且唯一; SessionStart的"先查后建"逻辑:先尝试从请求 Cookie 中读取 session id(对应本节的r.Cookie),没有则生成新 id 并通过http.SetCookie下发(对应本节的http.SetCookie),与 6.1 节的两个 API 形成完整的实战闭环;SessionDestroy的登出清理:销毁服务端会话的同时,用MaxAge: -1的 Cookie 让浏览器立即删除会话 Cookie,正是本节 Cookie 结构体中MaxAge<0语义的实际运用。
再往后,pt-br/06.4.md 专门讨论会话劫持防护:仅允许 session id 走 Cookie(不用 URL 重写)、开启 HttpOnly 阻断 XSS 读取、为每个请求附加 token 校验、以及定期更换 session id 并设置超时。这些防护措施的技术根基,都在本节的 Cookie 结构体字段与 Session 机制之中。
小结
经过本节的学习,你应当掌握:Cookie 是存储在客户端的历史信息载体,按是否设置过期时间可分为会话 Cookie 与持久化 Cookie;Session 是存储在服务端的会话状态,靠随机且唯一的 session id 与客户端建立关联;在 Go 中通过 http.SetCookie 设置 Cookie、通过 r.Cookie / r.Cookies 读取 Cookie;同时理解二者目的相同而实现不同,并清楚 Cookie 明文存储带来的两类常见攻击。接下来就可以进入 pt-br/06.2.md,用 Go 从零实现一个属于自己的 Session 管理器。


