Go Web编程–SecureCookie实现客户端Session管理

Web 应用开发中 Session 是在用户和服务器之间进行交换的非持久化交互信息。当用户登录时,可以在用户和服务器之间生成 Session ,然后来回交换数据,并在用户登出时销毁 Sessiongorilla/sessions 软件包提供了易于使用的 Go 语言 Session 实现。该软件包提供了两种不同的实现。第一个是文件系统存储,它将每个会话存储在服务器的文件系统中。另一个是 Cookie 存储,它使用我们 上篇文章 讲的 SecureCookie 在客户端上存储会话。同时还提供了用户自定义 Session 存储实现的选项,我们可以根据应用的需求自己实现 Session 存储。因为我们的教程是学会使用为目的就不大费周章的去实现 MySQL 或者 Redis 版本的 Session 存储了,我们直接使用软件包提供的 Cookie 实现来完成本节的 Session 相关内容。

Go Web 编程系列的每篇文章的源代码都打了对应版本的软件包,供大家参考。公众号中回复 gohttp09 获取本文源代码

使用Cookie存储用户Session的优缺点

客户端使用 Cookie 管理用户 Session 较之在服务器进行用户的 Session 管理会有一些优势。客户端 Session 增加了应用程序的可伸缩性,因为所有的会话数据都存储在用户端,因此可以将用户的请求平衡到不同的远端服务器,也不必在服务器端对所有用户的会话进行统一管理,所以使用 Cookie 存储用户 Session 会更简单一些。

当然有优势就必定有劣势,客户端 Cookie 的整体大小是有限制的。目前, Google Chrome 浏览器将 Cookie 限制为 4096 个字节。

客户端会话还意味着无法终止会话,从而导致注销不完整。如果用户在退出前保存了 Cookie 中的会话信息,则他们可以使用该会话信息创建一个新的 Cookie ,然后继续使用该应用程序,为了最大程度地降低安全风险,我们可以将会话 Cookie 设置为在合理的时间内过期,使用加密后的 ScureCookie 存储数据,同时还要避免在其中存储敏感信息(即使是服务端管理 Session 也不应该存储类似密码这种敏感信息)。

总之在考虑使用客户端还是服务端存储用户 Session 时一定要根据应用的使用场景来选择,这一点很重要。

安装gorilla/sessions

在开始编码前先来安装一下 gorilla/sessions 软件包,

$ go get github.com/gorilla/sessions

并简单看一下软件包功能特性的介绍

  • 方便地设置签名(也可以选择加密)的 Cookie
  • 自带将会话存储在 Cookie 或服务端文件系统中的 SessionStore 实现。
  • 支持Flash消息:读取即销毁的会话数据。
  • 支持方便地切换会话数据的持久化方式。
  • 为不同的 Session 存储提供统一的接口和基础设施。

演示用户Session设计实现

我们今天的示例代码是用 gorilla/sessions 提供的 CookieSessionStore 实现一个简单的系统登录功能。

我们会定义如下几个路由:

  • /user/login 用户登录验证,验证成功后在用户 Session 数据中标记用户是已验证的。
  • /user/logout 用户登出,会在 Session 中标记用户是未认证的。
  • /user/secret 通过用户 Session 判断用户是否已认证,未认证返回 403 Forbidden 错误。

为了达到演示目的的同时减少文章中出现过多代码,我们不会做前端页面,通过命令行 cURL 直接请求上面几个 URL 验证我们的系统登录功能。

初始化工作

我们现在项目的 handler 目录下新建一个 user 子目录,用于存放使用到用户 Session 的处理程序

...
handler/
└── user/
    └── init.go
    └── login.go
    └── logout.go
    └── secret.go
...
main.go

其下的四个分别是包的初始化程序 init.go 以及存放上面说的三个路由处理程序的 .go 源文件。

初始化Session存储

我们把 Session 存储的初始化工作放在 user 包的 init 函数中,这样首次导入 user 包时即可完成相关的初始化工作。

package user

import "github.com/gorilla/sessions"

const (
    //64位
    cookieStoreAuthKey = "..."
    //AES encrypt key必须是16或者32位
    cookieStoreEncryptKey = "..."
)

var sessionStore *sessions.CookieStore

func init () {
    sessionStore = sessions.NewCookieStore(
        []byte(cookieStoreAuthKey),
        []byte(cookieStoreEncryptKey),
    )

    sessionStore.Options = &sessions.Options{
        HttpOnly: true,
        MaxAge:   60 * 15,
    }

}

实现登录验证

// login.go
var sessionCookieName = "user-session"
func Login(w http.ResponseWriter, r *http.Request) {
    session, err := sessionStore.Get(r, sessionCookieName)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    // 登录验证
    name := r.FormValue("name")
    pass := r.FormValue("password")
    _, err = logic.AuthenticateUser(name, pass)
    if err != nil {
        http.Error(w, err.Error(), http.StatusUnauthorized)
        return
    }
    // 在session中标记用户已经通过登录验证
    session.Values["authenticated"] = true
    err = session.Save(r, w)

    fmt.Fprintln(w, "登录成功!", err)
}
  • 我们将浏览器 Cookie 中存储用户 SessionCookie-Name 设置成了 user-session
  • 登录验证就是简单的用户名和密码查找匹配的用户,在之前的文章 应用数据库应用 ORM 两篇文章中有在 MySQL 数据库中创建 users 表,并介绍了怎么使用 ORM 操作数据库,没有看过的同学可以回看一下。
  • 登录验证成功后在 Sessionauthenticated 中标记了用户已通过认证。 session.Values 是类型 map[interface{}]interface{} 的别名,所以可以往其中存储任意类型的数据。

实现登出

登出我们这里就是简单的将 Sessionauthenticated 的值设置成了 false .

//logout.go
func Logout(w http.ResponseWriter, r *http.Request) {
   session, _ := sessionStore.Get(r, sessionCookieName)
   
   session.Values["authenticated"] = false
   session.Save(r, w)
}

使用Session认证用户

//secret.go
func Secret(w http.ResponseWriter, r *http.Request) {
   session, _ := sessionStore.Get(r, sessionCookieName)

   if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
      http.Error(w, "Forbidden", http.StatusForbidden)
      return
   }

   fmt.Fprintln(w, "这里还是空空如也!")
}
  • 使用 Session 中存储的数据值都是接口类型的,所以使用时要先对其进行类型断言 session.Values["authenticated"].(bool)
  • 如果 authenticated 的值不为 true 或者是从 Session 中获取不到对应的值,这里直接返回 HTTP 403 Forbidden 错误。

注册路由

// router.go
func RegisterRoutes(r *mux.Router) {
  ...
  userRouter := r.PathPrefix("/user").Subrouter()
  userRouter.HandleFunc("/login", user.Login).Methods("POST")
  userRouter.HandleFunc("/secret", user.Secret)
  userRouter.HandleFunc("/logout", user.Logout)
  ...
}

验证已实现的Session管理功能

编写完上面的 Session 管理的功能后,重启服务器,然后使用 cURL 分别请求 URL 验证一下效果。

curl -XPOST   -d 'name=Klein&password=123' \
     -c - http://localhost:8000/user/login

-c 选项表示将 Cookie 写入到后面的文件中,完整格式是 -c - ,短横线后不带文件名表示把 Cookie 写入到标准输出中。

我们可以在下图里看到, Cookie 中的 user-session 存储的就是加密后的 Session 数据了

如果请求中不携带这个 Cookie 访问 /user/secret 会直接返回 HTTP 403 错误

那么接下来在使用 cURL 请求 /user/secret 时带上上面返回的 Cookie 值,看看请求是否能成功

curl --cookie "user-session=MTU4m..." http://localhost:8000/user/secret

Cookie 加密后的值太长了,搞得字儿好小, cURL 执行的结果显示服务器成功地响应了我们的请求。你们试验的时候换成自己生成的 Cookie 值请求就可以啦。

你们实践时也可以用 PostMan 代替 cURL 试验,不过感觉 PostMan 的返回不如 cURL 来的明显。

Go Web 编程系列的每篇文章的源代码都打了对应版本的软件包,供大家参考。公众号中回复 gohttp09 获取本文源代码

前文回顾

Go Web 编程–如何确保Cookie数据的安全传输

Go Web编程–应用ORM

Go Web编程–应用ORM

五分钟用Docker快速搭建Go开发环境

深入学习用Go编写HTTP服务器