最近学习go语言,学习gin框架。目前已经知道gin的基本用法后,想找一个开源的项目来通过读代码的方式,详细学习下gin框架。一定要带着目的来学习,我的目的就是:学习gin后端项目布局,api设计,jwt鉴权,数据库设计,中间件使用。
开源项目链接:gin-vue-admin
在把项目部署起来后,可以通过查看前端代码/浏览器开发者模式来查看如何调用api的,以此来理解整个流程。
首先学习jwt鉴权相关。jwt鉴权就是通过用户密码,发送给服务器后,服务器在校验通过会返回一个token值。后续请求服务器时带上这个token值,服务器就可以通过身份识别。
1 查看运行流程
可以直接在登录页面进行刷新,会请求/api/base/captcha
接口,方法为GET
此接口的主要目的是为了获取验证码,返回的data结果如下:
{
"captchaId": "qncrO0JmNABRyeHcxOFa",
"picPath": "xxxxxxx(省略)",
"captchaLength": 6
}
可见返回的信息中主要是三个值:
- captchaId: 验证码id
- picPath: 图片
- captchaLength: 验证码长度
来输入用户密码验证码后,发送给服务端。在点击发送后,可以看到,服务器请求了/api/base/login
接口,方法为POST
查看请求体为:
{
"username":"admin",
"password":"123456",
"captcha":"954566",
"captchaId":"qncrO0JmNABRyeHcxOFa"
}
这里采用了明文传输密码,在实际开发的过程中要避免这样,会引起安全风险。解决方案有很多在此不做过多赘述
里边主要包含了四个参数:
- username:用户名
- password: 密码
- captcha: 验证码
- captchaId: 验证码id
通过验证码和验证码id可以确保验证法是否输入正确,通过用户名和密码可以判定是否密码输入正确。
而此接口返回的数据为:
{
"code":0,
"data":{
"user":{
"ID":1,
"CreatedAt":"2022-07-10T21:29:00.512+08:00",
"UpdatedAt":"2022-07-10T21:29:00.514+08:00",
"uuid":"2885a149-ea02-498e-9ee2-c71d5a53dfd0",
"userName":"admin",
"nickName":"超级管理员",
"sideMode":"dark",
"headerImg":"https://qmplusimg.henrongyi.top/gva_header.jpg",
"baseColor":"#fff",
"activeColor":"#1890ff",
"authorityId":888,
"authority":{
"CreatedAt":"2022-07-10T21:29:00.35+08:00",
"UpdatedAt":"2022-07-10T21:29:00.52+08:00",
"DeletedAt":null,
"authorityId":888,
"authorityName":"普通用户",
"parentId":0,
"dataAuthorityId":null,
"children":null,
"menus":null,
"defaultRouter":"dashboard"
},
"authorities":[
{
"CreatedAt":"2022-07-10T21:29:00.35+08:00",
"UpdatedAt":"2022-07-10T21:29:00.52+08:00",
"DeletedAt":null,
"authorityId":888,
"authorityName":"普通用户",
"parentId":0,
"dataAuthorityId":null,
"children":null,
"menus":null,
"defaultRouter":"dashboard"
},
{
"CreatedAt":"2022-07-10T21:29:00.35+08:00",
"UpdatedAt":"2022-07-10T21:29:00.524+08:00",
"DeletedAt":null,
"authorityId":8881,
"authorityName":"普通用户子角色",
"parentId":888,
"dataAuthorityId":null,
"children":null,
"menus":null,
"defaultRouter":"dashboard"
},
{
"CreatedAt":"2022-07-10T21:29:00.35+08:00",
"UpdatedAt":"2022-07-10T21:29:00.522+08:00",
"DeletedAt":null,
"authorityId":9528,
"authorityName":"测试角色",
"parentId":0,
"dataAuthorityId":null,
"children":null,
"menus":null,
"defaultRouter":"dashboard"
}
],
"phone":"17611111111",
"email":"333333333@qq.com",
"enable":1
},
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiMjg4NWExNDktZWEwMi00OThlLTllZTItYzcxZDVhNTNkZmQwIiwiSUQiOjEsIlVzZXJuYW1lIjoiYWRtaW4iLCJOaWNrTmFtZSI6Iui2hee6p-euoeeQhuWRmCIsIkF1dGhvcml0eUlkIjo4ODgsIkJ1ZmZlclRpbWUiOjg2NDAwLCJleHAiOjE2NTgwNjQ5MzIsImlzcyI6InFtUGx1cyIsIm5iZiI6MTY1NzQ1OTEzMn0.0JydtFnsbQk8GL0sHBsIBjm7tM_enzOn_m_a3MV1YfI",
"expiresAt":1658064932000
},
"msg":"登录成功"
}
这个login主要返回了用户的信息和token。 其中用户信息包含了用户个人信息和权限相关信息。因此,要想了解gin服务器是怎么验证用户信息,并且返回这些信息的,就需要来查看这个/api/base/login
接口是如何实现的了
2 login接口实现
首先在源代码中找到login接口实现的代码:
func (b *BaseApi) Login(c *gin.Context) {
var l systemReq.Login
_ = c.ShouldBindJSON(&l)
if err := utils.Verify(l, utils.LoginVerify); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if store.Verify(l.CaptchaId, l.Captcha, true) {
u := &system.SysUser{Username: l.Username, Password: l.Password}
if user, err := userService.Login(u); err != nil {
global.GVA_LOG.Error("登陆失败! 用户名不存在或者密码错误!", zap.Error(err))
response.FailWithMessage("用户名不存在或者密码错误", c)
} else {
if user.Enable != 1 {
global.GVA_LOG.Error("登陆失败! 用户被禁止登录!")
response.FailWithMessage("用户被禁止登录", c)
return
}
b.TokenNext(c, *user)
}
} else {
response.FailWithMessage("验证码错误", c)
}
}
首先用将传入的参数专为systemReq.Login
对象,验证码检查通过后,就开始验证用户名和密码。在均验证通过后,就返回用户信息和token
在这里,我主要想了解的地方有两点:
- 服务端是如何针对用户传入的用户名和密码进行验证的?
- 如何生成token的?
针对这两个问题,就需要接着深入研究代码了
2.1 验证用户名密码是否正确
func (userService *UserService) Login(u *system.SysUser) (userInter *system.SysUser, err error) {
if nil == global.GVA_DB {
return nil, fmt.Errorf("db not init")
}
var user system.SysUser
err = global.GVA_DB.Where("username = ?", u.Username).Preload("Authorities").Preload("Authority").First(&user).Error
if err == nil {
if ok := utils.BcryptCheck(u.Password, user.Password); !ok {
return nil, errors.New("密码错误")
}
var SysAuthorityMenus []system.SysAuthorityMenu
err = global.GVA_DB.Where("sys_authority_authority_id = ?", user.AuthorityId).Find(&SysAuthorityMenus).Error
if err != nil {
return
}
var MenuIds []string
for i := range SysAuthorityMenus {
MenuIds = append(MenuIds, SysAuthorityMenus[i].MenuId)
}
var am system.SysBaseMenu
ferr := global.GVA_DB.First(&am, "name = ? and id in (?)", user.Authority.DefaultRouter, MenuIds).Error
if errors.Is(ferr, gorm.ErrRecordNotFound) {
user.Authority.DefaultRouter = "404"
}
}
return &user, err
}
可以看出,首先通过通过查询数据库来获取到system.SysUser
对象,对象内容具体如下:
type SysUser struct {
global.GVA_MODEL
UUID uuid.UUID `json:"uuid" gorm:"comment:用户UUID"` // 用户UUID
Username string `json:"userName" gorm:"comment:用户登录名"` // 用户登录名
Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
SideMode string `json:"sideMode" gorm:"default:dark;comment:用户侧边主题"` // 用户侧边主题
HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像
BaseColor string `json:"baseColor" gorm:"default:#fff;comment:基础颜色"` // 基础颜色
ActiveColor string `json:"activeColor" gorm:"default:#1890ff;comment:活跃颜色"` // 活跃颜色
AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID
Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"`
Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"`
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结
}
func (SysUser) TableName() string {
return "sys_users"
}
其中Authorities
和Authority
两个字段代表sys_authorities
表格,然后在代码中通过gorm框架的Preload字段来做联表查询。在Login方法的第9行就是判断密码是否正确。是通过密码明文和数据库中的密文进行对比所得的。
然后就是检测当前用户对配置的默认路由是否有权限,若无权限就返回404
最终都正常情况下,会返回system.SysUser
类型对象
2.2 生成token
在此之前,需要了解下jwt的原理,可以查看此博客: JWT详解
对于生成token,主要是如下的代码:
// 登录以后签发jwt
func (b *BaseApi) TokenNext(c *gin.Context, user system.SysUser) {
j := &utils.JWT{SigningKey: []byte(global.GVA_CONFIG.JWT.SigningKey)} // 唯一签名
claims := j.CreateClaims(systemReq.BaseClaims{
UUID: user.UUID,
ID: user.ID,
NickName: user.NickName,
Username: user.Username,
AuthorityId: user.AuthorityId,
})
token, err := j.CreateToken(claims)
if err != nil {
global.GVA_LOG.Error("获取token失败!", zap.Error(err))
response.FailWithMessage("获取token失败", c)
return
}
if !global.GVA_CONFIG.System.UseMultipoint {
response.OkWithDetailed(systemRes.LoginResponse{
User: user,
Token: token,
ExpiresAt: claims.StandardClaims.ExpiresAt * 1000,
}, "登录成功", c)
return
}
//省略。。。
}
首先获取配置文件中的SigningKey,然后通过这个Singkey来创建claims,即jwt中的Payload, 包含uuid,id,Nickname,Username,AuthorityId等信息。
而代码中封装的token对象为:
type Token struct {
Raw string // The raw token. Populated when you Parse a token
Method SigningMethod // The signing method used or to be used
Header map[string]interface{} // The first segment of the token
Claims Claims // The second segment of the token
Signature string // The third segment of the token. Populated when you Parse a token
Valid bool // Is the token valid? Populated when you Parse/Verify a token
}
然后具体创建token代码如下:
// 创建一个token
func (j *JWT) CreateToken(claims request.CustomClaims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(j.SigningKey)
}
//--------------------
func NewWithClaims(method SigningMethod, claims Claims) *Token {
return &Token{
Header: map[string]interface{}{
"typ": "JWT",
"alg": method.Alg(),
},
Claims: claims,
Method: method,
}
}
func (t *Token) SigningString() (string, error) {
var err error
var jsonValue []byte
if jsonValue, err = json.Marshal(t.Header); err != nil {
return "", err
}
header := EncodeSegment(jsonValue)
if jsonValue, err = json.Marshal(t.Claims); err != nil {
return "", err
}
claim := EncodeSegment(jsonValue)
return strings.Join([]string{header, claim}, "."), nil
}
func (t *Token) SignedString(key interface{}) (string, error) {
var sig, sstr string
var err error
if sstr, err = t.SigningString(); err != nil {
return "", err
}
if sig, err = t.Method.Sign(sstr, key); err != nil {
return "", err
}
return strings.Join([]string{sstr, sig}, "."), nil
}
token主要分为Header,Payload,Signature,上面代码对Header和Payload进行了赋值.然后SigningString
方法对Header和Payload进行Base64编码后通过"."来连接。token.SignedString
是对刚才base64编码后的Header,Payload字段组合按照HS256算法进行加密得到Signature,最终将三部分组合就是token。