Gin 框架学习实录 · 第10篇:实现用户登录功能与 JWT Token 签发及中间件验证

前言

在之前的几篇文章中,我们已经完成了 Gin 框架的基本入门,涵盖了用户模块的增删改查、Redis 缓存使用、统一响应结构封装等重要功能模块。经过这些基础模块的搭建,我们的项目框架已经有了一个稳固的基础。

接下来,我们将通过实现一个 登录功能 ,引入 JWT(JSON Web Token) 来进行用户认证,确保用户身份的合法性。具体来说,我们会通过以下步骤来实现:

  1. 实现登录接口:用户通过提交用户名和密码进行身份验证。
  2. JWT Token 签发:登录成功后,服务器将生成一个 JWT Token,并返回给客户端,用于后续的身份验证。
  3. JWT 中间件验证:每次请求时,通过中间件验证请求中携带的 JWT Token,确保用户的身份合法。

通过这一系列的操作,我们能够实现一个简易的用户认证机制,为后续开发更加复杂的 API 接口和权限管理打下基础。

第一步:实现登录功能 + JWT Token 签发

我们先实现这个接口:

POST /login

json 复制代码
{
  "username": "admin",
  "password": "123456"
}

返回:

json 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {
    "token": "xxxxx.yyyyy.zzzzz"
  }
}

技术拆解 & 方法设计

方法设计清单

方法名 说明
GenerateToken(userID uint) 生成 JWT Token
ParseToken(tokenStr string) 校验 Token 并提取用户 ID
LoginService(username, password string) 登录业务逻辑:校验用户、生成 Token|

1. 安装依赖

首先,我们需要安装 github.com/golang-jwt/jwt/v5 这个库,用于生成和解析 JWT Token。

bash 复制代码
go get github.com/golang-jwt/jwt/v5

2. 创建工具包:utils/jwt.go

接下来,我们创建一个 jwt.go 工具包,封装生成和解析 JWT Token 的功能。在这个文件中,我们定义了一个结构体 Claims,它包括了用户的 ID 和 JWT 的标准字段(比如过期时间、签发时间等)。

go 复制代码
package utils

import (
	"github.com/golang-jwt/jwt/v5"
	"time"
	"errors"
)

var jwtSecret = []byte("my-secret-key") // 可写到 config.yaml

// 自定义声明结构体
type Claims struct {
	UserID uint `json:"user_id"`
	jwt.RegisteredClaims
}

// 生成 Token 的方法
func GenerateToken(userID uint) (string, error) {
	expirationTime := time.Now().Add(24 * time.Hour)

	claims := Claims{
		UserID: userID,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

// 解析 Token 的方法
func ParseToken(tokenStr string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return jwtSecret, nil
	})
	if err != nil {
		return nil, err
	}

	// 校验是否是合法的 Claims
	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		return claims, nil
	}
	return nil, errors.New("invalid token")
}

说明

  • GenerateToken 方法:根据用户 ID 生成一个 JWT Token,并设置过期时间为 24 小时。
  • ParseToken 方法:解析并验证传入的 JWT Token 是否有效,并返回解码后的 Claims。

3. 创建登录请求结构体(request/user_request.go

我们接下来需要定义一个登录请求的结构体,用户提交登录信息时,我们会从这个结构体中获取 usernamepassword,然后在服务器端进行验证。

go 复制代码
type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

4.实现登录逻辑

service/user_service.go 中新增登录逻辑

在服务层,我们新增一个 DoLogin 方法来处理用户登录。该方法根据传入的用户名和密码查找数据库中的用户记录。如果查找到用户并且密码正确,则使用之前封装的 GenerateToken 方法生成并返回 JWT Token。这里我们暂时使用明文密码进行校验,后续可以替换为加密(如 bcrypt)进行校验。

go 复制代码
package service

import (
	"gin-learn-notes/config"
	"gin-learn-notes/model"
	"gin-learn-notes/utils"
)

func DoLogin(username, password string) (string, error) {
	var user model.User
	// 查找数据库中的用户,验证用户名和密码
	if err := config.DB.Where("name= ? AND password= ?", username, password).First(&user).Error; err != nil {
		return "", err
	}

	// 调用封装的 token 生成方法
	token, err := utils.GenerateToken(user.ID)
	if err != nil {
		return "", err
	}

	return token, nil
}

说明:

  • DoLogin :该函数通过用户名和密码查找用户,如果找到并且密码正确,则返回一个 JWT Token。密码校验这里先不加密,后续可以加入 bcrypt 或其他加密方法来增强安全性。

controller/user.go 中新增登录接口

接着,在控制器层我们实现了 Login 方法,这个方法接收前端提交的用户名和密码,调用 DoLogin 逻辑进行验证。如果验证成功,返回 JWT Token,否则返回相应的错误信息。

go 复制代码
package controller

import (
	"gin-learn-notes/request"
	"gin-learn-notes/service"
	"gin-learn-notes/response"
	"github.com/gin-gonic/gin"
)

func Login(c *gin.Context) {
	var req request.LoginRequest
	// 绑定请求体中的参数
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Fail(c, response.ParamError, "参数错误")
		return
	}

	// 调用 service 层的登录逻辑
	token, err := service.DoLogin(req.Username, req.Password)
	if err != nil {
		// 用户名或密码错误
		response.Fail(c, response.Unauthorized, "用户名或密码错误")
		return
	}

	// 返回成功的响应,包含 token
	response.Success(c, gin.H{
		"token": token,
	})
}

