Gin 框架之Cookie与Session

一、Cookie和Session的由来

我们知道HTTP协议无连接的, 也就是不保存用户的状态信息。

早期(十几年前)的网页是静态的, 数据都是写死的, 人们访问网页只是用来查看新闻的, 没有保存用户状态的需求。

而往后出现了像论坛、博客、网购这一类需要保存用户信息的网站, 如果网站不保存用户的状态信息, 意味着用户每次访问都需要重新输入用户名和密码, 这无疑对用户的体验是极其不好的。

于是, 就出现了会话跟踪技术, 我们可以把它理解为客户端与服务端之间的一次会晤, 一次会晤包含的多次请求与响应, 每次请求都带着请求参数, 比如请求登入的请求参数是用户名和密码, 服务端就会拿着请求参数与数据库去比对, 找到相应的用户信息。

如何实现会话跟踪 : 在HTTP协议中可以使用Cookie来完成, 在Web开发中可以使用Session来完成

  • Cookie是存在浏览器中的键值对, 每次发送请求都携带者参数, 但是容易被截获, 不安全
  • 于是就出现了Session, 它是存在于服务端的键值对, key为随机字符串, 安全性提高了, 但所有的数据都存在服务器中, 服务器的压力很大
  • 之后便产生了Token的概念, 服务端签发加密后的字符串给客户端浏览器保存, 客户端每次请求携带用户名和密码, 并加上由服务端签发的用户名和密码加密的字符串, 服务端收到请求后再对用户名密码加密, 与后面携带的密文对比, 由于它也是保存在客户端浏览器上的, 所以也叫Cookie

二、Cookie简介

1. 什么是Cookie

  • Cookie是服务器保存在客户端浏览器之上的key-value键值对 : username='jarvis';password="123"
  • 它是随着服务器的响应发送给客户端, 客户端将其保存, 下一次请求时会将Cookie放在其中, 服务器通过识别Cookie就能知道是哪个客户端浏览器

2. Cookie规范

  • Cookie大小上限为4KB
  • 一个服务器最多在客户端浏览器上保存20个Cookie
  • 一个浏览器最多保存300个Cookie

上面是HTTP中Cookie的规范, 现在浏览器的竞争, 有些Cookie大小能打到8KB, 最多可以保存500个Cookie 不同浏览器之间的Cookie是不共享的

3. 安全性

  • Cookie保存在浏览器本地, 意味着很容易被窃取和篡改

4. Cookie 关键配置

在使用 Cookie 的时候,要注意“安全使用”。

  • Domain: 也就是 Cookie 可以用在什么域名下,按照最小化原则来设定。
  • Path: Cookie 可以用在什么路径下,同样按照最小化原则来设定。
  • Max-AgeExpires: 过期时间,只保留必要时间。
  • Http-Only: 设置为 true 的话,那么浏览器上的 JS 代码将无法使用这个 Cookie。永远设置为 true。
  • Secure: 只能用于 HTTPS 协议,生产环境永远设置为 true。
  • SameSite: 是否允许跨站发送 Cookie,尽量避免。

在Go语言中,标准库net/http提供了用于处理HTTP请求和响应的功能,包括处理Cookie的相关功能。可以通过http.Cookie{}查看。

对应解释如下:

代码语言:javascript
复制
type Cookie struct {
	Name  string      // Cookie的名称,用于唯一标识一个Cookie
	Value string      // Cookie的值,存储在Cookie中的具体数据
Path       string    // Cookie的路径,指定了哪些路径下的页面可以访问该Cookie
Domain     string    // Cookie的域,指定了哪些域名下可以访问该Cookie
Expires    time.Time // Cookie的过期时间,表示Cookie在何时之前有效
RawExpires string    // 仅用于读取Cookie,保存未解析的过期时间信息

// MaxAge=0 表示没有指定'Max-Age'属性
// MaxAge<0 表示立即删除Cookie,相当于 'Max-Age: 0'
// MaxAge>0 表示'Max-Age'属性存在,并以秒为单位给出
MaxAge   int

Secure   bool   // Secure属性,指定是否只在使用HTTPS协议时才发送Cookie
HttpOnly bool   // HttpOnly属性,设置为true时,禁止通过JavaScript访问该Cookie

SameSite SameSite // SameSite属性,控制是否允许跨站发送Cookie

Raw      string   // 保存未解析的原始Cookie字符串
Unparsed []string // 保存未解析的属性-值对文本

}

三、Session简介

