Golang 后端接入 JWT 验证

Golang 后端接入 JWT 验证

前言

JWT 介绍

JSON Web Token(JWT)是一种前后端验证身份的协议。

项目相关

完整代码

https://github.com/deadmau5v/example-go-jwt

项目依赖

github.com/gin-gonic/gin v1.10.0 // HTTP框架
github.com/golang-jwt/jwt/v5 v5.2.1 // jwt 验证库
github.com/joho/godotenv v1.5.1  // 读取环境变量
golang.org/x/crypto v0.28.0  // 加密库
gorm.io/driver/postgres v1.5.9 // 数据库驱动
gorm.io/gorm v1.25.12 // 数据库ORM

项目结构

  • controller 实现用户注册和登录

  • initializers 实现数据库连接和迁移、读取环境变量

  • middleware 实现 gin + jwt 鉴权中间件

  • module 实现 user 模型

代码部分

1.读取环境变量

通过 godotenv.Load()​方法读取环境变量后,在任何地方使用 os.Getenv("Key")​ 就可以获取 .env​ 文件中设置的键值对

// initializers/loadEnv.go
func LoadEnv() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}
}

.env​ 示例

  • PORT HTTP监听的端口

  • JWT_SECRET JWT加密密钥

  • DB_DSN 指定postgresql连接DSN

PORT = 5000
JWT_SECRET = "g734b8X6SvA3a1g734b8X6SvA3a1"
DB_DSN = "host=localhost user=example-go-jwt password=g734b8X6SvA3a1 port=5432 dbname=example-go-jwt"

2. 连接到数据库

使用 Gorm​ 来做数据库和实体类的映射

什么是ORM?

通过 .env​ 设置的 DB_DNS​ 连接到 Postgresql

连接后其他模块可以通过 initializers.DB​ 这个全局变量来调用

// initializers/connectToDb.go
var DB *gorm.DB

func ConnectToPostgres() {
	var err error

	dsn := os.Getenv("DB_DSN")
	DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}
}

3. 定义模型并迁移

module​ 包中,定义User​结构体,继承gorm.Module​来获得ID、updateTime等基础字段。

自定义 Email​、Password​字段,并定义unique(唯一,不可重复)

// module/UserModule.go

type User struct {
	gorm.Model
	Email    string `gorm:"unique"`
	Password string
}

定义完成结构体后,对模型进行迁移。

// initializers/syncDb.go

import "github.com/deadmau5v/example-go-jwt/module"

func SyncDb() {
	DB.AutoMigrate(&module.User{})
	// 更多其他模型...
}

4. 在 main​ 中执行初始化

func init() {
	// 读取环境变量
	initializers.LoadEnv()
	// 连接数据库
	initializers.ConnectToPostgres()
	// 迁移
	initializers.SyncDb()
}

5. 实现HTTP服务

监听 .env​ 设置的端口

// main.go

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

	app.Run(":" + os.Getenv("PORT"))
}

6. 实现注册功能

首先定义用户发过来的结构requestBody​。

通过 ctx.BindJSON​ 方法,绑定POST的Json数据,如果是Form类型的POST,直接使用ctx.PostForm("xxx")​ 获取相应的值。

如果绑定失败或者参数不合法就返回错误信息,返回错误信息后记得要return​,防止程序继续往下执行。

如果参数合法并且用户未被注册,则加密密码并创建。(不应该直接保存密码明文)

通过 bcrypt.GenerateFromPassword​ 加密密码并保存,每次用户登录通过CompareHashAndPassword​方法对比加密后的密码即可。


func SingUp(ctx *gin.Context) {
	// 获取请求参数 email 和 password
	var requestBody struct {
		Email    string `json:"email" binding:"required,email"`
		Password string `json:"password" binding:"required,min=6"`
	}

	err := ctx.BindJSON(&requestBody)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
		return
	}

	// 验证参数
	if strings.TrimSpace(requestBody.Email) == "" || strings.TrimSpace(requestBody.Password) == "" {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
		return
	}

	// 查询用户是否存在
	var user module.User
	result := initializers.DB.Where("email = ?", requestBody.Email).First(&user)
	if result.RowsAffected > 0 {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "用户已存在"})
		return
	}

	// 加密密码
	hash, err := bcrypt.GenerateFromPassword([]byte(requestBody.Password), bcrypt.DefaultCost)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误 uc45"})
		return
	}

	// 创建用户
	user = module.User{
		Email:    requestBody.Email,
		Password: string(hash),
	}

	result = initializers.DB.Create(&user)
	if result.Error != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误 uc57"})
		return
	}

	// 返回
	ctx.JSON(http.StatusOK, gin.H{"message": "注册成功"})
}

7. 实现登录并创建JWT Token

定义用户请求模型。

通过 CompareHashAndPassword​ 对比数据库已加密的密码和用户发送的密码。

