登陆模块设计(mayfly-go框架源码)

248 阅读7分钟

前言

在学习mayfly-go开源代码时,觉得框架登陆部分写的很好。

写本文章,一方面是为了更细致总结梳理下自己(站在讲授者角度来说清楚一件事情),另一方面前面已经写了好多篇关于某些个知识点的深度总结,少了很多系统模块文章。

希望能给相关读者一些启发和思考。 文章只涉及方法,不涉及实际代码组织框架设计,入门难度不高。

开源项目地址:github.com/may-fly/may…

登陆页

要实现如下登陆页。

image

  • 验证码是可以通过配置,开启或者不开启。

  • 要保证账号和密码安全性,在与服务端 或者 落库时都不可以被泄漏。

前置知识

生成验证码

base64Captcha框架 用来生成验证码

加密生成伪随机数

crypto/rand : rand包实现了一个密码安全的伪随机数生成器。

rand.Reader: 是一个密码强大的伪随机生成器的全球共享实例。 【Reader在程序中也是全局唯一】

他会作为私钥使用。

相关参考:cloud.tencent.com/developer/s…

非对称加密方法

crypto/rsa: RSA加密算法是一种非对称加密算法

交互逻辑

获取是否需要进行验证码

验证码 是 拉新环节的重要一步。

  • 做的太重用户注册成本,转化效果差。

  • 做的太轻又会被疯狂注册,然后薅羊毛导致资损。

对于一般运营性平台 刚开始会采用三方登陆(微信等)进行登陆,当部分用户使用深度到一定程度时会要求输入手机号,用来做用户沉淀 和 触达用户。

有一些私人网站,要求比较高的,往往会使用。手机验证码 + Google Authenticator二次验证手段,或者是其中之一手段。

在mayfly系统中会首先进行如下请求用来判断网站是否开始验证码

/api/sys/configs/value?key=UseLoginCaptcha

关于类似系统配置有些同学可能会采用配置文件形式,在服务启动时获得。有些会采用放在库中,但是又觉得只存一个配置有点大才小用。 也曾有过疑惑,这里我们学习下框架是如何设计表的。

CREATE TABLE `t_sys_config` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(60) COLLATE utf8mb4_bin NOT NULL COMMENT '配置名',
  `key` varchar(120) COLLATE utf8mb4_bin NOT NULL COMMENT '配置key',
  `params` varchar(500) COLLATE utf8mb4_bin NOT NULL COMMENT '配置key',
  `value` varchar(500) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '配置value',
  `remark` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '备注',
  `create_time` datetime NOT NULL,
  `creator_id` bigint(20) NOT NULL,
  `creator` varchar(36) COLLATE utf8mb4_bin NOT NULL,
  `update_time` datetime NOT NULL,
  `modifier_id` bigint(20) NOT NULL,
  `modifier` varchar(36) COLLATE utf8mb4_bin NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

表中key对应查询接口中的 key=UseLoginCaptcha 字段。 value是配置,决定是否开始验证码。

获得验证码

/api/sys/captcha

接口会调用 base64Captcha 库生成验证码

{"base64Captcha": image, "cid": id}

返回 base64形图片 和 图片对应的id(这个id是图片id,与图片中验证码信息是对应的)

获得公钥

在提交用户登陆信息前会调用 /api/common/public-key 接口获得密码加密公钥。

生成RSA密钥对逻辑:

  1. 使用rsa.GenerateKey生成私钥

调用 privateKey, err := rsa.GenerateKey(rand.Reader, bits) 方法生成密钥。

GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥 Reader是一个全局、共享的密码用强随机数生成器

  1. 使用x509.MarshalPKCS1PrivateKey序列化私钥为derText

X509PrivateKey := x509.MarshalPKCS1PrivateKey(privateKey) 通过x509标准将得到的ras私钥序列化为ASN.1 的 DER编码字符串

  1. 使用pem.Block转为Block privateBlock := pem.Block{Type: "RSA Private Key", Bytes: X509PrivateKey}

构建一个pem.Block结构体对象

  1. 使用pem.Encode写入文件
privateBuf := new(bytes.Buffer)
pem.Encode(privateBuf, &privateBlock)
privateKeyStr = privateBuf.String()
  1. 从私钥中获取公钥

publicKey := privateKey.PublicKey

获取公钥的数据

  1. 使用x509.MarshalPKIXPublicKey序列化公钥为derStream
//X509对公钥编码
X509PublicKey, err := x509.MarshalPKIXPublicKey(&publicKey)
if err != nil {
   return publicKeyStr, privateKeyStr, err
}
  1. 使用pem.Block转为Block
//创建一个pem.Block结构体对象
publicBlock := pem.Block{Type: "RSA Public Key", Bytes: X509PublicKey}
  1. 使用pem.Encode写入文件
publicBuf := new(bytes.Buffer)
pem.Encode(publicBuf, &publicBlock)
publicKeyStr = publicBuf.String()
  1. 最终得到公钥 publicKeyStr 和 私钥privateKeyStr