1. 什么是Session

  • 存放在服务器上的键值对 : {'weqweq':{'username':'jarvis','password':'123'}}
  • Cookie可以保存状态, 但本身最大只能支持4069字节, 并且不安全, 于是就出现了Session
  • 它能支持更多字节, 并且保存在服务端上, 具有较高的安全性,Session基于Cookie, 本地存放服务器返回给浏览器的随机字符串
  • 客户端浏览器请求中携带随机字符串(session_id), 服务端收到后与数据库中存储的session做对比

ps : session存储的方式多种多样, 可以是数据库、缓存、硬盘等等

2. Session 安全性

Session 关键在于服务器要给浏览器一个 sess_id,也就是 Session 的 ID。

后续每一次请求都带上这个 Session ID,服务端就知道你是谁了。

但是后端服务器是“只认 ID 不认人”的。也就是说,如果攻击者拿到了你的 ID,那么服务器就会把攻击者当成你。在下图中,攻击者窃取到了 sess_id,就可以冒充是你了。

3. 如何让客户端携带 sess_id

因为 sess_id 是标识你身份的东西,所以你需要在每一次访问系统的时候都带上。

  • 最佳方式就是用 Cookie,也就是 sess_id 放到 Cookie 里面。sess_id 自身没有任何敏感信息,所以放 Cookie 也可以。
  • 也可以考虑放 Header,比如说在 Header 里面带一个 sess_id。这就需要前端的研发记得在 Header 里面带上。
  • 还可以考虑放查询参数,也就是 ?sess_id=xxx

理论上来说还可以放 body,但是基本没人这么干。在一些禁用了 Cookie 功能的浏览器上,只能考虑后两者。

在浏览器上,你可以通过插件 cookieEditor 来查看某个网站的 Cookie 信息。

四、使用 Gin 的 Session 插件

4.1 介绍

Gin框架本身并不内置对Session的支持,但你可以使用第三方的Session中间件来实现。其中比较常用的是 github.com/gin-contrib/sessions

4.2 基本使用

首先,确保你已经安装了Session中间件:

代码语言:javascript
复制
go get github.com/gin-contrib/sessions

然后在你的Gin应用程序中使用它。以下是一个基本的使用示例:

代码语言:javascript
复制
package main

import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http"
"time"
)

func main() {
// 创建Gin引擎
r := gin.Default()

// 使用Cookie存储Session
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("mysession", store))

// 路由:设置Session
r.GET("/set", func(c *gin.Context) {
	// 获取默认Session
	session := sessions.Default(c)
	
	// 设置Session中的键值对
	session.Set("user", "example@example.com")
	session.Set("expires", time.Now().Add(24*time.Hour).Unix())
	
	// 保存Session
	session.Save()

	// 返回JSON响应
	c.JSON(http.StatusOK, gin.H{"message": "Session set successfully"})
})

// 路由:获取Session
r.GET("/get", func(c *gin.Context) {
	// 获取默认Session
	session := sessions.Default(c)
	
	// 从Session中获取键值对
	user := session.Get("user")
	expires := session.Get("expires")

	// 返回JSON响应
	c.JSON(http.StatusOK, gin.H{"user": user, "expires": expires})
})

// 启动Gin应用,监听端口8080
r.Run(":8080")

}

在上述示例中,我们使用github.com/gin-contrib/sessions/cookie包创建了一个基于Cookie的Session存储。路由"/set"用于设置Session,路由"/get"用于获取Session。请注意,这里的Session数据是存储在客户端的Cookie中的,因此在实际应用中需要注意安全性。

五、 session与store

在Web应用中,会话(session)是一种用于在不同请求之间存储和共享用户信息的机制。通常,会话用于跟踪用户的身份验证状态、存储用户首选项和其他与用户相关的数据。在Gin框架中,会话的管理通常通过sessionstore两个概念来完成。

5.1 会话(Session)

  • 概念: 会话是在服务器端存储用户状态的一种机制。每个用户访问网站时,服务器都会为其创建一个唯一的会话标识符,该标识符存储在用户的浏览器中,通常通过Cookie来实现。服务器可以根据这个标识符来识别用户,并在多个请求之间共享用户的状态信息。
  • 作用: 主要用于存储用户的身份验证状态、用户的首选项、购物车内容等用户相关的信息,以便在用户访问不同页面或进行不同请求时能够保持一致的用户状态。

5.2 存储(Store)

  • 概念: 存储是用于实际存储和检索会话数据的地方。存储可以是内存、数据库、文件系统等,具体取决于应用程序的需求。存储负责维护会话数据的持久性和安全性。
  • 作用: 存储的主要作用是提供对会话数据的 CRUD(Create、Read、Update、Delete)操作。当用户发起请求时,存储会根据会话标识符检索相应的会话数据,服务器可以通过存储来实现会话的管理。