说明:

  • Login :该函数接收前端传递的用户名和密码,通过 service.DoLogin 进行验证。如果登录成功,则返回 JWT Token,否则返回 "用户名或密码错误"

注册路由

router/router.go 中,我们添加了 /login 路由,使得客户端能够发送 POST 请求到该接口以进行登录。

go 复制代码
package router

import (
	"github.com/gin-gonic/gin"
	"gin-learn-notes/controller"
)

func InitRouter() *gin.Engine {
	r := gin.Default()

	// 登录路由
	r.POST("/login", controller.Login)

	// 其他路由...

	return r
}

5. 测试登录接口

我们已经实现了登录接口,并且通过 JWT Token 进行认证。接下来,我们进行接口的测试。

1. 启动项目

首先,确保项目已经正常启动。通过以下命令启动我们的 Gin 项目:

bash 复制代码
go run main.go

确认服务已在指定的端口启动。

2. 使用 Postman 或 Curl 测试登录接口

测试请求:

我们使用 Postman 或 Curl 发送一个 POST 请求到 /login 接口,提交 usernamepassword

请求示例:

json 复制代码
POST /login
Content-Type: application/json

{
  "username": "admin",
  "password": "123456"
}

测试响应:

如果用户名和密码正确,返回如下响应:

json 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {
    "token": "your-jwt-token-here"
  }
}

如果用户名或密码错误,返回如下错误信息:

json 复制代码
{
  "code": 10004,
  "msg": "用户名或密码错误",
  "data": null
}

我们之前已经成功实现了登录功能,并使用 JWT Token 进行身份验证。在测试登录接口时,返回的示例数据如下:

json 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {
    "token": "your-jwt-token-here"
  }
}

这个响应返回了用户的 JWT Token。然而,在实际开发中,登录接口不仅仅返回用户的 Token ,可能还会返回其他的用户信息,比如 用户 ID用户名。因此,我们需要对现有的登录逻辑进行一些修改,以便返回更多用户信息。

如何修改登录返回结果?

为了在登录返回结果中不仅仅返回 token,还要返回 userIDusername,我们可以通过新增一个返回结构体专门用于登录接口的响应数据。这个结构体可以最初放在 user_service.go 文件中,但为了更好的代码结构和复用性,我们可以将其拆分到独立的文件中。

拆分结构体

为了避免结构体定义过于集中在业务逻辑中,并提升代码的可维护性,我们将 LoginResponse 结构体提取到一个单独的文件中。通常,这种结构体可以放在 response 包下。

原因:

考虑到后续项目接口越来越多,若将所有响应结构体放在一个 response.go 文件中会变得非常臃肿且不易维护。为了避免这种情况,建议按 模块功能 对响应结构体进行拆分管理。每个模块对应一个单独的响应文件,保持代码的清晰和结构化。

因此,我们可以按照模块划分,在 response 目录下创建 user_response.go 文件,并将登录返回的结构体 LoginResult 放入该文件中。

步骤:修改和拆分结构体

1. 创建 response/user_response.go 文件

response 文件夹下,我们新建 user_response.go 文件,用于存放与用户相关的所有响应结构体。

response/user_response.go

go 复制代码
package response

