Skip to content

用户登录 原创

1、定义 dao 方法

登录需要校验用户名和密码,在 dao 里定义的方法就是通过用户名查询用户信息。

internal/app/dao/user.go 中增加如下代码:

go
// UserGetByName 根据用户名获取用户信息
func (d *Dao) UserGetByName(username string) (*models.User, error) {
	user := models.User{
		Username: username,
	}
	return user.GetByName(d.engine)
}

上面使用了 user.GetByName,所以在 internal/app/models/user.go 中新增如下方法:

go
// GetByName 通过用户名查找用户信息
func (u *User) GetByName(db *gorm.DB) (*User, error) {
	var user *User
	if u.Username != "" {
		db.Where("username=? and is_del=?", u.Username, 0).First(&user)
	}
	return user, nil
}

2、新增 services 方法

2.1、新增参数校验

internl/app/requests/auth.go 中新增以下代码:

go
package requests

import (
	"github.com/gin-gonic/gin"
	"github.com/joker-bai/hawkeye/pkg/app"
	"github.com/thedevsaddam/govalidator"
)

type AuthLoginRequest struct {
	Username string `json:"username" form:"username" valid:"username"`
	Password string `json:"password" form:"password" valid:"password"`
}

func ValidAuthLoginRequest(data interface{}, ctx *gin.Context) map[string][]string {
	rules := govalidator.MapData{
		"username": []string{"required"},
		"password": []string{"required", "min:6"},
	}
	messages := govalidator.MapData{
		"username": []string{
			"required: 用户名为必填字段,字段为 username",
		},
		"password": []string{
			"required: 密码为必填字段,字段为 password",
			"min:密码长度需大于 6",
		},
	}

	// 校验入参
	return app.ValidateOptions(data, rules, messages)
}

2.3、新增 services 方法

internal/app/services/auth.go 中增加以下代码:

go
package services

import (
	"github.com/joker-bai/hawkeye/internal/app/models"
	"github.com/joker-bai/hawkeye/internal/app/requests"
)

// UserLogin 用户登录
func (s *Services) UserLogin(param *requests.AuthLoginRequest) (*models.User, error) {
	return s.dao.UserGetByName(param.Username)
}

3、新建 controllers 方法

3.1、JWT 授权

当用户登录过后,我们需要给用户颁发 access token,以便用户在下次操作的时候不需要再次认证。

go
$ go get github.com/golang-jwt/jwt

3.1.2、开发 jwt 包

pkg/jwt 目录中创建 jwt.go,写入以下代码:

go
package jwt

import (
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	jwtpkg "github.com/golang-jwt/jwt"
	"github.com/joker-bai/hawkeye/global"
	"github.com/joker-bai/hawkeye/pkg/errorcode"
	"github.com/joker-bai/hawkeye/pkg/tools"
)

// JWT 定义一个jwt结构体
type JWT struct {
	SignKey    []byte        // 密钥,用于加密JWT
	MaxRefresh time.Duration // 刷新Token的最大过期时间
}

// JWTCustomClaims 自定义载荷
type JWTCustomClaims struct {
	UserID       string `json:"user_id"`
	UserName     string `json:"user_name"`
	ExpireAtTime int64  `json:"expire_time"`

	// StandardClaims 结构体实现了 Claims 接口继承了  Valid() 方法
	// JWT 规定了7个官方字段,提供使用:
	// - iss (issuer):发布者
	// - sub (subject):主题
	// - iat (Issued At):生成签名的时间
	// - exp (expiration time):签名过期时间
	// - aud (audience):观众,相当于接受者
	// - nbf (Not Before):生效时间
	// - jti (JWT ID):编号
	jwtpkg.StandardClaims
}

// NewJWT 初始化JWT
func NewJWT() *JWT {
	return &JWT{
		SignKey:    []byte(global.AppSetting.JWTSigningKey),
		MaxRefresh: time.Duration(global.AppSetting.JWTMaxRefreshTime) * time.Minute,
	}
}