在Gin框架中,常用的Session中间件是 github.com/gin-contrib/sessions,而存储则可以选择不同的后端,例如使用Cookie、Redis、内存等。

在Gin应用中引入该中间件,通过创建Session对象和设定存储引擎,可以方便地进行Session的处理。以下是一个基本的使用示例:

代码语言:javascript
复制
package main

import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
r := gin.Default()

// 使用Cookie存储Session
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("mysession", store))

r.GET("/set", func(c *gin.Context) {
	// 设置Session
	session := sessions.Default(c)
	session.Set("user", "example@example.com")
	session.Save()

	c.JSON(http.StatusOK, gin.H{"message": "Session set successfully"})
})

r.GET("/get", func(c *gin.Context) {
	// 获取Session
	session := sessions.Default(c)
	user := session.Get("user")

	c.JSON(http.StatusOK, gin.H{"user": user})
})

r.Run(":8080")

}

  • cookie.NewStore([]byte("secret")) 在上述示例中,使用了Cookie存储。cookie.NewStore 创建了一个基于Cookie的存储引擎,使用了一个密钥来加密Cookie中的会话数据。
  • 其他存储引擎: 除了Cookie存储,github.com/gin-contrib/sessions 还支持其他存储引擎,例如Redis、文件系统等。可以根据实际需求选择合适的存储引擎。
代码语言:javascript
复制
// 使用Redis存储Session的例子
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

// 使用Redis存储Session
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret"))
r.Use(sessions.Sessions("mysession", store))

// ...

}

在实际项目中,选择存储引擎时要考虑性能、可扩展性和持久性等因素。 Cookie 存储适用于小型应用,而对于大型应用,可能需要选择支持分布式的存储引擎,如 Redis。

总体而言,使用Gin的Session中间件能够方便地管理会话,而不同的存储引擎则提供了灵活的选择,以适应不同的应用场景。

六、小黄书实践

目录结构

代码语言:javascript
复制
├── docker-compose.yaml         # Docker Compose配置文件,定义了整个应用的容器化部署
├── go.mod # Go模块文件,用于管理项目的依赖项
├── go.sum # Go模块文件,包含依赖项的版本信息
├── internal # 内部模块,包含应用程序的业务逻辑
│ ├── domain # 领域层,包含领域对象和领域逻辑
│ │ └── user.go # 用户领域对象的定义
│ ├── repository # 数据仓库层,用于与数据库交互
│ │ ├── dao # 数据访问对象,与数据库的交互接口
│ │ │ ├── init.go # 数据库初始化
│ │ │ └── user.go # 用户数据访问对象的定义
│ │ └── user.go # 用户数据仓库接口的定义
│ ├── service # 服务层,包含业务逻辑的实现
│ │ └── user.go # 用户服务的实现
│ └── web # Web层,处理HTTP请求和路由
│ ├── middleware # 中间件,用于处理HTTP请求的中间逻辑
│ │ └── login.go # 登录中间件的定义
│ ├── user.go # 用户相关的HTTP处理函数和路由定义
│ └── user_test.go # 用户相关的测试文件
├── main.go # 主程序入口,应用程序的启动文件
├── pkg # 外部可导出的包,供其他应用程序或模块使用
├── script # 脚本目录,包含各种初始化和辅助脚本
└── mysql # MySQL初始化脚本目录
└── init.sql # 数据库初始化SQL脚本

internal/domain/user.go 文件代码:

代码语言:javascript
复制
package domain

import "time"

// User 领域对象,是DDD 中的 entity
type User struct {
Id int64
Email string
Password string
Ctime time.Time
}

internal/repository/user.go

代码语言:javascript
复制
package repository

import (
"context"
"webook/internal/domain"
"webook/internal/repository/dao"
)

var (
ErrUserDuplicateEmail = dao.ErrUserDuplicateEmail
ErrUserNotFound = dao.ErrUserNotFound
)

type UserRepository struct {
dao *dao.UserDAO
}

func NewUserRepository(dao *dao.UserDAO) *UserRepository {
return &UserRepository{
dao: dao,
}
}

func (r *UserRepository) Create(ctx context.Context, u domain.User) error {
return r.dao.Insert(ctx, dao.User{
Email: u.Email,
Password: u.Password,
})
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
u, err := r.dao.FindByEmail(ctx, email)
if err != nil {
return domain.User{}, err
}
return domain.User{
Id: u.Id,
Email: u.Email,
Password: u.Password,
}, nil
}