// LoginResult 是用户登录的返回结构
type LoginResult struct {
	Token    string `json:"token"`
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
}

2. 修改 user_service.go

service/user_service.go 中,我们调用新的 LoginResult 返回结构体,并修改 DoLogin 函数,使其返回用户信息以及 token

go 复制代码
package service

import (
	"gin-learn-notes/config"
	"gin-learn-notes/model"
	"gin-learn-notes/utils"
	"gin-learn-notes/response"
	"errors"
)

// 登录验证逻辑
func DoLogin(username, password string) (*response.LoginResult, error) {
	var user model.User
	if err := config.DB.Where("name = ? AND password = ?", username, password).First(&user).Error; err != nil {
		return nil, errors.New("用户名或密码错误")
	}

	// 生成 Token
	token, err := utils.GenerateToken(user.ID)
	if err != nil {
		return nil, err
	}

	// 返回 token 和用户信息
	return &response.LoginResult{
		Token:    token,
		UserID:   user.ID,
		Username: user.Name,
	}, nil
}

3. 修改控制器 controller/user.go

在控制器中,我们通过调用 DoLogin 返回的结构体,发送用户信息和 token 给前端。

go 复制代码
package controller

import (
	"gin-learn-notes/request"
	"gin-learn-notes/service"
	"gin-learn-notes/response"
	"github.com/gin-gonic/gin"
)

func Login(c *gin.Context) {
	var req request.LoginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Fail(c, response.ParamError, "参数错误")
		return
	}

	// 调用 DoLogin 进行登录验证
	res, err := service.DoLogin(req.Username, req.Password)
	if err != nil {
		response.Fail(c, response.Unauthorized, err.Error())
		return
	}

	// 返回 token 和用户信息
	response.Success(c, gin.H{
		"token":    res.Token,
		"user_id":  res.UserID,
		"username": res.Username,
	})
}

4. 路由配置

确保在路由中添加 /login 接口:

go 复制代码
package router

import (
	"github.com/gin-gonic/gin"
	"gin-learn-notes/controller"
)

func InitRouter() *gin.Engine {
	r := gin.Default()

	// 登录路由
	r.POST("/login", controller.Login)

	// 其他路由...

	return r
}

然后我们再调用登陆接口就可以看到返回结果如下:

总结

  1. 拆分结构体 :为了更好的代码复用和维护性,我们将 LoginResponse 结构体从 service 中拆分出来,放入 response/user_response.go 文件中。
  2. 模块化管理响应结构体:考虑到后续接口会增多,我们将响应结构体按模块进行拆分管理,确保每个模块的响应结构体文件清晰明了,便于维护。
  3. 修改 DoLogin 和控制器 :在 service 层,我们修改了 DoLogin 函数,使其返回 token 和用户信息;在控制器中,我们相应地修改了 Login 函数,将 token 和用户信息一起返回给前端。

这样,我们的登录接口不仅返回了 token,还返回了用户的 ID 和用户名,同时通过模块化管理响应结构体,使得项目代码结构更加清晰。


第二步:JWT 鉴权中间件开发

在本步骤中,我们实现一个用于验证 JWT Token 的中间件 JWTAuthMiddleware,它的主要功能是:

  1. 从请求头提取 token :自动从请求的 Authorization 头中提取 Bearer <token>
  2. 解析 Token :调用 utils.ParseToken() 解析并验证 JWT 的有效性。
  3. 获取用户信息 :从解析出的 claims 中获取 userID
  4. 注入上下文 :将 userID 注入到 Gin 的上下文中,供后续的业务逻辑使用。
  5. 验证失败时返回 401:如果 Token 无效或过期,则返回 401 错误,提示无权限访问。

方法设计清单

方法名 作用
JWTAuthMiddleware() 返回一个 Gin 中间件,用于处理 Token 校验
ParseToken(token string) 解析 JWT,已封装在 utils/jwt.go
c.Set("userID", userID) userID 注入到 Gin 上下文,供后续业务使用

1. 创建中间件文件:middleware/jwt_auth.go

首先,我们在 middleware 目录下创建了 jwt_auth.go 文件,并实现了 JWTAuthMiddleware 中间件。这个中间件的主要功能是从请求的 Authorization 头部获取 JWT Token,验证其有效性,并将 userID 注入到 Gin 的上下文中。如果验证失败,则返回对应的错误信息和错误码。

