Golang 中的 Session Cookie 身份验证

文章目录

本文介绍如何在 Golang 服务器应用程序中使用 session cookie 对用户进行身份验证。

当用户登录到我们的应用程序时,我们需要知道他们在所有HTTP方法和路由中的身份。

一种方法是存储用户的“会话”(session)。一旦用户登录成功,会话开始,并在一定时间后过期。

每个已登录用户都有与会话相关的某些信息(称为cookie),他们将这些信息随请求一起发送。我们可以使用这些信息来查找它所属的用户并返回特定于该用户的信息。

概述

在这篇文章中,我们将研究如何创建登录用户的会话并将其存储为浏览器上的 cookie。

我们将构建一个带有 /signin/welcome 路由的应用程序。

  • /signin 路由将接受用户的用户名和密码,如果成功则设置会话 cookie。
  • /welcome 路由将是一个简单的 HTTP GET 路由,它将向当前登录的用户显示个性化消息。

用户的会话信息将存储在我们的应用程序本地内存中。

我们还将假设正在登录的用户已经在我们的应用程序上创建了他们的用户名-密码凭据。

创建 HTTP 服务器

让我们首先通过初始化HTTP服务器和所需的路由开始:

package main

import (
	"log"
	"net/http"
)

func main() {
    // "Signin"和"Welcome"是我们需要实现的处理程序
	http.HandleFunc("/signin", Signin)
	http.HandleFunc("/welcome", Welcome)
	// 在8080端口上启动服务器
	log.Fatal(http.ListenAndServe(":8080", nil))
}

现在我们可以定义 Signin 和 Welcome 路由了。

创建 Session Tokens

每次用户登录时,我们都会创建一个新的会话令牌(Session Token)。

/signin 路由将获取用户凭据并登录。

为了简化此过程,我们将在代码中将用户信息作为内存 map 存储:

var users = map[string]string{
	"user1": "password1",
	"user2": "password2",
}

目前,应用程序中只有两个有效的用户:user1user2

我们还需要定义一个 map 来存储有关每个用户会话的信息:

// 这个 map 存储用户会话信息。对于更大规模的应用程序,可以使用数据库或缓存来实现此目的
var sessions = map[string]session{}

// 每个会话都包含用户的用户名以及它的过期时间
type session struct {
	username string
	expiry   time.Time
}

// 稍后我们将使用此方法来确定会话是否已过期。
func (s session) isExpired() bool {
	return s.expiry.Before(time.Now())
}

接下来,我们可以实现signin HTTP 处理程序:

// 创建一个结构体,对请求体中的用户进行建模
type Credentials struct {
	Password string `json:"password"`
	Username string `json:"username"`
}