internal/repository/dao/init.go

代码语言:javascript
复制
package dao

import "gorm.io/gorm"

func InitTable(db *gorm.DB) error {
return db.AutoMigrate(&User{})
}

internal/repository/dao/user.go

代码语言:javascript
复制
package dao

import (
"context"
"errors"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"time"
)

var (
ErrUserDuplicateEmail = errors.New("邮箱冲突")
ErrUserNotFound = gorm.ErrRecordNotFound
)

type UserDAO struct {
db *gorm.DB
}

func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{
db: db,
}
}
func (dao *UserDAO) Insert(ctx context.Context, u User) error {
// 存毫秒数
now := time.Now().UnixNano()
u.Ctime = now
u.Utime = now
err := dao.db.WithContext(ctx).Create(&u).Error
// 类型断言,判断是否是mysql的唯一冲突错误
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
const uniqueConflictsErrNo uint16 = 1062
if mysqlErr.Number == uniqueConflictsErrNo {
// 邮箱已存在
return ErrUserDuplicateEmail
}
}
return err
}
func (dao *UserDAO) FindByEmail(ctx context.Context, email string) (User, error) {
var u User
err := dao.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
return u, err
}

// User 直接对应数据库中的表
// 有些人叫做entity,有些人叫做model
type User struct {
Id int64 gorm:"primaryKey,autoIncrement"
// 全部用户唯一
Email string gorm:"unique"
Password string

// 创建时间,毫秒数,解决时区问题
Ctime int64
// 更新时间
Utime int64

}

internal/service/user.go

代码语言:javascript
复制
package service

import (
"context"
"errors"
"golang.org/x/crypto/bcrypt"
"webook/internal/domain"
"webook/internal/repository"
"webook/internal/repository/dao"
)

var (
ErrUserDuplicateEmail = dao.ErrUserDuplicateEmail
ErrInvalidUserOrPassword = errors.New("账号/邮箱或密码不对")
)

type UserService struct {
repo *repository.UserRepository
}

func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{
repo: repo,
}
}

func (svc *UserService) SignUp(ctx context.Context, u domain.User) error {
// 先加密密码
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = string(hash)
// 然后就是,存起来
return svc.repo.Create(ctx, u)
}

func (svc *UserService) Login(ctx context.Context, email, password string) (domain.User, error) {
// 先找用户
u, err := svc.repo.FindByEmail(ctx, email)
if err == repository.ErrUserNotFound {
return domain.User{}, ErrInvalidUserOrPassword
}
if err != nil {
return domain.User{}, err
}
// 比较密码了
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
// DEBUG
return domain.User{}, ErrInvalidUserOrPassword
}
return u, nil
}

internal/web/user.go

代码语言:javascript
复制
package web

import (
regexp "github.com/dlclark/regexp2"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"webook/internal/domain"
"webook/internal/service"
)

type UserHandler struct {
svc *service.UserService
emailExp *regexp.Regexp
passwordExp *regexp.Regexp
}

func NewUserHandler(svc service.UserService) UserHandler {
const (
emailRegexPattern = "^\w+([-+.]\w+)
@\w+([-.]\w+)
\.\w+([-.]\w+)*$"
passwordRegexPattern = ^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{8,}$
)
emailExp := regexp.MustCompile(emailRegexPattern, regexp.None)
passwordExp := regexp.MustCompile(passwordRegexPattern, regexp.None)
return &UserHandler{
svc: svc,
emailExp: emailExp,
passwordExp: passwordExp,
}
}

func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
ug := server.Group("/user") //ug is user group
ug.GET("/profile", u.Profile) // 查询用户信息接口
ug.POST("/signup", u.SignUp) // 注册接口
ug.POST("/login", u.Login) // 登录接口
ug.POST("/logout", u.Logout) // 登出接口
ug.POST("/edit", u.Edit) // 修改用户信息接口

}

func (u *UserHandler) RegisterRoutesV1(ug *gin.RouterGroup) {
ug.GET("/profile", u.Profile) // 查询用户信息接口
ug.POST("/signup", u.SignUp) // 注册接口
ug.POST("/login", u.Login) // 登录接口
ug.POST("/logout", u.Logout) // 登出接口
ug.POST("/edit", u.Edit) // 修改用户信息接口

}
func (u *UserHandler) Profile(ctx *gin.Context) {
}
func (u *UserHandler) SignUp(ctx *gin.Context) {
type SignUpRequest struct {
Email string json:"email"
Password string json:"password"
ConfirmPassword string json:"confirmPassword"
}
var request SignUpRequest
// 如果 Bind 方法发现输入有问题,它就会直接返回一 个错误响应到前端。
if err := ctx.Bind(&request); err != nil {
return
}

ok, err := u.emailExp.MatchString(request.Email)
if err != nil {
	ctx.String(http.StatusOK, "系统错误")
	return
}
if !ok {
	ctx.String(http.StatusOK, "邮箱格式错误")
	return
}
ok, err = u.passwordExp.MatchString(request.Password)
if err != nil {
	ctx.String(http.StatusOK, "系统错误")
	return
}
if !ok {
	ctx.String(http.StatusOK, "密码必须包含至少一个数字、一个字母、一个特殊字符,并且长度至少为8位")
	return
}
if request.Password != request.ConfirmPassword {
	ctx.String(http.StatusOK, "两次密码不一致")
	return
}
// 调用以一下svc 的方法
err = u.svc.SignUp(ctx, domain.User{
	Email:    request.Email,
	Password: request.Password,
})
if err == service.ErrUserDuplicateEmail {
	ctx.String(http.StatusOK, "邮箱冲突")
	return
}
if err != nil {
	ctx.String(http.StatusOK, "系统错误")
	return

}
ctx.String(http.StatusOK, "注册成功")

}
func (u *UserHandler) Login(ctx *gin.Context) {
type LoginRequest struct {
Email string json:"email"
Password string json:"password"
}
var request LoginRequest
if err := ctx.Bind(&request); err != nil {
return
}
// 调用以一下svc 的方法
user, err := u.svc.Login(ctx, request.Email, request.Password)

if err == service.ErrInvalidUserOrPassword {
	ctx.String(http.StatusOK, "账号或密码错误")
	return
}
if err != nil {
	ctx.String(http.StatusOK, "系统错误")
	return
}
// 登录成功设置session
sess := sessions.Default(ctx)
// 设置session的key和value
sess.Set("userId", user.Id)
sess.Save()
ctx.String(http.StatusOK, "登录成功")
return

}

func (u *UserHandler) Logout(ctx *gin.Context) {

}
func (u *UserHandler) Edit(ctx *gin.Context) {

}

main.go

代码语言:javascript
复制
package main

import (
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"strings"
"time"
"webook/internal/repository"
"webook/internal/repository/dao"
"webook/internal/service"
"webook/internal/web"
"webook/internal/web/middleware"
)

func main() {
db := initDB()
ser := initWebServer()
u := initUser(db)
u.RegisterRoutes(ser)
ser.Run(":8080")

}

func initWebServer() *gin.Engine {
ser := gin.Default()
ser.Use(func(ctx *gin.Context) {
println("这是一个中间件")
})
ser.Use(func(ctx gin.Context) {
println("这是二个中间件")
})
ser.Use(cors.New(cors.Config{
//AllowOrigins: []string{"
"},
//AllowMethods: []string{"POST", "GET"},
AllowHeaders: []string{"Content-Type", "Authorization"},
//ExposeHeaders: []string{"x-jwt-token"},
// 是否允许你带 cookie 之类的东西
AllowCredentials: true,
AllowOriginFunc: func(origin string) bool {
if strings.HasPrefix(origin, "http://localhost") {
// 你的开发环境
return true
}
return strings.Contains(origin, "yourcompany.com")
},
MaxAge: 12 * time.Hour,
}))
// 用 cookie 存储 session
store := cookie.NewStore([]byte("secret"))
// 使用 session 中间件
ser.Use(sessions.Sessions("mysession", store))
//ser.Use(middleware.NewLoginMiddlewareBuilder().Build())
//ser.Use(middleware.NewLoginMiddlewareBuilder().
// IgnorePaths("/users/signup").
// IgnorePaths("/users/login").Build())
// v1
middleware.IgnorePaths = []string{"sss"}
ser.Use(middleware.CheckLogin())

// 不能忽略sss这条路径
server1 := gin.Default()
server1.Use(middleware.CheckLogin())
return ser

}

func initUser(db *gorm.DB) *web.UserHandler {
ud := dao.NewUserDAO(db)
repo := repository.NewUserRepository(ud)
svc := service.NewUserService(repo)
u := web.NewUserHandler(svc)
return u
}
func initDB() *gorm.DB {
db, err := gorm.Open(mysql.Open("root:root@tcp(localhost:13316)/webook"))
if err != nil {
// panic 相当于 整个 goroutine 结束
panic("failed to connect database")
}
err = dao.InitTable(db)
if err != nil {
panic("failed to init table")
}
return db
}