go 复制代码
package middleware

import (
	"gin-learn-notes/core/response"
	"gin-learn-notes/utils"
	"github.com/gin-gonic/gin"
	"strings"
)

func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 从 header 获取 Authorization
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			response.Fail(c, response.InvalidToken, "Token无效或者不存在")
			c.Abort()
			return
		}

		// 处理 "Bearer xxxxx" 格式
		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			response.Fail(c, response.TokenFormatError, "Token格式错误")
			c.Abort()
			return
		}

		// 解析 token
		claims, err := utils.ParseToken(parts[1])
		if err != nil {
			response.Fail(c, response.TokenExpired, "Token无效或已过期")
			c.Abort()
			return
		}

		// 注入 userID 到上下文
		c.Set("userID", claims.UserID)

		// 放行
		c.Next()
	}
}

中间件说明:

  • JWTAuthMiddleware:此中间件会从请求头中获取 Token,解析并校验 Token 的有效性。如果 Token 有效,则将 userID 注入到上下文 c.Set("userID", userID),供后续操作使用;如果验证失败,则返回相应的错误信息(如 Token 格式错误或已过期)。

新增错误码

core/response/code.go 中新增了以下三个错误码,以便在 Token 校验失败时进行响应:

go 复制代码
package response

const (
	Success      = 0
	ParamError   = 10001
	NotFound     = 10002
	DBError      = 10003
	Unauthorized = 10004
	ServerError  = 10005

	// 新增错误码
	InvalidToken    = 10006 // Token 无效或不存在
	TokenFormatError = 10007 // Token 格式错误
	TokenExpired     = 10008 // Token 已过期
)

2.注册路由中使用中间件

router 配置中,我们将受保护的接口放在一个新的路由组中,使用 JWTAuthMiddleware 中间件来进行保护,确保只有通过认证的用户才能访问。

go 复制代码
import (
	"gin-learn-notes/middleware"
)

func InitRouter() *gin.Engine {
	r := gin.Default()

	// 登录接口,无需 token
	r.POST("/login", controller.Login)

	// 需要登录的接口分组
	auth := r.Group("/api")
	auth.Use(middleware.JWTAuthMiddleware())
	{
	    auth.POST("/profile", controller.GetUserProfile)
	}

	return r
}

3.在业务中取出 userID

在中间件中,我们将 userID 注入到了 Gin 的上下文中。在后续的业务逻辑中,我们可以从上下文中提取 userID,进行权限验证或获取用户信息。

go 复制代码
userIDRaw, exists := c.Get("userID")
if !exists {
	response.Fail(c, 401, "用户未登录")
	return
}

userID := userIDRaw.(uint) // 类型断言

为了简化获取 userID 的过程,我们可以封装一个获取 userID 的方法,放在 utils/context.go 中:

go 复制代码
func GetUserID(c *gin.Context) uint {
	if userIDRaw, exists := c.Get("userID"); exists {
		if userID, ok := userIDRaw.(uint); ok {
			return userID
		}
	}
	return 0
}

调用时可以这样简洁:

go 复制代码
userID := utils.GetUserID(c)

4. 测试步骤

  1. 登录: 通过 /login 获取 token
  2. 请求受保护接口: 请求 /api/profile 接口时,附带 Authorization: Bearer <token>,中间件会自动拦截并校验 token
  3. 中间件校验: 中间件验证通过后,userID 被注入到上下文中,后续的处理可以直接从上下文中获取用户信息。

验证 /api/profile 接口:JWT 鉴权

为了验证 JWT 鉴权的功能,我们将修改 GetUserProfile 方法,使其能够从上下文中获取 userID,而不是通过请求结构体获取。这将确保只有通过 JWT 鉴权的用户才能访问该接口。

修改 GetUserProfile 方法

我们将在 controller/user.go 中修改 GetUserProfile 方法,改为从上下文中获取 userID,而不是请求结构体中的 ID。具体修改如下:

go 复制代码
package controller

import (
	"gin-learn-notes/response"
	"gin-learn-notes/service"
	"gin-learn-notes/utils"
	"github.com/gin-gonic/gin"
)

