使用 Go 构建安全会话管理器

网站和访客之间的互动是无状态的,意思是每次访客向服务器发送请求时,服务器都会独立处理这个请求。一旦服务器发送了响应,它就没有内置的方式来识别同一个访客是否再次发送了请求。
静态网站很好用,服务器只是单纯地把内容发给用户,不需要记住每个访问者是谁。因为这种交互是单向的,服务器不需要根据用户个性化地回复,也不需要保存关于用户的特定信息。
动态网站就不一样了,它们会根据每个访问者来个性化大部分,甚至是全部的内容。比如GitHub,你登录一次之后,GitHub就能认出你,每次你请求内容,它都会根据你的需求来调整回复和操作。

网站是怎么做到的呢?
你可能会想,网站是不是通过用户的IP地址来识别用户呢?但这不可靠。IP地址经常变,因为用户可能会切换网络,或者通过VPN上网。而且,很多人可能会共享同一个IP地址,比如在办公室、学校或者公共网络里。因为这些不一致的情况,只靠IP地址来识别用户是不行的。

所以,网站用cookie来识别和记住用户。Cookie是网站存储在用户浏览器里的一小段数据。当你登录一个网站时,服务器会发送一个独一无二的标识符作为cookie,浏览器之后每次向这个网站发送请求时,都会附带这个cookie。这样,服务器就能认出你,保持你的会话状态,并且根据你来个性化内容。

不过,cookie有两个主要的限制:

  1. 大小限制:cookie的最大大小是4KB,这限制了它能存储的数据量。
  2. 安全风险:因为cookie存储在用户的浏览器里,所以可能会被篡改甚至被偷走。

为了解决这些限制,网站通常会用服务器端会话。服务器不会把大量敏感数据直接存储在cookie里,而是为每个用户生成一个独一无二的随机会话ID。这个会话ID会存储在cookie里,然后发送到用户的浏览器。
每次浏览器发送请求时,都会附带这个会话ID,服务器根据这个ID从安全的服务器端存储系统里获取真正的会话数据。这个存储系统可以是数据库、像Redis或Memcached这样的内存数据存储系统,或者是基于文件的系统,具体取决于应用程序的需求。

为了解决这些限制,网站应用通常会使用服务器端会话。服务器不会在 cookie 中直接存储大量或敏感的数据,而是为每个用户生成一个唯一的、随机的会话 ID,并将这个会话 ID 存储在 cookie 中发送给用户的浏览器。
每次请求时,浏览器都会带上这个会话 ID,服务器就可以从安全的服务器端存储系统中检索实际的会话数据。这个存储系统可以是数据库、内存数据存储(如 Redis 或 Memcached),甚至是文件系统,具体取决于应用的需求。

基本会话操作
为了展示访客浏览器(客户端)和网站应用(服务器)之间的会话交换是如何工作的,我们来实现一个简单的会话管理器:

go
import (
    "crypto/rand"
    
"net/http"
)
var sessions = map[string]map[string]string{}
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cookie, _ := r.Cookie(
"session_id")
        if cookie != nil {
            session := sessions[cookie.Value]
        } else {
            session := map[string]string{}
            sessionId := rand.Text()
            sessions[sessionId] = session
            http.SetCookie(w, &http.Cookie{
                Name:  
"session_id",
                Value: sessionId,
            })
        }
        next.ServeHTTP(w, r)
    })
}


在这个例子中,我们使用一个 map 来存储所有会话,每个会话由一个随机的会话 ID 标识。在中间件中,我们检查传入的请求是否有会话 cookie。如果有,我们提取会话 ID 并从会话存储中检索对应的会话。如果没有找到 cookie,我们假设访客是新用户,为他们生成一个新的会话,并在响应中将会话 ID 作为 cookie 发送。

虽然这个例子展示了会话管理的完整概念,但它缺少一些使实现安全和可扩展的方面:

1. 会话永不过期:长期存在的会话增加了会话劫持的风险,因为攻击者一旦窃取了会话,就可以无限期使用它。此外,随着越来越多的访客使用网站应用,会话会不断累积,导致存储空间不足,未使用的会话会一直存在。
2. 会话 ID 生成不够强:如果会话 ID 容易猜测,攻击者可以通过暴力破解来冒充用户。例如,rand.Text() 函数生成一个 26 字符的 base32 字符串,攻击者可以系统地猜测会话 ID 并获得未授权访问。
3. 不安全的会话 cookie:如果 cookie 没有正确保护,攻击者可以通过跨站脚本(XSS)注入恶意 JavaScript 来读取或修改会话 cookie。此外,如果 cookie 没有限制为仅通过 HTTPS 传输,它们会以明文形式通过网络传输,容易受到中间人(MITM)攻击。
4. 没有防止跨站请求伪造(CSRF)的保护:如果 cookie 在每次请求时都会发送,无论请求来源如何,攻击者可以诱骗用户提交一个请求(例如通过恶意表单或脚本),自动包含他们的会话 cookie。由于浏览器不会验证请求的来源,服务器会将其视为来自合法用户的请求,允许攻击者执行未授权的操作。

除了这些问题,会话在中间件中确定后,并没有传递给请求链中的 HTTP 处理器。这使得会话对网站应用的业务逻辑不可访问。此外,由于会话存储在应用的内存中,当应用重启时,会话会丢失,导致数据丢失或用户需要重新登录。

构建安全的会话管理器
首先,我们为会话定义一个类型:

go
type Session struct {
    createdAt      time.Time
    lastActivityAt time.Time
    id             string
    data           map[string]any
}


这个会话类型包括两个字段,用于存储会话创建的时间和用户最后一次活动的时间。这些时间戳使我们能够根据用户不活动的时间或会话的总时长来使会话过期。
接下来,我们为会话存储介质定义一个接口:
go
type SessionStore interface {
    read(id string) (*Session, error)
    write(session *Session) error
    destroy(id string) error
    gc(idleExpiration, absoluteExpiration time.Duration) error
}

这个 SessionStore 接口可以由多个类型实现,这些类型管理不同存储介质中的会话。它定义了四个方法:
1. read:读取具有给定唯一 ID 的会话。
2. write:将会话写入存储引擎。
3. destroy:删除具有给定 ID 的会话。
4. gc:执行垃圾回收。它查询所有过期的会话并将其从存储中删除。


现在,我们定义一个 SessionManager 类型,用于协调所有会话操作:

go
type SessionManager struct {
    store              SessionStore
    idleExpiration     time.Duration
    absoluteExpiration time.Duration
    cookieName         string
}


SessionManager 应该在应用启动时实例化,并从环境变量或终端标志中读取配置选项。我们将在这个教程中使用一个最小化的设置,但你可以根据自己的需求调整实现,以控制更多方面。

生成会话 ID
根据 OWASP 的建议,会话标识符必须至少有 64 位的随机性。然而,在我开发的大多数应用中,建议使用 256 位的随机性,并将其编码为 base64 字符集,以增加安全性:

go
import (
    "crypto/rand"
    
"encoding/base64"
    
"io"
)
func generateSessionId() string {
    id := make([]byte, 32)
    _, err := io.ReadFull(rand.Reader, id)
    if err != nil {
        panic(
"failed to generate session id")
    }
    return base64.RawURLEncoding.EncodeToString(id)
}


在这里,我们首先创建一个 32 字节的缓冲区(32 × 8 = 256 位),并使用 crypto/rand 包填充随机数据。接下来,我们使用 base64.RawURLEncoding 将二进制数据编码为 URL 安全的字符串,去掉了特殊字符如 /+

最终的输出是一个 43 字符的 base64 字符串,包含字母(A-Z, a-z)、数字(0-9)、连字符(-)和下划线(_)。例如:


cOCMc1RV1m_uFZhkllsMLXPsJgFGc2YQ9GbdPy1hqHyA-


使用会话类型
为了创建一个新的会话,我们实现一个构造函数:

go
func newSession() *Session {
    return &Session{
        id:             generateSessionId(),
        data:           make(map[string]any),
        createdAt:      time.Now(),
        lastActivityAt: time.Now(),
    }
}

这个函数调用我们之前定义的 generateSessionId 函数来生成一个安全的会话 ID。它还设置了时间戳为当前系统时间,并初始化了用于存储会话数据的 map。
接下来,我们实现三个方法来读取和操作会话数据:

go
func (s *Session) Get(key string) any {
    return s.data[key]
}
func (s *Session) Put(key string, value any) {
    s.data[key] = value
}
func (s *Session) Delete(key string) {
    delete(s.data, key)
}


在这个教程中,我只实现了这三个方法,但你可以根据需要添加更多方法。此外,我的实现没有考虑并发安全性,因为会话默认与单个请求绑定。这意味着只有一个 goroutine 可以同时访问会话。然而,如果处理器产生了额外的 goroutine,我建议将会话数据作为原始值传递,而不是共享会话对象本身。这种方法简化了数据交换,并消除了对昂贵的互斥锁的需求。

会话管理器
为了在应用启动时创建一个新的会话管理器,我们实现一个构造函数:

go
func NewSessionManager(
    store SessionStore,
    gcInterval,
    idleExpiration,
    absoluteExpiration time.Duration,
    cookieName string) *SessionManager {
    m := &SessionManager{
        store:              store,
        idleExpiration:     idleExpiration,
        absoluteExpiration: absoluteExpiration,
        cookieName:         cookieName,
    }
    go m.gc(gcInterval)
    return m
}


这个构造函数做了两件事:
1. 创建一个 SessionManager 实例。
2. 在一个 goroutine 中调用 gc 方法,并传递 gcInterval 给它。


gc 方法使用一个定时器在固定间隔内运行垃圾回收。每次定时器触发时,它调用存储的 gc 方法,负责删除过期的会话:

go
func (m *SessionManager) gc(d time.Duration) {
    ticker := time.NewTicker(d)
    for range ticker.C {
        m.store.gc(m.idleExpiration, m.absoluteExpiration)
    }
}

接下来,我们实现一个 validate 方法,确保给定的会话是有效的:

go
func (m *SessionManager) validate(session *Session) bool {
    if time.Since(session.createdAt) > m.absoluteExpiration ||
        time.Since(session.lastActivityAt) > m.idleExpiration {
        
        // 从存储中删除会话
        err := m.store.destroy(session.id)
        if err != nil {
            panic(err)
        }
        return false
    }
    return true
}

这个方法检查会话是否已过期,并返回 truefalse。如果会话已过期,我们从会话存储中删除它。

接下来,我们实现一个 start 方法,通过读取会话 cookie 或生成一个新的会话来检索会话。然后,它将会话附加到请求中,使用上下文值:

go
func (m *SessionManager) start(r *http.Request) (*Session, *http.Request) {
    var session *Session
    // 从 cookie 中读取
    cookie, err := r.Cookie(m.cookieName)
    if err == nil {
        session, err = m.store.read(cookie.Value)
        if err != nil {
            log.Printf(
"Failed to read session from store: %v", err)
        }
    }
   
// 生成新会话
    if session == nil || !m.validate(session) {
        session = newSession()
    }
   
// 将会话附加到上下文
    ctx := context.WithValue(r.Context(), sessionContextKey{}, session)
    r = r.WithContext(ctx)
    return session, r
}

我们还添加了另一个方法,在更新 lastActivityAt 字段后将会话保存到存储中:

go
func (m *SessionManager) save(session *Session) error {
    session.lastActivityAt = time.Now()
    err := m.store.write(session)
    if err != nil {
        return err
    }
    return nil
}

最后,我们添加一个 migrate 方法,删除现有会话并创建一个具有新 ID 的会话:

go
func (m *SessionManager) migrate(session *Session) error {
    session.mu.Lock()
    defer session.mu.Unlock()
    err := m.store.destroy(session.id)
    if err != nil {
        return err
    }
    session.id = generateSessionId()
    return nil
}


这个方法应该在用户的权限级别在会话期间发生变化时调用。例如,当访客用户登录时,调用 migrate 删除旧会话并创建一个具有新 ID 的会话。这可以防止会话劫持或会话固定等安全风险,确保任何先前被泄露的会话 ID 失效,使其对攻击者无用。

会话中间件
有了我们的会话管理器,我们现在可以实现一个中间件,高效地处理会话管理。这个中间件将:
1. 从会话 cookie 中检索会话 ID。
2. 从存储中加载对应的会话数据。
3. 验证会话以确保它仍然有效。
4. 将有效会话附加到请求上下文中。
5. 覆盖响应写入器以跟踪写入,并确保会话 cookie 正确包含在响应中。

为了添加中间件,我们将在会话管理器中添加一个 Handle 方法:

go
func (m *SessionManager) Handle(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        
    })
}


这个方法接受一个 http.Handler 并返回一个修改后的处理器。

在中间件处理器中,代码看起来像这样:

``go
// 启动会话
session, rws := m.start(r)
// 创建一个新的响应写入器
sw := &sessionResponseWriter{
    ResponseWriter: w,
    sessionManager: m,
    request:        rws,
}
// 添加必要的头信息
w.Header().Add(
"Vary", "Cookie")
w.Header().Add(
"Cache-Control",
no-cache="Set-Cookie")
// 调用下一个处理器并传递新的响应写入器和新的请求
next.ServeHTTP(sw, rws)
// 保存会话
m.save(session)
// 如果尚未写入,将会话 cookie 写入响应
writeCookieIfNecessary(sw)
`

第一步是调用 start 方法,它要么从传入的请求 cookie 中检索会话,要么生成一个新的会话(如果没有找到)。然后,它将会话附加到请求上下文中,以便在整个请求生命周期中使用。接着,我们创建一个自定义的响应写入器,稍后会讨论。

接下来,我们设置 Vary 头为 Cookie,并将 Cache-Control 头设置为 no-cache="Set-Cookie"

  • - Vary: Cookie 头确保缓存(如 CDN 或浏览器缓存)根据 Cookie 头的存在或值区分响应。这可以防止将一个用户的缓存响应提供给另一个用户。
  • - Cache-Control: no-cache="Set-Cookie" 指令指示缓存不要存储包含 Set-Cookie 头的响应,确保会话相关的头信息总是从服务器新鲜获取,而不是从缓存中读取。

这些头信息有助于维护正确的会话处理,并防止缓存问题导致显示错误的用户数据。

在方法的最后几步中,我们调用链中的下一个处理器,处理完请求后保存会话,并确保如果尚未设置 cookie,则将其写入响应。

自定义响应写入器
自定义的
sessionResponseWriter 的主要作用是确保会话 cookie 在响应体和状态码写入之前被写入响应。如果没有这个,cookie 会被忽略,导致用户每次请求都会收到一个新的会话。
我们将这个自定义写入器传递给
next.ServeHTTP,允许链中的下一个处理器使用它来写入响应。

会话响应写入器如下:

go
type sessionResponseWriter struct {
    http.ResponseWriter
    sessionManager *SessionManager
    request        *http.Request
    done           bool
}
func (w *sessionResponseWriter) Write(b []byte) (int, error) {
    writeCookieIfNecessary(w)
    return w.ResponseWriter.Write(b)
}
func (w *sessionResponseWriter) WriteHeader(code int) {
    writeCookieIfNecessary(w)
    w.ResponseWriter.WriteHeader(code)
}
func (w *sessionResponseWriter) Unwrap() http.ResponseWriter {
    return w.ResponseWriter
}


我们重写了
Write 方法(在写入响应体时调用)和 WriteHeader 方法(设置响应状态码时调用)。在每个方法中,在执行嵌入的 http.ResponseWriter 的实际操作之前,我们首先调用 writeCookieIfNecessary 以确保 cookie 被写入。

我们还定义了一个 Unwrap 方法,返回原始的响应写入器。这个方法由 http.ResponseController 类型使用,用于检索底层写入器,以便调用 FlushHijackSetReadDeadlineSetWriteDeadlineEnableFullDuplex 等方法。

将 cookie 添加到响应
writeCookieIfNecessary 函数如下:

go
func writeCookieIfNecessary(w *sessionResponseWriter) {
    if w.done {
        return
    }
    session, ok := r.Context().Value(sessionContextKey{}).(*Session)
    if !ok {
        panic("session not found in request context")
    }
    cookie := &http.Cookie{
        Name:     w.sessionManager.cookieName,
        Value:    session.id,
        Domain:  
"mywebsite.com",
        HttpOnly: true,
        Path:    
"/",
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        Expires:  time.Now().Add(w.sessionManager.idleExpiration),
        MaxAge:   int(w.sessionManager.idleExpiration / time.Second),
    }
    http.SetCookie(w.ResponseWriter, cookie)
    w.done = true
}


它从请求上下文中检索会话,生成一个安全的 cookie,并将其写入响应。
done 标志确保即使在同一请求中多次调用此函数,cookie 也只写入一次。
这个函数创建的 cookie 有几个安全含义:
  • - HttpOnly: true:防止 JavaScript 访问 cookie,减少跨站脚本(XSS)攻击的风险。
  • - Secure: true:确保 cookie 仅通过 HTTPS 传输,防止中间人(MITM)攻击。
  • - SameSite: Lax:防止 cookie 在大多数跨站请求中发送,减少跨站请求伪造(CSRF)风险,同时仍允许一些基于导航的请求(例如从另一个站点点击链接)。
  • - Domain: "mywebsite.com":将 cookie 限制在此特定域名,防止其发送到未经授权的站点。
  • - Path: "/":允许 cookie 在域名的所有路径中发送,使其可以在网站的不同部分访问。
  • - Expires:根据会话空闲超时设置过期时间。
  • - MaxAge:根据会话空闲超时设置过期时间。

由于 cookie 包含在每个响应中,其过期时间会随着每次用户请求而刷新。然而,如果用户在一段时间内不活动,cookie 将过期,会话管理器将在他们下次请求时生成一个新的会话。

从请求中提取会话
在我们的应用代码中,我们经常需要从请求上下文中检索会话。为了简化这一点,我们定义一个辅助函数:

go
func GetSession(r *http.Request) *Session {
    session, ok := r.Context().Value(sessionContextKey{}).(*Session)
    if !ok {
        panic("session not found in request context")
    }
    return session
}

实现内存中的会话存储
为了让你了解会话存储实现的样子,我们构建一个内存中的会话存储,将会话保存在本地 map 中:

go
type InMemorySessionStore struct {
    mu       sync.RWMutex
    sessions map[string]*Session
}
func NewInMemorySessionStore() *InMemorySessionStore {
    return &InMemorySessionStore{
        sessions: make(map[string]*Session),
    }
}

接口方法可以实现如下:

go
func (s *InMemorySessionStore) read(id string) (*Session, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    session, _ := s.sessions[id]
    return session, nil
}
func (s *InMemorySessionStore) write(session *Session) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.sessions[session.id] = session
    return nil
}
func (s *InMemorySessionStore) destroy(id string) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    delete(s.sessions, id)
    return nil
}
func (s *InMemorySessionStore) gc(idleExpiration, absoluteExpiration time.Duration) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    for id, session := range s.sessions {
        if time.Since(session.lastActivityAt) > idleExpiration ||
            time.Since(session.createdAt) > absoluteExpiration {
            delete(s.sessions, id)
        }
    }
    return nil
}


虽然这个会话存储功能齐全,但它不适合生产环境。由于会话存储在应用的内存中,每当应用重启时,会话都会丢失。这可能导致数据丢失、强制用户重新登录,以及糟糕的用户体验。此外,随着活动会话数量的增加,将它们存储在内存中可能导致高内存使用、可扩展性问题以及潜在的性能瓶颈。

对于生产环境,更健壮的方法是使用持久化会话存储,如数据库(PostgreSQL、MySQL)、内存数据存储(Redis、Memcached)或分布式会话管理系统。这些选项提供了更好的可靠性、可扩展性和弹性,确保会话在应用重启后仍然存在,并且可以在负载均衡环境中跨多个实例高效管理。

使用会话管理器
在我们的应用启动期间,我们通过调用其构造函数创建一个新的
SessionManager 实例。如果我们的会话包名为 sess,我们可以这样调用构造函数:

go
func main() {
    sessionManager := sess.NewSessionManager(
        sess.NewInMemorySessionStore(),
        30 * time.Minute,
        1 * time.Hour,
        12 * time.Hour,
        "session",
    )
}


在这里,我们配置了一个内存中的会话存储,并设置了以下选项:
  • - 垃圾回收每 30 分钟运行一次,以删除过期的会话。
  • - 会话空闲超时设置为 1 小时,意味着如果会话在此时间内不活动,则会过期。
  • - 会话绝对生命周期设置为 12 小时,确保会话无论是否活动都会在此时间后失效。
  • - cookie 名称设置为 "session",用于在客户端浏览器中存储会话 ID。

然后,我们在服务器的多路复用器上调用会话管理器的
Handle 方法:
go
server := &http.Server{
    Addr:    ":8080",
    Handler: sessionManager.Handle(mux),
// 这里
}
server.ListenAndServe()


在我们的处理器中,我们可以使用
sess.GetSession` 函数从请求中检索会话实例,并根据需要读取或修改其数据:
go
mux.HandleFunc("/projects/switch/some-project-id", func(w http.ResponseWriter, r *http.Request) {
    session := sess.GetSession(r)
    
    session.Put(
"current_project", "some-project-id")
})
mux.HandleFunc(
"/project", func(w http.ResponseWriter, r *http.Request) {
    session := sess.GetSession(r)
    
    currentProject := session.Get(
"current_project")
})

总结
在这篇文章中,我们探讨了会话管理的基础知识,并构建了一个符合开放网络应用安全项目(OWASP)安全建议的会话管理器。

虽然我们的会话管理器遵循最佳实践,但需要采取额外措施来全面防止跨站点请求伪造(CSRF)攻击。

会话Session=上下文Context