// ParserToken 解析 Token,中间件中调用
func (jwt *JWT) ParserToken(c *gin.Context) (*JWTCustomClaims, error) {

	tokenString, parseErr := jwt.getTokenFromHeader(c)
	if parseErr != nil {
		return nil, parseErr
	}

	// 1. 调用 jwt 库解析用户传参的 Token
	token, err := jwt.parseTokenString(tokenString)

	// 2. 解析出错
	if err != nil {
		validationErr, ok := err.(*jwtpkg.ValidationError)
		if ok {
			if validationErr.Errors == jwtpkg.ValidationErrorMalformed {
				return nil, errorcode.ErrTokenMalformed
			} else if validationErr.Errors == jwtpkg.ValidationErrorExpired {
				return nil, errorcode.ErrTokenExpired
			}
		}
		return nil, errorcode.ErrTokenInvalid
	}

	// 3. 将 token 中的 claims 信息解析出来和 JWTCustomClaims 数据结构进行校验
	if claims, ok := token.Claims.(*JWTCustomClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, errorcode.ErrTokenInvalid
}

// RefreshToken 更新 Token,用以提供 refresh token 接口
func (jwt *JWT) RefreshToken(c *gin.Context) (string, error) {

	// 1. 从 Header 里获取 token
	tokenString, parseErr := jwt.getTokenFromHeader(c)
	if parseErr != nil {
		return "", parseErr
	}

	// 2. 调用 jwt 库解析用户传参的 Token
	token, err := jwt.parseTokenString(tokenString)

	// 3. 解析出错,未报错证明是合法的 Token(甚至未到过期时间)
	if err != nil {
		validationErr, ok := err.(*jwtpkg.ValidationError)
		// 满足 refresh 的条件:只是单一的报错 ValidationErrorExpired
		if !ok || validationErr.Errors != jwtpkg.ValidationErrorExpired {
			return "", err
		}
	}

	// 4. 解析 JWTCustomClaims 的数据
	claims := token.Claims.(*JWTCustomClaims)

	// 5. 检查是否过了『最大允许刷新的时间』
	x := tools.TimenowInTimezone().Add(-jwt.MaxRefresh).Unix()
	if claims.IssuedAt > x {
		// 修改过期时间
		claims.StandardClaims.ExpiresAt = jwt.expireAtTime()
		return jwt.createToken(*claims)
	}

	return "", errorcode.ErrTokenExpiredMaxRefresh
}

// IssueToken 生成  Token,在登录成功时调用
func (jwt *JWT) IssueToken(userID string, userName string) string {

	// 1. 构造用户 claims 信息(负荷)
	expireAtTime := jwt.expireAtTime()
	claims := JWTCustomClaims{
		userID,
		userName,
		expireAtTime,
		jwtpkg.StandardClaims{
			NotBefore: tools.TimenowInTimezone().Unix(), // 签名生效时间
			IssuedAt:  tools.TimenowInTimezone().Unix(), // 首次签名时间(后续刷新 Token 不会更新)
			ExpiresAt: expireAtTime,                     // 签名过期时间
			Issuer:    global.AppSetting.AppName,        // 签名颁发者
		},
	}

	// 2. 根据 claims 生成token对象
	token, err := jwt.createToken(claims)
	if err != nil {
		global.Log.Error(err.Error())
		return ""
	}

	return token
}

// createToken 创建 Token,内部使用,外部请调用 IssueToken
func (jwt *JWT) createToken(claims JWTCustomClaims) (string, error) {
	// 使用HS256算法进行token生成
	token := jwtpkg.NewWithClaims(jwtpkg.SigningMethodHS256, claims)
	return token.SignedString(jwt.SignKey)
}

// expireAtTime 过期时间
func (jwt *JWT) expireAtTime() int64 {
	timenow := tools.TimenowInTimezone()

	expireTime := int64(global.AppSetting.JWTExpireTime)

	expire := time.Duration(expireTime) * time.Minute
	return timenow.Add(expire).Unix()
}

// parseTokenString 使用 jwtpkg.ParseWithClaims 解析 Token
func (jwt *JWT) parseTokenString(tokenString string) (*jwtpkg.Token, error) {
	return jwtpkg.ParseWithClaims(tokenString, &JWTCustomClaims{}, func(token *jwtpkg.Token) (interface{}, error) {
		return jwt.SignKey, nil
	})
}

// getTokenFromHeader 使用 jwtpkg.ParseWithClaims 解析 Token
// Authorization:Bearer xxxxx
func (jwt *JWT) getTokenFromHeader(c *gin.Context) (string, error) {
	authHeader := c.Request.Header.Get("Authorization")
	if authHeader == "" {
		return "", errorcode.ErrHeaderEmpty
	}
	// 按空格分割
	parts := strings.SplitN(authHeader, " ", 2)
	if !(len(parts) == 2 && parts[0] == "Bearer") {
		return "", errorcode.ErrHeaderMalformed
	}
	return parts[1], nil
}

上面使用到 errorcode 包中的一些错误码,在 pkg/errorcode 目录中创建 token.go 文件,写入以下内容:

go
package errorcode

var (
	ErrTokenExpired           = NewError(300001, "令牌已过期")
	ErrTokenExpiredMaxRefresh = NewError(300002, "令牌已过最大刷新时间")
	ErrTokenMalformed         = NewError(300003, "请求令牌格式有误")
	ErrTokenInvalid           = NewError(300004, "请求令牌无效")
	ErrHeaderEmpty            = NewError(300005, "需要认证才能访问!")
	ErrHeaderMalformed        = NewError(300006, "请求头中 Authorization 格式有误")
)

再在 pkg/tools 目录中创建 timezone.go,写入以下代码,用于获取当前时区时间:

go
package tools

import (
	"time"

	"github.com/joker-bai/kubemana/global"
)

// TimenowInTimezone 获取当前时间,支持时区
func TimenowInTimezone() time.Time {
	chinaTimezone, _ := time.LoadLocation(global.AppSetting.TIMEZONE)
	return time.Now().In(chinaTimezone)
}

3.1.3、添加配置文件

jwt 包中使用到很多配置文件,现在在配置文件中增加它们。

首先,在configs/config.yaml中增加以下配置:

yaml
......
App:
  AppName: "hawkeye"
  TIMEZONE: "Asia/Shanghai"
  JWTExpireTime: 120
  JWTMaxRefreshTime: 86400
  JWTSigningKey: 845wCriP4mfGTcG3
  ......
......

再次,增加配置文件结构体信息,修改 pkg/setting/section.go,增加的内容如下:

go
......
type AppSettingS struct {
	AppName           string
	TIMEZONE          string
	JWTExpireTime     int
	JWTMaxRefreshTime int
	JWTSigningKey     string
    ......
}
......

3.2、增加控制器代码

现在,我们来实现登录的控制器方法,在 internal/app/controllers/api/v1/auth.go 文件中新增如下代码:

go
package v1

import (
	"github.com/gin-gonic/gin"
	"github.com/joker-bai/hawkeye/global"
	"github.com/joker-bai/hawkeye/internal/app/requests"
	"github.com/joker-bai/hawkeye/internal/app/services"
	"github.com/joker-bai/hawkeye/pkg/app"
	"github.com/joker-bai/hawkeye/pkg/errorcode"
	"github.com/joker-bai/hawkeye/pkg/jwt"
	"github.com/spf13/cast"
	"go.uber.org/zap"
)

type AuthController struct{}

// Create godoc
// @Summary 用户登录
// @Description 用户登录
// @Tags 认证管理
// @Produce json
// @Param body body requests.AuthLoginRequest true "body"
// @Success 200 {object} string "成功"
// @Failure 400 {object} errorcode.Error "请求错误"
// @Failure 500 {object} errorcode.Error "内部错误"
// @Router /api/v1/auth/login [post]
func (u *AuthController) Login(ctx *gin.Context) {
	param := requests.AuthLoginRequest{}
	response := app.NewResponse(ctx)

	if ok := app.Validate(ctx, &param, requests.ValidAuthLoginRequest); !ok {
		return
	}

	svc := services.New(ctx)
	user, err := svc.UserLogin(&param)
	if err != nil {
		global.Log.Error("用户登录失败,", zap.String("error", err.Error()))
		response.ToErrorResponse(errorcode.ErrorAuthLoginFail)
		return
	}

	if user.Password != param.Password {
		global.Log.Error("用户名或密码错误,", zap.String("error", err.Error()))
		response.ToErrorResponse(errorcode.ErrorAuthLoginFail)
		return
	}

	token := jwt.NewJWT().IssueToken(cast.ToString(user.ID), user.Username)

	response.ToResponse(gin.H{
		"data":  user,
		"token": token,
	})
}

再到 pkg/errorcode/auth.go 中增加以下错误代码:

go
package errorcode

var (
	ErrorAuthLoginFail = NewError(400001, "登录失败,用户名或密码错误")
)

4、添加路由

internal/app/routers/auth.go 中,新增以下路由:

go
package routers

import (
	"github.com/gin-gonic/gin"
	v1 "github.com/joker-bai/hawkeye/internal/app/controllers/api/v1"
)

type AuthRouter struct{}

func (a *AuthRouter) Inject(r *gin.RouterGroup) {
	ac := new(v1.AuthController)
	r.POST("/auth/login", ac.Login)
}

然后在initialize/router.go中增加 AuthRouter。

go
{
		for _, r := range []injector{
			new(routers.UserRouter),
			new(routers.AuthRouter),
		} {
			r.Inject(router.Group("/api/v1"))
		}
	}

5、测试一下

现在,我们使用 apifox 进行测试,观察是否能够完成正常登录。

创建一个用户登录的测试接口,如下:

c6491e142176bd491993707ad84aeea1 MD5

注意,这里传递数据的方式是通过json的方式。

然后发送请求,进行测试,观察返回的结果,如下表示正常:

add594755e578030a05dbeda2cfd9bd9 MD5

6、生成 swagger

本节我们新增了 /api/v1/auth/login 接口,所以需要使用 swag init 重新生成一下 swagger 文档,如下:

3c2a5f89c4f1bd6b932dabe46c41d50a MD5

7、代码版本

本节开发完成后,记得给代码进行标记,如下:

go
$ git add .
$ git commit -m "新增用户登录模块"
最近更新