func GetUserProfile(c *gin.Context) {
	// 从上下文中获取用户ID
	userID := utils.GetUserID(c)
	if userID == 0 {
		// 如果用户ID为0,说明用户未登录或Token无效
		response.Fail(c, response.Unauthorized, "未登录或 token 无效")
		return
	}

	// 使用缓存获取用户信息
	user, err := service.GetUserProfileWithCache(userID)
	if err != nil {
		// 如果用户不存在
		response.Fail(c, response.NotFound, "用户不存在")
		return
	}

	// 返回用户信息
	response.Success(c, user)
}

说明:

  • userID := utils.GetUserID(c):从 Gin 上下文中获取 userID,它是在 JWTAuthMiddleware 中间件中注入的。
  • 如果 userID0,说明 Token 无效或者用户未登录,这时返回 Unauthorized 错误。
  • 通过 service.GetUserProfileWithCache(userID) 获取用户信息,并返回。

接口测试流程

  1. 登录获取 Token:

    • 首先,通过 /login 接口使用用户名和密码登录,获取返回的 Token。
    • 请求示例:
    bash 复制代码
    POST /login
    Content-Type: application/json
    
    {
        "username": "admin",
        "password": "123456"
    }
    • 响应示例:
    json 复制代码
    {
        "code": 0,
        "msg": "success",
        "data": {
            "token": "your-jwt-token-here"
             ...
        }
    }
  2. 请求 /api/profile 接口:

  • 获取到 Token 后,使用 Authorization 头部携带 Bearer <token> 请求 /api/profile 接口。
  • 请求示例:
bash 复制代码
POST /api/profile
Authorization: Bearer <your-jwt-token-here>
  • 响应示例(假设用户存在):
json 复制代码
{
    "code": 0,
    "msg": "success",
    "data": {
        "id": 1,
        "name": "user1",
        "age": 25
    }
}
  • 如果 Token 无效或者过期,接口会返回:
json 复制代码
{
    "code": 10006,
    "msg": "Token无效或者不存在",
    "data": null
}

总结

  1. 修改 GetUserProfile 方法 :我们将 GetUserProfile 方法从通过请求结构体获取用户 ID 改为从上下文中获取 userID,确保只有经过 JWT 鉴权的用户才能访问该接口。
  2. 中间件处理JWTAuthMiddleware 中间件会验证请求头中的 Token,确保请求是合法的。如果验证通过,将 userID 注入到上下文中,供后续的接口使用。
  3. 接口测试 :我们测试了登录获取 Token 后,如何通过带 Token 的请求访问受保护的 /api/profile 接口。并且验证了 Token 无效时,接口会返回相应的错误信息。

通过这一系列步骤,我们就已经简单实现了 JWT 鉴权和用户信息获取功能使用。


项目结构继续演进

go 复制代码
gin-learn-notes/
├── config/
│   ├── config.go        // 配置加载与管理
│   ├── database.go      // 数据库连接配置
│   └── redis.go         // Redis 配置
├── controller/
│   ├── hello.go         // 测试接口
│   ├── index.go         // 首页接口
│   └── user.go          // 用户模块接口
├── core/
│   └── response/        // 统一响应模块
│       ├── code.go      // 错误码定义
│       ├── page.go      // 分页响应结构
│       └── response.go  // 统一响应结构体
├── logger/
│   └── logger.go        // 日志配置与处理
├── logs/
│   └── app.log          // 日志文件
├── middleware/
│   └── jwt_auth.go      // JWT 鉴权中间件
├── model/
│   └── user.go          // 用户数据模型
├── request/
│   ├── page_request.go  // 分页请求参数
│   └── user_request.go  // 用户请求参数
├── response/
│   └── user_response.go // 用户模块响应结构
├── router/
│   └── router.go        // 路由配置
├── service/
│   └── user_service.go  // 用户模块服务层
├── utils/
│   ├── context.go       // 上下文工具
│   ├── jwt.go           // JWT 生成与解析工具
│   ├── paginate.go      // 分页工具
│   ├── redis.go         // Redis 工具
│   ├── response.go      // 通用响应工具
│   └── validator.go     // 验证工具
├── config.yaml          // 全局配置文件
├── go.mod               // Go Modules 配置
├── LICENSE              // 开源协议
├── main.go              // 项目入口
└── README.md            // 项目说明

本篇对应代码提交记录

commit: 51e0668b35603642683f0651067989f480df93cd

👉 GitHub 源码地址:github.com/luokakale-k...

相关推荐
研究司马懿5 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大18 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo