用户登录 原创
1、定义 dao 方法
登录需要校验用户名和密码,在 dao 里定义的方法就是通过用户名查询用户信息。
在 internal/app/dao/user.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
中新增如下方法:
// 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
中新增以下代码:
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
中增加以下代码:
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 get github.com/golang-jwt/jwt
3.1.2、开发 jwt 包
在 pkg/jwt
目录中创建 jwt.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
文件,写入以下内容:
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
,写入以下代码,用于获取当前时区时间:
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
中增加以下配置:
......
App:
AppName: "hawkeye"
TIMEZONE: "Asia/Shanghai"
JWTExpireTime: 120
JWTMaxRefreshTime: 86400
JWTSigningKey: 845wCriP4mfGTcG3
......
......
再次,增加配置文件结构体信息,修改 pkg/setting/section.go
,增加的内容如下:
......
type AppSettingS struct {
AppName string
TIMEZONE string
JWTExpireTime int
JWTMaxRefreshTime int
JWTSigningKey string
......
}
......
3.2、增加控制器代码
现在,我们来实现登录的控制器方法,在 internal/app/controllers/api/v1/auth.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, ¶m, requests.ValidAuthLoginRequest); !ok {
return
}
svc := services.New(ctx)
user, err := svc.UserLogin(¶m)
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 中增加以下错误代码:
package errorcode
var (
ErrorAuthLoginFail = NewError(400001, "登录失败,用户名或密码错误")
)
4、添加路由
在 internal/app/routers/auth.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。
{
for _, r := range []injector{
new(routers.UserRouter),
new(routers.AuthRouter),
} {
r.Inject(router.Group("/api/v1"))
}
}
5、测试一下
现在,我们使用 apifox
进行测试,观察是否能够完成正常登录。
创建一个用户登录的测试接口,如下:
注意,这里传递数据的方式是通过json
的方式。
然后发送请求,进行测试,观察返回的结果,如下表示正常:
6、生成 swagger
本节我们新增了 /api/v1/auth/login
接口,所以需要使用 swag init
重新生成一下 swagger 文档,如下:
7、代码版本
本节开发完成后,记得给代码进行标记,如下:
$ git add .
$ git commit -m "新增用户登录模块"