Skip to content

Gin 实战 10:登录认证与 JWT

用户 CRUD 有了之后,后台系统需要登录认证。本章实现验证码、用户名密码登录、bcrypt 密码校验和 JWT 签发。

1. 安装依赖

bash
go get golang.org/x/crypto/bcrypt
go get github.com/golang-jwt/jwt/v5
go get github.com/mojocn/base64Captcha

2. 登录请求与响应

登录接口:

http
POST /api/v1/auth/login
Content-Type: application/json

请求参数:

json
{
  "username": "admin",
  "password": "123456",
  "captchaId": "captcha_id_from_captcha_api",
  "captchaCode": "1234"
}

成功响应:

json
{
  "code": "00000",
  "msg": "成功",
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
    "tokenType": "Bearer",
    "expiresIn": 7200
  }
}
go
type LoginRequest struct {
    // 用户名和密码来自登录表单。
    Username    string `json:"username" binding:"required"`
    Password    string `json:"password" binding:"required"`
    // captchaId 来自获取验证码接口,captchaCode 是用户输入的验证码。
    CaptchaID   string `json:"captchaId" binding:"required"`
    CaptchaCode string `json:"captchaCode" binding:"required"`
}

type LoginResponse struct {
    // accessToken 用于访问受保护接口。
    AccessToken  string `json:"accessToken"`
    // refreshToken 用于刷新 accessToken。
    RefreshToken string `json:"refreshToken"`
    // tokenType 固定为 Bearer,前端会拼成 Authorization 请求头。
    TokenType    string `json:"tokenType"`
    // expiresIn 告诉前端 accessToken 还有多久过期,单位秒。
    ExpiresIn    int    `json:"expiresIn"`
}

这里和 vue3-element-admin 对齐:验证码字段使用 captchaId/captchaCode

3. 获取图形验证码

go
type CaptchaVO struct {
    // captchaId 用来标识服务端保存的验证码答案。
    CaptchaID     string `json:"captchaId"`
    // captchaBase64 直接给前端 img 标签展示。
    CaptchaBase64 string `json:"captchaBase64"`
}

// captchaStore 保存验证码答案。
// 从生成验证码到登录校验必须使用同一个 store,否则验证码永远校验不通过。
var captchaStore = base64Captcha.DefaultMemStore

func GetCaptcha() (*CaptchaVO, error) {
    // 生成 4 位数字验证码,图片大小为 140x44。
    driver := base64Captcha.NewDriverString(
        44, 140, 0, 0, 4, "23456789", nil, nil,
    )
    // captchaStore 会把验证码答案暂存在内存中。
    // 生产环境多实例部署时可以替换为 Redis 存储。
    captcha := base64Captcha.NewCaptcha(driver, captchaStore)
    id, b64s, err := captcha.Generate()
    if err != nil {
        return nil, err
    }
    return &CaptchaVO{CaptchaID: id, CaptchaBase64: b64s}, nil
}

4. 生成 JWT

go
type Claims struct {
    // 自定义业务字段,后续鉴权中间件会从 Token 中解析出来。
    UserID   int64  `json:"userId"`
    Username string `json:"username"`
    // RegisteredClaims 包含 exp、iat、issuer 等 JWT 标准字段。
    jwt.RegisteredClaims
}

func GenerateToken(user model.User, secret string, ttl time.Duration) (string, error) {
    now := time.Now()
    claims := Claims{
        UserID:   user.ID,
        Username: user.Username,
        RegisteredClaims: jwt.RegisteredClaims{
            // ExpiresAt 控制 Token 过期时间。
            ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
            // IssuedAt 记录签发时间。
            IssuedAt:  jwt.NewNumericDate(now),
        },
    }
    // HS256 使用同一个 secret 完成签名和校验。
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

5. 登录 Service

go
func (s *Service) Login(req LoginRequest) (*LoginResponse, error) {
    // 先校验图形验证码,挡掉一部分脚本撞库请求。
    if !captchaStore.Verify(req.CaptchaID, req.CaptchaCode, true) {
        return nil, errs.BadRequest("验证码错误")
    }

    // 不区分“用户不存在”和“密码错误”,避免账号枚举。
    user, err := s.repo.GetByUsername(req.Username)
    if err != nil {
        return nil, errs.BadRequest("用户名或密码错误")
    }

    // 数据库存的是 bcrypt 哈希,不能明文比较。
    if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
        return nil, errs.BadRequest("用户名或密码错误")
    }

    // accessToken 有效期短,用于日常接口访问。
    accessTTL := time.Duration(config.Cfg.Security.JWT.AccessTokenTTL) * time.Second
    accessToken, err := GenerateToken(user, config.Cfg.Security.JWT.SecretKey, accessTTL)
    if err != nil {
        return nil, err
    }

    // refreshToken 有效期长,用于刷新 accessToken。
    refreshTTL := time.Duration(config.Cfg.Security.JWT.RefreshTokenTTL) * time.Second
    refreshToken, err := GenerateToken(user, config.Cfg.Security.JWT.SecretKey, refreshTTL)
    if err != nil {
        return nil, err
    }

    return &LoginResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    config.Cfg.Security.JWT.AccessTokenTTL,
    }, nil
}

6. 登录接口

go
func (h *Handler) Captcha(c *gin.Context) {
    // 前端先调用验证码接口,拿到 captchaId 和验证码图片。
    captcha, err := GetCaptcha()
    if err != nil {
        c.Error(errs.System("生成验证码失败"))
        return
    }

    response.Ok(c, captcha)
}

func (h *Handler) Login(c *gin.Context) {
    var req LoginRequest
    // 绑定 JSON 请求体,同时执行 binding:"required" 校验。
    if err := c.ShouldBindJSON(&req); err != nil {
        c.Error(errs.BadRequest("参数错误"))
        return
    }

    // 具体登录规则放到 Service,Handler 保持薄。
    token, err := h.service.Login(req)
    if err != nil {
        c.Error(err)
        return
    }

    response.Ok(c, token)
}

func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
    auth := r.Group("/auth")

    // 获取图形验证码:GET /api/v1/auth/captcha
    auth.GET("/captcha", h.Captcha)

    // 用户名、密码、验证码登录:POST /api/v1/auth/login
    auth.POST("/login", h.Login)
}

登录完成后,前端拿到 accessToken,后续请求放到 Authorization: Bearer <token>

基于 MIT 许可发布 · 如需部署协助或二开定制,请查看 支持与合作