我们知道HTTP协议无连接的, 也就是不保存用户的状态信息。
早期(十几年前)的网页是静态的, 数据都是写死的, 人们访问网页只是用来查看新闻的, 没有保存用户状态的需求。
而往后出现了像论坛、博客、网购这一类需要保存用户信息的网站, 如果网站不保存用户的状态信息, 意味着用户每次访问都需要重新输入用户名和密码, 这无疑对用户的体验是极其不好的。
于是, 就出现了会话跟踪技术, 我们可以把它理解为客户端与服务端之间的一次会晤, 一次会晤包含的多次请求与响应, 每次请求都带着请求参数, 比如请求登入的请求参数是用户名和密码, 服务端就会拿着请求参数与数据库去比对, 找到相应的用户信息。
如何实现会话跟踪 : 在HTTP协议中可以使用Cookie来完成, 在Web开发中可以使用Session来完成
username='jarvis';password="123"
上面是HTTP中Cookie的规范, 现在浏览器的竞争, 有些Cookie大小能打到8KB, 最多可以保存500个Cookie 不同浏览器之间的Cookie是不共享的
在使用 Cookie 的时候,要注意“安全使用”。
Domain
: 也就是 Cookie 可以用在什么域名下,按照最小化原则来设定。Path
: Cookie 可以用在什么路径下,同样按照最小化原则来设定。Max-Age
和 Expires
: 过期时间,只保留必要时间。Http-Only
: 设置为 true 的话,那么浏览器上的 JS 代码将无法使用这个 Cookie。永远设置为 true。Secure
: 只能用于 HTTPS 协议,生产环境永远设置为 true。SameSite
: 是否允许跨站发送 Cookie,尽量避免。在Go语言中,标准库net/http
提供了用于处理HTTP请求和响应的功能,包括处理Cookie的相关功能。可以通过http.Cookie{}
查看。
对应解释如下:
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 // 保存未解析的属性-值对文本
}
{'weqweq':{'username':'jarvis','password':'123'}}
session_id
), 服务端收到后与数据库中存储的session
做对比ps : session存储的方式多种多样, 可以是数据库、缓存、硬盘等等
Session
关键在于服务器要给浏览器一个 sess_id
,也就是 Session 的 ID。
后续每一次请求都带上这个 Session ID
,服务端就知道你是谁了。
但是后端服务器是“只认 ID 不认人”的。也就是说,如果攻击者拿到了你的 ID
,那么服务器就会把攻击者当成你。在下图中,攻击者窃取到了 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的支持,但你可以使用第三方的Session中间件来实现。其中比较常用的是 github.com/gin-contrib/sessions
。
首先,确保你已经安装了Session中间件:
go get github.com/gin-contrib/sessions
然后在你的Gin应用程序中使用它。以下是一个基本的使用示例:
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中的,因此在实际应用中需要注意安全性。
在Web应用中,会话(session)是一种用于在不同请求之间存储和共享用户信息的机制。通常,会话用于跟踪用户的身份验证状态、存储用户首选项和其他与用户相关的数据。在Gin框架中,会话的管理通常通过session
和store
两个概念来完成。
在Gin框架中,常用的Session中间件是 github.com/gin-contrib/sessions
,而存储则可以选择不同的后端,例如使用Cookie、Redis、内存等。
在Gin应用中引入该中间件,通过创建Session对象和设定存储引擎,可以方便地进行Session的处理。以下是一个基本的使用示例:
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中的会话数据。github.com/gin-contrib/sessions
还支持其他存储引擎,例如Redis、文件系统等。可以根据实际需求选择合适的存储引擎。// 使用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中间件能够方便地管理会话,而不同的存储引擎则提供了灵活的选择,以适应不同的应用场景。
目录结构
├── 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
文件代码:
package domain
import "time"
// User 领域对象,是DDD 中的 entity
type User struct {
Id int64
Email string
Password string
Ctime time.Time
}
internal/repository/user.go
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
package dao
import "gorm.io/gorm"
func InitTable(db *gorm.DB) error {
return db.AutoMigrate(&User{})
}
internal/repository/dao/user.go
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
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
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
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
}