前言
在学习mayfly-go开源代码时,觉得框架登陆部分写的很好。
写本文章,一方面是为了更细致总结梳理下自己(站在讲授者角度来说清楚一件事情),另一方面前面已经写了好多篇关于某些个知识点的深度总结,少了很多系统模块文章。
希望能给相关读者一些启发和思考。 文章只涉及方法,不涉及实际代码组织框架设计,入门难度不高。
开源项目地址:github.com/may-fly/may…
登陆页
要实现如下登陆页。
-
验证码是可以通过配置,开启或者不开启。
-
要保证账号和密码安全性,在与服务端 或者 落库时都不可以被泄漏。
前置知识
生成验证码
base64Captcha框架 用来生成验证码
-
包官方地址: pkg.go.dev/github.com/…
-
包使用方法: juejin.cn/post/696947…
加密生成伪随机数
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密钥对逻辑:
- 使用rsa.GenerateKey生成私钥
调用 privateKey, err := rsa.GenerateKey(rand.Reader, bits) 方法生成密钥。
GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥 Reader是一个全局、共享的密码用强随机数生成器
- 使用x509.MarshalPKCS1PrivateKey序列化私钥为derText
X509PrivateKey := x509.MarshalPKCS1PrivateKey(privateKey) 通过x509标准将得到的ras私钥序列化为ASN.1 的 DER编码字符串
- 使用pem.Block转为Block privateBlock := pem.Block{Type: "RSA Private Key", Bytes: X509PrivateKey}
构建一个pem.Block结构体对象
- 使用pem.Encode写入文件
privateBuf := new(bytes.Buffer)
pem.Encode(privateBuf, &privateBlock)
privateKeyStr = privateBuf.String()
- 从私钥中获取公钥
publicKey := privateKey.PublicKey
获取公钥的数据
- 使用x509.MarshalPKIXPublicKey序列化公钥为derStream
//X509对公钥编码
X509PublicKey, err := x509.MarshalPKIXPublicKey(&publicKey)
if err != nil {
return publicKeyStr, privateKeyStr, err
}
- 使用pem.Block转为Block
//创建一个pem.Block结构体对象
publicBlock := pem.Block{Type: "RSA Public Key", Bytes: X509PublicKey}
- 使用pem.Encode写入文件
publicBuf := new(bytes.Buffer)
pem.Encode(publicBuf, &publicBlock)
publicKeyStr = publicBuf.String()
- 最终得到公钥 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
后端处理逻辑
- 校验提交上来的数据完整性 与 正确性
验证方法用的是ginx框架自带的 ginx.BindJsonAndValid(rc.GinCtx, loginForm)
- 判断是否需要验证码,如果需要执行校验。
biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")
校验部分也是交给框架,给Cid 和 用户输入Captcha 进行校验。
- 然后用服务端第三步生成的密钥对, 用私钥 对密码进行解密。获得用户输入密码。
RsaDecrypt(priKey, []byte(data))
-
根据用户username 字段查询数据,获得库中保存的密码。注意密码不是用户实际密码,密码是经过hash过的值。
-
校验用户上传密码 和 数据库中密码是否对应。
bcrypt.CompareHashAndPassword比较字符串和哈希值,若是不匹配,返回error。 实际代码如下:
bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
- 登陆成功后进行业务逻辑处理:
-
账号是否可用,密码抢夺
-
账号权限等级,返回可用权限列表。
-
获得账号资源列表(栏目列表)==>. 权限列表服务端也要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, 然后去登录时候设置的缓存中。获得用户相关权限,对其相关操作进行处理。
总结
有一个交互图会好点。