获得公钥和私钥最终代码为

func GenerateRSAKey(bits int) (string, string, error) {
   var privateKeyStr, publicKeyStr string

   //GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥
   //Reader是一个全局、共享的密码用强随机数生成器
   privateKey, err := rsa.GenerateKey(rand.Reader, bits)
   if err != nil {
      return privateKeyStr, publicKeyStr, err
   }
   //保存私钥
   //通过x509标准将得到的ras私钥序列化为ASN.1 的 DER编码字符串
   X509PrivateKey := x509.MarshalPKCS1PrivateKey(privateKey)
   //构建一个pem.Block结构体对象
   privateBlock := pem.Block{Type: "RSA Private Key", Bytes: X509PrivateKey}

   privateBuf := new(bytes.Buffer)
   pem.Encode(privateBuf, &privateBlock)
   privateKeyStr = privateBuf.String()

   //保存公钥
   //获取公钥的数据
   publicKey := privateKey.PublicKey
   //X509对公钥编码
   X509PublicKey, err := x509.MarshalPKIXPublicKey(&publicKey)
   if err != nil {
      return publicKeyStr, privateKeyStr, err
   }
   //创建一个pem.Block结构体对象
   publicBlock := pem.Block{Type: "RSA Public Key", Bytes: X509PublicKey}

   publicBuf := new(bytes.Buffer)
   pem.Encode(publicBuf, &publicBlock)
   publicKeyStr = publicBuf.String()

   return privateKeyStr, publicKeyStr, nil
}

将获得的公钥 和 私钥 保存在内存中。如果下次需要生成,内存中存在的话会直接返回。【这种情况下,如果多服务部署会出现问题】

公钥发给用户用于加密。

登录

前端发起处理逻辑

前端会调用接口 /api/sys/accounts/login 下面数据为POST请求

{
  "username": "admin",
  "password": "shn9JRsQcX8AU2up8p3k0TCAtT7ZMCdSR0YUMorPwPKXcwcv/Bckx1fNqteGK2r82NGdyMRbzqLt002HqhqE+c9c5WfxNX63oE1VduHoFwuZ8RwDx7jb9np8g+XJJJRyCVCaQQ1EnmZb4hr1p2V5tkVr5Jhb1AzOg1pU7MOJb+M=",
  "captcha": "70423",
  "cid": "1omzKQQkEjoq9gTNzC62"
}

Password 前端加密方法为:

import JSEncrypt from 'jsencrypt'

encryptor = new JSEncrypt()
const publicKey = await getRsaPublicKey() as string; // 获得公钥
notBlank(publicKey, "获取公钥失败")
encryptor.setPublicKey(publicKey)//设置公钥
return encryptor.encrypt(value) // 加密

Captcha: 用户输入验证码

Cid: 返回的图像验证码的id

后端处理逻辑

  1. 校验提交上来的数据完整性 与 正确性

验证方法用的是ginx框架自带的 ginx.BindJsonAndValid(rc.GinCtx, loginForm)

  1. 判断是否需要验证码,如果需要执行校验。

biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")

校验部分也是交给框架,给Cid 和 用户输入Captcha 进行校验。

  1. 然后用服务端第三步生成的密钥对, 用私钥 对密码进行解密。获得用户输入密码。

RsaDecrypt(priKey, []byte(data))

  1. 根据用户username 字段查询数据,获得库中保存的密码。注意密码不是用户实际密码,密码是经过hash过的值。

  2. 校验用户上传密码 和 数据库中密码是否对应。

bcrypt.CompareHashAndPassword比较字符串和哈希值,若是不匹配,返回error。 实际代码如下:

bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil

  1. 登陆成功后进行业务逻辑处理:
  • 账号是否可用,密码抢夺

  • 账号权限等级,返回可用权限列表。

  • 获得账号资源列表(栏目列表)==>. 权限列表服务端也要cache下。后续请求需要用它验证。

  • 保存用户登陆信息

  • 生成token

生成token方式是通过jwt框架,方法如下:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
   "id":       userId,
   "username": username,
   "exp":      time.Now().Add(time.Minute * time.Duration(ExpTime)).Unix(),
})

token中保存着用户信息。

  • 最终返回给客户端, 如下信息:
{
   "token":         ctx.CreateToken(account.Id, account.Username),
   "username":      account.Username,
   "lastLoginTime": account.LastLoginTime,
   "lastLoginIp":   account.LastLoginIp,
   "menus":         menus.ToTrees(0),
   "permissions":   permissions,
}

前端后续请求

前端后续请求需要将这个token放在header中带上

// request interceptor
service.interceptors.request.use(
    (config: any) => {
        // do something before request is sent
        const token = getSession("token")
        if (token) {
            // 设置token
            config.headers['Authorization'] = token
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)

服务端会根据此token解析出来userid, 然后去登录时候设置的缓存中。获得用户相关权限,对其相关操作进行处理。

总结

有一个交互图会好点。