Go Web 模块 Session

209 阅读17分钟

一. Session 概述

HTTP 协议是无状态的。即每一个 HTTP 请求都是独立的,可以认为相互之间没有任何关系。但实际上在业务中,经常需要将某些请求归并为一组。最为直观的例子就是登录状态。一些资源是只有登录之后才可以访问的,所以一个 HTTP 请求过来,就要带上登录后的身份标识。进一步说,在登录之后,除了身份标识,还需要临时存放一些和用户相关的数据。这些东西就被合称为 Session,会话。

image.png

image.png

二. Session ID

核心就是让用户在 HTTP 请求里面带上这种凭证。这个凭证也叫做 session id。输入可能来自 HTTP 协议的各种部分,那么 session id 也可以放在这些位置:

  • Cookie:也是最为常见的方案。每次发送请求到同一个域名之下,Cookie 就会被浏览器自动带上。如果跨越了域名,Cookie 就不一定生效。
  • HTTP Header:部分情况下,如果用户禁用了 Cookie,那么我们可以考虑放到 Header 里面。
  • HTTP URL:在 URL 后面附上一个参数,例如sessid=xxx。
  • HTTP Body:理论上也可以放在这里,但目前没怎么见过。

image.png

GitHub 上的 Session 设置,利用的是 Cookie。这是使用 Cookie-Editor 调出来看的。

三. 各框架 Session 的设计

3.1 Beego Session 设计

image.png

image.png

image.png

image.png Controller 内置了 Session 的操作,也是一种侵入式的设计。

1. 使用例子

type MainController struct {
   web.Controller
}

func (ctrl *MainController) PutSession() {
   // put something into session
   ctrl.SetSession("name", "web session")

   // web-example/views/hello_world.html
   ctrl.TplName = "hello_world.html"
   ctrl.Data["name"] = "PutSession"
   _ = ctrl.Render()
}

func (ctrl *MainController) ReadSession() {
   // web-example/views/hello_world.html
   ctrl.TplName = "hello_world.html"
   ctrl.Data["name"] = ctrl.GetSession("name")
   // don't forget this
   _ = ctrl.Render()
}

func (ctrl *MainController) DeleteSession() {
   // delete session all
   ctrl.DestroySession()
   // web-example/views/hello_world.html
   ctrl.TplName = "hello_world.html"
   _ = ctrl.Render()
}

2. Store 抽象

image.png

StoreSession 中数据存储在哪里的抽象,例如基于 Redis 的实现 Session 的存储。

3. Provider 抽象

image.png

Provider 实际上是管理 Session 本身的抽象。一般来说 StoreProvider 都是成对出现的。

4. 总结

image.png

3.2 Gin Session 设计

image.png

image.png

Gin 的 Session 是完全被拆出去作为一个独立模块的,连代码仓库都不一样:github.com/gin-contrib… 它通过嵌入一个 Middleware 来嵌入到请求处理过程中。这种完全无侵入式的方案,就是最为完美的方案。它体现了设计者认为 Session 本身就不应该归属到 Web 核心里面,撑死了算是一个扩展。

1. 使用例子

func TestGinSession(t *testing.T) {
   r := gin.Default()
   store := cookie.NewStore([]byte("secret"))
   r.Use(sessions.Sessions("mysession", store))

   r.GET("/hello", func(c *gin.Context) {
      session := sessions.Default(c)

      if session.Get("hello") != "world" {
         session.Set("hello", "world")
         session.Save()
      }

      c.JSON(200, gin.H{"hello": session.Get("hello")})
   })
   r.Run(":8000")
}

2. session Middleware

image.png

这个 Middleware 其实就是设置了一下 Session,操作非常轻量。

3. Session 接口

image.png

image.png

Session 大体上分成两部分

  • Get、Set 和 Delete: 这种操作键值对
  • AddFlash 和 Flashes:操作 Flash 的

Flash,就是指存储之后,只能被取出来一次的数据。也就是说某些数据用过了之后就不能再次使用。从个人的观点来看,Session 和 Flash 是两种不同的东西,不能将 Flash 看做是一种一次性的临时的 Session 的键值对。也就是说,应该把Flash 和 Session 看成同等级的抽象。

4. session 结构体

image.png

session 结构体实现了 Session 接口,它封装了 sessions.Session。但是,这个 sessions.Session 并不是 Gin 的Session,而是另外一个包的 Session:github.com/gorilla/sessions ,即 gollira Session。

gollira Session:

image.png

Gin 中 Session 也可以看做是对 gollira Session 的封装。gollira 采用了一种刷新的设计策略。即数据最开始存放在内存,也就是 Values 里面,后面再调用Save 来真的刷新到存储里面,比如说刷新到 Redis里面。

gollira Store:

image.png

它实际上承担了真正的存储数据的职责,比如将 session 存到 Redis、存到 MySQL 之类的。另外一方面,从它接收 http.Request 和http.ResponseWriter 来看,它也承担了 session id 存放和提取的职责

5. 总结

image.png

image.png

image.png

image.png

Gin 抽象了 Session 和 Store 两个概念。同时提供了 Middleware 往 Context 里面注入 Session 的功能,用户也能从 Context 里面把 Session 提取出来。Context 就是作为一个中介。

3.3 Echo Session 设计

image.png

image.png

和 Gin 半斤八两。机制基本一样,利用 Middleware 注入 Store,并且在请求进来的时候就把 Session 放到 Context 里面。然后在业务代码里尝试把Session 拿出来,并且从 Session 里读写值

1. 使用例子

func TestSession(t *testing.T)  {
   e := echo.New()
   e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))

   e.GET("/", func(c echo.Context) error {
      sess, _ := session.Get("session", c)
      sess.Options = &sessions.Options{
         Path:     "/",
         MaxAge:   86400 * 7,
         HttpOnly: true,
      }
      sess.Values["foo"] = "bar"
      sess.Save(c.Request(), c.Response())
      return c.NoContent(http.StatusOK)
   })
}

3.4 Iris Session 设计

1. Session

image.png

  • sid:也就是 session id
  • flashes:存放的是 Flash 信息
  • provider: 是一个关键的设计,它管理的是 Session 本身

2. provider

image.png

provider 也是一个比较常规设计: sessions:是在内存里放置的 Session 实例 db:是存储 Session 数据的地方。相当于 sessions 字段放的是皮,db 里面才是用户真的存储数据的地方

3. Database

image.png

image.png

Database 是真实存放数据的地方。Iris 提供了几个实现,比如说基于 Redis 的设计。大体上,可以依旧认为这是一个过度设计的产物。因为这里面的很多方法,其实不能说是核心方法,只能说是不同用户的个性需求,或者部分场景下的特殊需求。真正的核心方法应该就是 Set 和 Get 两个

4. LifeTime

image.png

image.png

LifeTime 是用来管理 Session 生命周期的。妥妥的过度设计产物。对于大多数 Session 来说,无非就是创建、销毁,以及续命(延长过期时间,或者说刷新)

为什么说 Iris 是过度设计呢,就是因为它构建的抽象复杂庞大,但本身大部分人是用不到的。相当于,里面90% 的代码是 90% 的用户根本用不上的。将个性需求做成共性(也就是抽象出来),是不值 得学习的。

5. 总结

image.png

  • 一个直接为用户提供接口的 Session 结构体
  • 一个控制 Session 创建和销毁的 provider
  • 一个控制 Session 过期时间的 LifeTime
  • 一个真实存储 Session 数据的 Database

它的设计是一种非常侵入式的设计,就是直接把 Session 做成了 Web 框架的一部分,还是那种偏核心的部分。

四. 自定义的 Session 设计

首先要搞清楚一个问题:Session 究竟是不是 Web 框架要解决的核心问题

  • 是:那么可以尝试采用侵入式的设计
  • 否:那么就不能和 Web 框架主体耦合

可以参考前面的例子:

  • Beego:是,在它的核心里面有很多和 Session 处理有关的代码
  • Gin:不是,它只是借用了 Middleware 来将 Session 注入到了 Context 里面

image.png

基本上,我个人认为 SessionWeb 应用的一部分,但不是 Web 框架的一部分。也就是说,构建面向真实用户的 Web 应用的时候,确实需要 Session;但是这个职责不应该归属于 Web 框架,而应该是一个完全独立的东西。Web 框架对此应该毫无感知才对。

Gin 的设计很好,但是其实 Gin 可以不负责 Session 的管理,而是让用户直接使用 gorrila Session。

4.1 为什么要反对侵入式设计?

  • 坚持开闭原则:所有的侵入式设计,都违背了这个原则。因为侵入式就要修改已有的实现,而不是提供新的实现。
  • 代码难维护:侵入式设计属于典型的千里之堤毁于蚁穴的场景。每次侵入式增加一个功能,代码距离屎山又近了一步,到中期新人就不太可能读懂代码了。
  • Do less:侵入式设计无法解决所有用户的问题,所以不如将选择权给用户,而作为设计者只提供解决问题的接入点。

4.2 总体抽象

从前面的框架分析,实现一个 Session 模块要解决以下问题:

  1. 管理 Session 的问题:如创建、查找、销毁和刷新
  2. Session 存储和查找用户数据:要考虑这些数据真实存储在什么地方
  3. session id 存储和提取的问题:基本上就是要和 HTTP 的请求响应打交道

在前面的设计里,Beego、gorilla、Gin 和 Echo 都是将 1 和 3 合并在一起的。这里将它们分开,因为这更加符合单一职责原则。

image.png

三个基本抽象,用户自由组合它们的实现

4.2.1 Session 管理

1. Session 抽象

type Session interface {
   Get(ctx context.Context, key string) (string, error)
   Set(ctx context.Context, key string, val string) error
   ID() string
}

Session 本体需要考虑的就是存储和查找用户设置的数据。在这个设计里面不打算支持 Flash。(前面说过,它不能看做是一种特殊的 Session 键值对,而应该看做是一种新形态的东西)

  • Get:根据 Key 来找数据
  • Set:设置键值对

考虑到 Session 的数据可能存储在数据库或者 Redis 上,那么还可以引入批量方法:MultiGet、MultiSet。本次实现不会支持批量。另外一个可选的方案类似 Gin、gorilla,提供一个 Save 方法,Save 才会最终将数据写到对应的存储中间件里。

2. Store 抽象

该模块对应前面 Beego 的 Provider,或者gorilla、Gin、Echo、Iris 的 Store。

image.png

它需要支持:

  • 创建 Session:例如在用户登录成功之后,为他创建一个 Session。Session 应该有过期时间。
  • 销毁 Session:当用户退出登录的时候,要销毁掉数据。
  • 查找:根据 HTTP 请求里面携带的 session id,验证 session id 并查找对应的 Session 实例。
  • 刷新:如果用户持续保持活跃,那么 Session 应该在这期间一直有效。
// Store 管理 Session
// 从设计的角度来说,Generate 方法和 Refresh 在处理 Session 过期时间上有点关系
// 也就是说,如果 Generate 设计为接收一个 expiration 参数,
// 那么 Refresh 也应该接收一个 expiration 参数。
// 因为这意味着用户来管理过期时间
type Store interface {
   // Generate 生成一个 session
   Generate(ctx context.Context, id string) (Session, error)
   // Refresh 这种设计是一直用同一个 id 的
   // 如果想支持 Refresh 换 ID,那么可以重新生成一个,并移除原有的
   // 又或者 Refresh(ctx context.Context, id string) (Session, error)
   // 其中返回的是一个新的 Session
   Refresh(ctx context.Context, id string) error
   Remove(ctx context.Context, id string) error
   Get(ctx context.Context, id string) (Session, error)
}

3. Store 和 Session 的实现

这里,默认将 session 存到缓存中

package memory

import (
   "context"
   "errors"
   cache "github.com/patrickmn/go-cache"
   "time"
   "web/session"
)

func (s *Store) Refresh(ctx context.Context, id string) error {
   sess, err := s.Get(ctx, id)
   if err != nil {
      return err
   }
   s.c.Set(sess.ID(), sess, s.expiration)
   return nil
}

func (s *Store) Remove(ctx context.Context, id string) error {
   s.c.Delete(id)
   return nil

}

func (s *Store) Get(ctx context.Context, id string) (session.Session, error) {
   sess, ok := s.c.Get(id)
   if !ok {
      return nil, errors.New("session not found")
   }
   return sess.(*memorySession), nil
}

func (s *Store) Generate(ctx context.Context, id string) (session.Session, error) {
   sess := &memorySession{
      id:   id,
      data: make(map[string]string),
   }
   s.c.Set(sess.ID(), sess, s.expiration)
   return sess, nil
}

// NewStore 创建一个 Store 的实例
// 实际上,这里也可以考虑使用 Option 设计模式,允许用户控制过期检查的间隔
func NewStore(expiration time.Duration) *Store {
   return &Store{
      c: cache.New(expiration, time.Second),
   }
}

type Store struct {
   // 利用一个内存缓存来帮助我们管理过期时间
   c          *cache.Cache
   expiration time.Duration
}

func (m *memorySession) Set(ctx context.Context, key string, val string) error {
   m.data[key] = val
   return nil
}

func (m *memorySession) Get(ctx context.Context, key string) (string, error) {
   val, ok := m.data[key]
   if !ok {
      return "", errors.New("找不到这个 key")
   }
   return val, nil
}

func (m *memorySession) ID() string {
   return m.id
}

type memorySession struct {
   id         string
   data       map[string]string
   expiration time.Duration
}

4. cookie

cookie 用来存储用户的 session id,其实就是负责 session id 的管理的实现

package cookie

import "net/http"

type PropagatorOption func(propagator *Propagator)

func WithCookieOption(opt func(c *http.Cookie)) PropagatorOption {
   return func(propagator *Propagator) {
      propagator.cookieOpt = opt
   }
}

type Propagator struct {
   cookieName string
   cookieOpt  func(c *http.Cookie)
}

func NewPropagator(cookieName string, opt ...PropagatorOption) *Propagator {
   res := &Propagator{
      cookieName: cookieName,
      cookieOpt:  func(c *http.Cookie) {},
   }
   return res
}

func (c *Propagator) Inject(id string, writer http.ResponseWriter) error {
   cookie := &http.Cookie{
      Name:  c.cookieName,
      Value: id,
   }
   c.cookieOpt(cookie)
   http.SetCookie(writer, cookie)
   return nil
}

func (c *Propagator) Extract(req *http.Request) (string, error) {
   cookie, err := req.Cookie(c.cookieName)
   if err != nil {
      return "", err
   }
   return cookie.Value, nil
}

func (c *Propagator) Remove(writer http.ResponseWriter) error {
   cookie := &http.Cookie{
      Name:   c.cookieName,
      MaxAge: -1,
   }
   c.cookieOpt(cookie)
   http.SetCookie(writer, cookie)
   return nil
}

4.2.2 Session ID 存储和提取

回顾开始提到,session id 可以存在很多地方,只是主流都是存储在 Cookie 里面而已。

Propagator 就是一个抽象层,不同的实现允许将 session id 存储在不同的地方。

  • Inject:将 session id 注入到 HTTP 响应里面
  • Extract:将 session id 从 HTTP 请求里面提取出来

image.png

type Propagator interface {
   // Inject 将 session id 注入到里面
   // Inject 必须是幂等的
   Inject(id string, writer http.ResponseWriter) error
   // Extract 将 session id 从 http.Request 中提取出来
   // 例如从 cookie 中将 session id 提取出来
   Extract(req *http.Request) (string, error)

   // Remove 将 session id 从 http.ResponseWriter 中删除
   // 例如删除对应的 cookie
   Remove(id string, writer http.ResponseWriter) error
}

理论上可以设计两个接口,但因为它们基本都是成对出现的,例如说存储在 Cookie 里面,那么自然也是从 Cookie 里面提取出来,所以我们只需要一个接口。

4.2.3 Context 支持用户自定义数据

虽然理论上来说,我们能够利用 Req.Context来传递,但是它总是会引起 http.Request 的拷贝,所以我们可以考虑在 Context 里面支持一个UserValues 字段。

type Context struct {
   Request *http.Request
   // Response 原生的 ResponseWriter。当你直接使用 Response 的时候,
   // 那么相当于你绕开了 RespStatusCode 和 RespData。
   // 响应数据直接被发送到前端,其它中间件将无法修改响应
   // 其实我们也可以考虑将这个做成私有的
   Response http.ResponseWriter

   // 缓存的响应部分
   // 这部分数据会在最后刷新
   RespStatusCode int
   RespData       []byte

   // 路径参数
   PathParams map[string]string

   // 命中的路由
   MatchedRoute string

   // 万一将来有需求,可以考虑支持这个,但是需要复杂一点的机制
   // Body []byte 用户返回的响应
   // Err error 用户执行的 Error

   // 缓存的数据
   cacheQueryValues url.Values

   // 页面渲染的引擎
   tplEngine TemplateEngine

   // 用户可以自由决定在这里存储什么,
   // 主要用于解决在不同 Middleware 之间数据传递的问题
   // 但是要注意
   // 1. UserValues 在初始状态的时候总是 nil,你需要自己手动初始化
   UserValues map[string]any
}

注意:这里设计初始化为 nil,还是因为多数用户可能用不上。即便用得上,不同的人也需要不同的初始化容量。所以为了规避内存分配,将这个 UserValues的初始化过程交给了用户。当然也可以初始化为一个固定容量的 map,比如说make(map[string]any, 8)。但是这就相当于让多数用户为少数用户买单了——他们付出了内存和 CPU,结果自己用不上。

Gin、Echo 和 Iris 的 context 都有类似的设计。

image.png

image.png

image.png

比较奇怪的是,大家都没想过用context.Context 来传递, 为什么?因为 context.Context 的一个特性:父亲 context.Context 将无法访问子 context.Context 里面的内容。

4.2.4 Manager 用户友好的封装

type Manager struct {
   Store
   Propagator
   SessCtxKey string
}

前面那些东西都可以理解为是必不可少的,而这个 Manager 则纯粹是为了用户体验搞出来的。虽然在用户眼里它是核心结构,但你站在一个设计者的角度,应该认识到,它就是一个胶水

// GetSession 将会尝试从 ctx 中拿到 Session,
// 如果成功了,那么它会将 Session 实例缓存到 ctx 的 UserValues 里面
func (m *Manager) GetSession(ctx *web.Context) (Session, error) {
   if ctx.UserValues == nil {
      ctx.UserValues = make(map[string]any, 1)
   }
   val, ok := ctx.UserValues[m.SessCtxKey]
   if ok {
      return val.(Session), nil
   }

   id, err := m.Extract(ctx.Request)
   if err != nil {
      return nil, err
   }

   sess, err := m.Get(ctx.Request.Context(), id)
   if err != nil {
      return nil, err
   }

   ctx.UserValues[m.SessCtxKey] = sess
   return sess, nil
}

// InitSession 初始化一个 session,并且注入到 http response 里面
func (m *Manager) InitSession(ctx *web.Context, id string) (Session, error) {
   sess, err := m.Generate(ctx.Request.Context(), id)
   if err != nil {
      return nil, err
   }
   if err = m.Inject(id, ctx.Response); err != nil {
      return nil, err
   }
   return sess, nil
}

// RefreshSession 刷新 Session
func (m *Manager) RefreshSession(ctx *web.Context) (Session, error) {
   sess, err := m.GetSession(ctx)
   if err != nil {
      return nil, err
   }

   // 刷新存储的过期时间
   err = m.Refresh(ctx.Request.Context(), sess.ID())
   if err != nil {
      return nil, err
   }
   // 重新注入 HTTP 里面
   if err = m.Inject(sess.ID(), ctx.Response); err != nil {
      return nil, err
   }
   return sess, nil
}

// RemoveSession 删除 Session
func (m *Manager) RemoveSession(ctx *web.Context) error {
   sess, err := m.GetSession(ctx)
   if err != nil {
      return err
   }
   err = m.Store.Remove(ctx.Request.Context(), sess.ID())
   if err != nil {
      return err
   }
   return m.Propagator.Remove(ctx.Response)
}

注意到,GetSession 方法里为了加速获得 Session,在第一次从 ctx 里解析出来之后,尝试缓存在 ctx 里面,避免再次解析。

五. 自定义 Session 简单的使用例子

func LoginMiddleware(m *session.Manager) web.Middleware {
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         // 执行校验
         if ctx.Request.URL.Path != "/login" {
            sess, err := m.GetSession(ctx)
            // 不管发生了什么错误,对于用户我们都是返回未授权
            if err != nil {
               ctx.RespStatusCode = http.StatusUnauthorized
               return
            }
            ctx.UserValues["sess"] = sess
            _ = m.Refresh(ctx.Request.Context(), sess.ID())
            next(ctx)
         }
      }
   }
}

func TestManager(t *testing.T) {
   s := web.NewHTTPServer()
   m := &session.Manager{
      SessCtxKey: "_sess",
      Store:      memory.NewStore(30 * time.Minute),
      Propagator: cookie.NewPropagator("sessid",
         cookie.WithCookieOption(func(c *http.Cookie) {
            c.HttpOnly = true
         })),
   }
   s.Use(LoginMiddleware(m))
}

六. Session 安全性

实际上 Session 这种 session id 的认证还是比较 弱的,如果没有做一些安全措施,那么不管是谁拿 到 session id,服务器都只认 session id 不认人

一些可行的保护 session id 的方案:

  • 在使用 Cookie 的时候,同时设置 http_only 和 secure 选项,限制 Cookie 只能在 HTTPS 协议里面被传输
  • 在 session id 编码的时候带上一些客户端信息, 如 agent 信息、MAC 地址之类的。如果服务端 检测到 session id 所携带的这些信息发生了变 化,就要求用户重新登录。

image.png

这就是 Session 劫持

如果在后端设置cookie的HttpOnly属性,那么在前端调用接口后,无法读取cookies,有效防止XSS攻击,增加cookies安全性

image.png

七. Session 总结

7.1 为什么中间件设计者不管 session id 生成?

session id 在当下的生成策略可以说是五花八门,实在管不过来。session id 的生成 策略可以要考虑:

  • 是否要包含业务信息
    • 是:
      • 编码什么业务信息,用户决定,接口难以设计
      • 编码用什么算法,用户决定,接口更难以设计
    • 否:
      • 都不用包含什么信息了,UUID 搞一下就可以了,用不着web框架来管

虽然不考虑管理 session id,但是还是有一些基本原则可以参考:

  • 如果要尝试将数据编码到 session id 里,那么就编码非敏感数据,敏感数据就不要编码进去了
  • 如果因为 Redis 之类的压力很大,那么可以编码部分数据到 session id 里面,减少对 Redis 的访问
  • 谨慎选择编码(加密)算法

7.2 什么时候刷新 Session

func LoginMiddleware(m *session.Manager) web.Middleware {
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         // 是登录请求则执行校验
         if ctx.Request.URL.Path != "/login" {
            sess, err := m.GetSession(ctx)
            // 不管发生了什么错误,对于用户我们都是返回未授权
            if err != nil {
               ctx.RespStatusCode = http.StatusUnauthorized
               return
            }
            // 每次收到一个请求都刷新
            ctx.UserValues["sess"] = sess
            _ = m.Refresh(ctx.Request.Context(), sess.ID())
            next(ctx)
         }
      }
   }
}
  • 如果我们一直不刷新 Session,时间一到,Session 就会直接过期。即便此时用户还在操作,也会导致 用户直接退出登录状态。
  • 最直接的做法就是每次收到一个请求都刷新。体量不大的时候就这么干,简单直接好维护。 类似的做法还可以是前端定时心跳刷新,例如 5 秒 刷新一次。
  • 高端一点的就是维护长短两个 token,可以看做是 两个过期时间不一样的 Session。每次先检查短过期时间的 token,找不到就去找长过期时间的 token,这时会重新生成一个短 token。

八. 面试要点

  • Session 是什么?一种在服务端维护用户状态的机制
  • Cookie 和 Session 的对比?其实两者都可以看做是维护用户状态的机制,只不过一个是在客户端,一个是在服务端
  • 什么时候刷新 Session?一般是用户活跃的时候就可以刷,前端定时刷,或者后端每次收到请求都刷。但是这里要注意,频繁刷新 Session 可能给 Session 存储的地方带来庞大的压力,例如 Redis
  • 怎么实现一个 Session?简单来说就是要构建出 Session 和 Store 两个抽象,其它就随意了
  • 怎么生成 session id?看业务需求了,最简单的就是 UUID,高级一点的就用特定的算法进行加密,然后将业务需要的数据编码进去
  • 为什么要把数据编码进 session id?为了减轻存储 Session 的压力,比如说 Redis。直接解码拿到数据之后就不需要访问 Redis 了
  • 什么数据可以编码进 session id?一般来说也是看业务,不过关键是非核心数据才能编码进去,不然泄露了就凉了
  • session id 可以放哪里?一般都是根据喜好,因为问题都不大。可以放 Cookie,放 Header,放查询参数。主流是 Cookie,但是用户可能禁用了 Cookie
  • session id 放 cookie 有什么缺点?主要就是安全性的问题,一般来说我们会要求 Cookie 设置 HttpOnly,以及设置 SameSite 策略。最好的是启用 HTTPS 协议,并且设置 Cookie 的 Secure 选项。坦白说这是吹毛求疵,毕竟放 Header 或者查询参数连这些选项都没有