如果密码正确则将此用户的 user.ID​ 放到 JWT 中加密 生成Token 发送给用户。

设置 exp​ 过期时间为30天。

生成后设置到用户的Cookie中,并设置SameSite​为严格模式,防止跨域请求。

设置在Cookie的好处是,浏览器会在过期后自动删除用户Cookie。

用户登陆后,携带这个Token再次访问需要授权的API,通过Gin的中间件实现放行和拦截。


func Login(ctx *gin.Context) {
	// 获取请求参数 email 和 password
	var requestBody struct {
		Email    string `json:"email" binding:"required,email"`
		Password string `json:"password" binding:"required,min=6"`
	}

	ctx.BindJSON(&requestBody)

	// 判断参数是否合法
	if strings.TrimSpace(requestBody.Email) == "" || strings.TrimSpace(requestBody.Password) == "" {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
		return
	}

	// 判断加密后的密码是否相等
	var user module.User
	initializers.DB.First(&user, "email = ?", requestBody.Email)

	if user.ID == 0 {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
		return
	}

	err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(requestBody.Password))
	if err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "密码错误"})
		return
	}

	// 生成 token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"sub": user.ID,
		"exp": time.Now().Add(time.Hour * 24 * 30).Unix(),
	})

	tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误 uc103"})
		return
	}

	ctx.SetSameSite(http.SameSiteStrictMode)
	ctx.SetCookie("Authorization", tokenString, 3600*24*30, "/", "", false, true)

	// 返回
	ctx.JSON(http.StatusOK, gin.H{"status": "success"})
	return
}

8. 实现Gin JWT 鉴权中间件

首先获取用户携带的Cookie,

如果没有携带则直接使用 ctx.Abort​方法拦截掉请求,返回401错误信息。

通过 jwt.ParseWithClaims​ 解密token内容,参数是.env​中设置的 JWT_SECRET​ ,

只有拥有这个 Secret 才能解密这个信息,用户篡改任意字符就会导致直接失效。

解密 token内容后 验证是否过期,或是用户是否存在。

通过验证后通过ctx.Set​设置一个 user 到上下文中。

什么是 Context?

func RequrieAuth(ctx *gin.Context) {
	// 获取请求头的Cookie  Authorization
	jwt_token, err := ctx.Cookie("Authorization")
	if err != nil {
		log.Fatal(err)
		ctx.JSON(401, gin.H{"error": "内部错误 ra17"})
		ctx.Abort()
		return
	}

	// 如果没有直接返回401
	if strings.TrimSpace(jwt_token) == "" {
		ctx.JSON(401, gin.H{"error": "请登录"})
		ctx.Abort()
		return
	}

	// 解析验证token
	token, err := jwt.ParseWithClaims(jwt_token, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
		return []byte(os.Getenv("JWT_SECRET")), nil
	}, jwt.WithLeeway(5*time.Second))
	if err != nil {
		log.Fatal(err)
		ctx.JSON(401, gin.H{"error": "内部错误 ra31"})
		ctx.Abort()
		return
	} else if claims, ok := token.Claims.(jwt.MapClaims); ok {
		userId := claims["sub"].(float64)
		timeStep := claims["exp"].(float64)

		if timeStep < float64(time.Now().Unix()) {
			ctx.JSON(401, gin.H{"error": "登录过期"})
			ctx.Abort()
			return
		}

		var user module.User
		initializers.DB.First(&user, userId)

		if user.ID == 0 {
			ctx.JSON(401, gin.H{"error": "无效的登录"})
			ctx.Copy().AbortWithStatus(http.StatusUnauthorized)
		}

		ctx.Set("user", user)

	} else {
		ctx.JSON(401, gin.H{"error": "无效的登录"})
		ctx.Abort()
		return
	}

	// 放行
	ctx.Next()

}

9. 实现权限验证API

实现一个简单的权限验证API,通过 ctx.Get​ 获取上下文中的 user​ 数据,

如果用户没有登陆就会直接被 RequrieAuth​ 拦截掉

指定user类型 _user.(module.User)

func ValiDate(ctx *gin.Context) {
	_user, _ := ctx.Get("user")
	user := _user.(module.User)

	ctx.JSON(http.StatusOK, gin.H{"status": "hello " + user.Email})
}

10. 设置路由

main.go​ 中决定哪些需要使用到鉴定权限中间件。

像注册、登录这种API,并不需要权限验证。

定义 api​ 里面绑定需要验证的路由

定义 noAuth​ 里面绑定无须验证的路由

func main() {
	app := gin.Default()
	api := app.Group("/api")
	api.Use(middleware.RequrieAuth)
	api.GET("/validate", controller.ValiDate)

	noAuth := app.Group("/api")
	noAuth.POST("/signup", controller.SingUp)
	noAuth.POST("/login", controller.Login)

	app.Run(":" + os.Getenv("PORT"))
}

评论