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/base64Captcha2. 登录请求与响应
登录接口:
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>。