func Signin(w http.ResponseWriter, r *http.Request) {
	var creds Credentials
    // 获取JSON数据,并将其解码为凭证
	err := json.NewDecoder(r.Body).Decode(&creds)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

    // 从我们的内存映射表中获取期望的密码
	expectedPassword, ok := users[creds.Username]

	// 如果给定用户存在密码
	// 而且,如果它与我们接收到的密码相同,就可以继续进行
	// 如果不是,我们就返回"Unauthorized"状态
	if !ok || expectedPassword != creds.Password {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

    // 创建新的随机会话令牌
    // 我们使用 "github.com/google/uuid" 库来生成 UUID
	sessionToken := uuid.NewString()
	expiresAt := time.Now().Add(120 * time.Second)

    // 将令牌和会话信息设置到会话映射中
	sessions[sessionToken] = session{
		username: creds.Username,
		expiry:   expiresAt,
	}

    // 最后,我们将客户端cookie设置为刚刚生成的会话令牌"session_token"
    // 我们还设置了一个到期时间为120秒。
	http.SetCookie(w, &http.Cookie{
		Name:    "session_token",
		Value:   sessionToken,
		Expires: expiresAt,
	})
}

如果用户成功登录,此处理程序将在客户端和它自己的本地内存中设置一个 cookie。一旦在客户端上设置了 cookie,它就会随每个后续请求一起发送。

我们可以使用 UUID 来表示会话令牌,因为它们在结构上是统一的,而且很难猜测。

现在我们已经将用户会话信息(以 session_token cookie的形式)持久化在客户端和服务器上,我们可以编写我们的“Welcome”处理程序来处理用户特定的信息。

由于登录的客户端已经将会话信息存储在其端上,我们可以使用它来:

  • 验证后续用户请求
  • 获取有关发出请求的用户的信息

让我们编写 Welcome 处理程序来实现这一功能:

func Welcome(w http.ResponseWriter, r *http.Request) {
    // 我们可以从请求的cookies中获取会话令牌,这些cookies将随每个请求一起发送
	c, err := r.Cookie("session_token")
	if err != nil {
		if err == http.ErrNoCookie {
            // 如果未设置cookie,则返回未经授权的状态
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
        // 对于任何其他类型的错误,返回 400 状态
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	sessionToken := c.Value

	// 从我们的会话映射中获取会话
	userSession, exists := sessions[sessionToken]
	if !exists {
        // 如果会话令牌不在会话映射中存在,则返回未经授权的错误信息
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
    // 如果会话存在但已过期,则可以删除该会话并返回未经授权的状态
	if userSession.isExpired() {
		delete(sessions, sessionToken)
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

    // 如果会话有效,则将欢迎消息返回给用户
	w.Write([]byte(fmt.Sprintf("Welcome %s!", userSession.username)))
}

从这段代码中,我们可以看出在以下情况下,我们的欢迎处理程序会给出“未经授权”(或401)的状态代码:

  • 如果请求中没有 session_token cookie(这意味着请求者尚未登录)
  • 如果会话令牌不存在于内存中(这意味着请求者正在向我们发送无效的会话令牌)
  • 如果会话已过期

基于会话的身份验证通过以下几种方式保护您的用户会话安全:

  • 由于会话令牌是随机生成的,恶意用户几乎无法使用暴力破解方式进入用户会话。
  • 如果用户的会话令牌在某种方式下被泄露,那么它在到期后就不能再使用。这就是为什么会话有效期被限制在小的时间段(几秒钟到几分钟)的原因。

刷新会话令牌

由于会话令牌的有效期很短,我们需要经常发行新令牌,以保持用户登录状态。

当然,我们不能期望用户每次令牌过期时都要重新登录。为解决这个问题,我们可以创建另外一个路由,接受用户当前的会话令牌,然后发行一个带有更新过期时间的新会话令牌。

让我们定义一个 Refresh 的 HTTP 处理程序,每次用户访问我们应用程序的 /refresh 路由时,它都可以更新用户的会话令牌。

func Refresh(w http.ResponseWriter, r *http.Request) {
    // (BEGIN) 从这里开始的代码与 Welcome 路由的第一部分完全相同
	c, err := r.Cookie("session_token")
	if err != nil {
		if err == http.ErrNoCookie {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	sessionToken := c.Value

	userSession, exists := sessions[sessionToken]
	if !exists {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	if userSession.isExpired() {
		delete(sessions, sessionToken)
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
    // (END) 到这里为止的代码与 Welcome 路由的第一部分相同

    // 如果之前的会话有效,则为当前用户创建一个新的会话令牌
	newSessionToken := uuid.NewString()
	expiresAt := time.Now().Add(120 * time.Second)

    // 将令牌设置在会话映射中,并设置该令牌所代表的用户
	sessions[newSessionToken] = session{
		username: userSession.username,
		expiry:   expiresAt,
	}

    // 删除旧的会话令牌
	delete(sessions, sessionToken)

    // 将新令牌设置为用户的“session_token”cookie
	http.SetCookie(w, &http.Cookie{
		Name:    "session_token",
		Value:   newSessionToken,
		Expires: time.Now().Add(120 * time.Second),
	})
}

现在我们可以将此代码添加到我们的其他路由中:

http.HandleFunc("/refresh", Refresh)

登出用户

如果用户决定退出我们的应用程序,我们需要从我们的存储以及用户客户端中删除他们的会话令牌。

让我们创建一个 Logout 处理程序来实现这个逻辑:

func Logout(w http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("session_token")
	if err != nil {
		if err == http.ErrNoCookie {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	sessionToken := c.Value

    // 从会话映射中删除用户会话
	delete(sessions, sessionToken)

    // 我们需要通知客户端 cookie 已过期
    // 在响应中,我们将会话令牌设置为空值,并将其到期时间设置为当前时间
	http.SetCookie(w, &http.Cookie{
		Name:    "session_token",
		Value:   "",
		Expires: time.Now(),
	})
}

我们现在可以将此注销处理程序添加到我们的其它路由中:

http.HandleFunc("/logout", Logout)

运行我们的应用程序

要运行此应用程序,请构建并运行 Go 二进制文件,现在,使用任何支持 cookie 的 HTTP 客户端(如 Postman 或您的网络浏览器)使用适当的凭据发出登录请求:

POST http://localhost:8080/signin

{"username":"user2","password":"password2"}

您现在可以尝试从同一个客户端访问欢迎路由以获取欢迎消息:

GET http://localhost:8080/welcome

访问刷新路由,然后检查客户端 cookie 以查看 session_token 的新值:

POST http://localhost:8080/refresh

最后,调用注销路由清除会话数据:

GET http://localhost:8080/logout

在此之后调用欢迎和刷新路由将导致 401 错误。


也可以看看


全国大流量卡免费领

19元月租ㆍ超值优惠ㆍ长期套餐ㆍ免费包邮ㆍ官方正品