需求背景
在进行go-web项目脚手架学习之后,搭建了服务登录,和服务注册两个功能。
其中服务登录采用了JWT的方式,让客服端与服务端进行登录认证交互。而采用token这种模式交互,最大的优点就是服务端无需任何存储,就能判断客户端是否登录,缺点就是token是无状态的,且token较长。
在项目视频中,李文周老师有一个课下作业就是怎么能做到一个账号一登陆问题,就是A用户拿着A的账号在A地登录服务,那么A访问服务是没有问题的,此时B用户拿着A的账号在B地登录,此时A就会要求重新登录,而此时B用户访问时正常的。
我们把这个问题发散一下,能不能做到一账号多登录?
项目连接:一账号多登录项目
go-web脚手架:go-web
go-web脚手架搭建教程:脚手架教程
一下代码是已经完成的工程代码,为之后技术方案选型实现做参考。
middlewares-> auth.go
// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization")
p := new(models.ParamLogin)
err := c.ShouldBindJSON(p)
if err != nil {
controller.ResponseError(c, controller.CodeNeedLogin)
c.Abort()
return
}
if authHeader == "" {
controller.ResponseError(c, controller.CodeNeedLogin)
c.Abort()
return
}
// 按空格分割
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
controller.ResponseError(c, controller.CodeInvalidToken)
c.Abort()
return
}
// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它
mc, err := jwt.ParseToken(parts[1])
if err != nil {
controller.ResponseError(c, controller.CodeInvalidToken)
c.Abort()
return
}
if !flag {
controller.ResponseError(c, controller.AccountNumberOverLimit)
c.Abort()
return
}
// 将当前请求的userID信息保存到请求的上下文c上
c.Set(controller.CtxUserIDKey, mc.UserID)
c.Next() // 后续的处理请求的函数中 可以用过c.Get(CtxUserIDKey) 来获取当前请求的用户信息
}
}
logic -> route.go
func Login(p *models.ParamLogin) (user *models.User, err error) {
user = &models.User{
Username: p.Username,
Password: p.Password,
}
// 传递的是指针,就能拿到user.UserID
flag := mysql.Login(user)
if flag {
return user, errors.New("username or password is error")
}
// 生成JWT
token, err := jwt.GenToken(user.UserId, user.Username)
if err != nil {
return
}
user.Token = token
return user, nil
}
controller -> user.go
// LoginHandler 登录
func LoginHandler(c *gin.Context) {
// 1.获取请求参数及参数校验
p := new(models.ParamLogin)
p.Username = c.Query("username")
p.Password = c.Query("password")
// 2.业务逻辑处理
user, err := logic.Login(p)
if err != nil {
zap.L().Error("logic.Login failed", zap.String("username", p.Username), zap.Error(err))
ResponseError(c, CodeInvalidPassword)
return
}
if !flag {
ResponseError(c, CodeInvalidPassword)
return
}
// 4.返回响应
ResponseSuccess(c, gin.H{
"user_id": fmt.Sprintf("%d", user.UserId), // id值大于1<<53-1 int64类型的最大值是1<<63-1
"user_name": user.Username,
"token": user.Token,
})
}
logic -> route.go
func Login(p *models.ParamLogin) (user *models.User, err error) {
user = &models.User{
Username: p.Username,
Password: p.Password,
}
// 传递的是指针,就能拿到user.UserID
flag := mysql.Login(user)
if flag {
return user, errors.New("username or password is error")
}
// 生成JWT
token, err := jwt.GenToken(user.UserId, user.Username)
if err != nil {
return
}
user.Token = token
return user, nil
}
这里jwt采用的是jwt-go第三方组件库生成。整体流程就是在登录的时候会生成jwt令牌返回给client,client请求再带上这个令牌去请求后端。
curl – postman 验证登录成功
curl --location --request GET 'localhost:8081/ping' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDYwMjQzMjk5MjQ2MDgsInVzZXJuYW1lIjoidXNlcm5hbWUiLCJleHAiOjE3MTc0MjA4ODIsImlzcyI6ImJsdWViZWxsIn0.npDRpe7cnQSvJEZnppRKDvhVkwSrBLqfHNRiov4WzEo' \
--header 'Content-Type: application/json' \
--data-raw '{
"username":"llbnk",
"password":"213"
}'
一、技术方案
JWT令牌是无状态的,可以理解是牺牲速度换取容量的一种做法(个人理解网络带宽肯定大,服务端不存储)。
如果想实现一个账号多台登录,那必须需要再服务端进行存储。下图技术总体时序图,可以看到请求token过来是需要从db中获取进行对比,确认为同一个token后才能返回给前端相应结果,否则提示前端token无效。
那实现一账号多地登录的方案是怎么样的?我们以一账号3登录为例,请看下图:
二、方案选择
可看到A用户最后请求/ping的时候token应该是无效的,此时说明存储必须存三个token。
db选择 | 方案 | 优缺点 |
---|---|---|
mysql | 建立一个token表去存储user和token的关系 | 优点维护简单,缺点存储在磁盘太重了 |
redis | 创建key=login_token+username,将token放入value | 优点使用便利,缺点JWT令牌太长,当访问量太大redis内存可能不足,需要更换token算法 |
综合考虑采用redis去做登录校验比较合适,首先用户登录理解不是一个必须落表行为,可以容忍key丢失,用户再次登录,且redis相应速度快,用户体验好。
整体业务改造逻辑如下图,红色部分为应该改造部分。
而redis采用stirng存储,但value怎么设计呢?
value选择存储方案 | 实例 | 优缺点 |
---|---|---|
将value变成对象存储 | {“FirstToken”:“TOKEN1”,“SecondToken”:“TOKEN2”,“ThirdToken”:“TOKEN3”} | 优点理解便利,代码实现简单,缺点可拓展性差,代码改动较多 |
将value中存入token组成的切片 | {“TOKEN1”,“TOKEN2”,“TOKEN3”} | 优点节省redis内存,可拓展性强,缺点存入redis中需要先将sclice-》string-》json工序繁琐 |
最终还是选择第二种存入切片的方式。
三、代码编写
我们需要在登录返回相应前去将jwt存入redis中实现代码如下:
(代码有err地方尽量打印一下err,这里图省事没有打印error)
user.go
const Login_Token = "login_token"
// LoginHandler 登录
func LoginHandler(c *gin.Context) {
// 1.获取请求参数及参数校验
p := new(models.ParamLogin)
p.Username = c.Query("username")
p.Password = c.Query("password")
// 2.业务逻辑处理
user, err := logic.Login(p)
if err != nil {
zap.L().Error("logic.Login failed", zap.String("username", p.Username), zap.Error(err))
ResponseError(c, CodeInvalidPassword)
return
}
//3.在返回前去将token放入redis中
flag := setRedisTokenV2(user)
if !flag {
ResponseError(c, CodeInvalidPassword)
return
}
// 4.返回响应
ResponseSuccess(c, gin.H{
"user_id": fmt.Sprintf("%d", user.UserId), // id值大于1<<53-1 int64类型的最大值是1<<63-1
"user_name": user.Username,
"token": user.Token,
})
}
func setRedisTokenV2(user *models.User) bool {
res, err := redis.Rdb.Get(fmt.Sprintf(Login_Token+"_"+"%s", user.Username)).Result()
if err != nil && err.Error() != redis.NIl {
return false
}
var tokens []string
if res != "" {
err = json.Unmarshal([]byte(res), &tokens)
if err != nil {
return false
}
}
fmt.Println("Slice from JSON:", tokens)
for _, tokenstr := range tokens {
if tokenstr == user.Token {
return true
}
}
if len(tokens) < 3 {
tokens = append(tokens, user.Token)
} else {
copy(tokens[:2], tokens[1:])
tokens[2] = user.Token
}
// 将 slice 转换为 JSON 字符串
jsonData, err := json.Marshal(tokens)
if err != nil {
log.Fatalf("Error marshalling to JSON: %v", err)
}
jsonString := string(jsonData)
fmt.Println("JSON string:", jsonString)
res, err = redis.Rdb.Set(fmt.Sprintf(Login_Token+"_%s", user.Username), jsonString, 3600*time.Second).Result()
if err != nil {
return false
}
return true
}
整体的实现思路就是是否当前redis中存入的token数量
如果tooken数量小于3就将新生成的token放入slice中并重新设置redis
如果token数量大于3就将slice中的第0号,1号位的token覆盖第1号,2号位的token,并将新生成的token赋给第0号位置。
auth.go
// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization")
p := new(models.ParamLogin)
err := c.ShouldBindJSON(p)
if err != nil {
controller.ResponseError(c, controller.CodeNeedLogin)
c.Abort()
return
}
if authHeader == "" {
controller.ResponseError(c, controller.CodeNeedLogin)
c.Abort()
return
}
// 按空格分割
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
controller.ResponseError(c, controller.CodeInvalidToken)
c.Abort()
return
}
// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它
mc, err := jwt.ParseToken(parts[1])
if err != nil {
controller.ResponseError(c, controller.CodeInvalidToken)
c.Abort()
return
}
//一个账号可以三台登录
flag := accountNumberLimitV2(parts[1], p)
if !flag {
controller.ResponseError(c, controller.AccountNumberOverLimit)
c.Abort()
return
}
// 将当前请求的userID信息保存到请求的上下文c上
c.Set(controller.CtxUserIDKey, mc.UserID)
c.Next() // 后续的处理请求的函数中 可以用过c.Get(CtxUserIDKey) 来获取当前请求的用户信息
}
}
func accountNumberLimitV2(authHeader string, p *models.ParamLogin) bool {
res, err := redis.Rdb.Get(fmt.Sprintf(controller.Login_Token+"_"+"%s", p.Username)).Result()
if err != nil {
return false
}
var tokens []string
if res != "" {
err = json.Unmarshal([]byte(res), &tokens)
if err != nil {
return false
}
}
fmt.Println("Slice from JSON:", tokens)
for _, tokenstr := range tokens {
if tokenstr == authHeader {
return true
}
}
return false
}
在/ping验证接口中只需要在校验jwt中间件中去判断是否当前登录携带的token在redis中如果不在则认为token校验失败,用户应该重新登陆。
四、验证
通过postman请求登录结果
curl
curl --location --request GET 'localhost:8081/login?username=llbnk&password=213'
我们连续请求三次,可以查看redis-cli中的key
我们采用第一个jwt去请求一下/ping
curl
curl --location --request GET 'localhost:8081/ping' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDYwMjQzMjk5MjQ2MDgsInVzZXJuYW1lIjoidXNlcm5hbWUiLCJleHAiOjE3MTc0MjkyNTksImlzcyI6ImJsdWViZWxsIn0._vi7RiS_IDLLwJbVvc3LnXsG4jn6CaEHopgA7qbJVrQ' \
--header 'Content-Type: application/json' \
--data-raw '{
"username":"llbnk",
"password":"213"
}'
此时我们再生成一个token。
达到我们想要的效果
总结
使用redis+JWT实现了一个账号多地登录。
这里有一点美中不足,就是setRedisTokenV2
方法中
if len(tokens) < 3 { tokens = append(tokens, user.Token) } else { copy(tokens[:2], tokens[1:]) tokens[2] = user.Token }
这段代码如果能从viper的配置中读取,并改写,可以实现一个账号任意N地登录。
我的目标
希望在年底学习一下内容:
java学习内容:
1.tomcat源码
2.dubbo源码
3.zookeeper源码
4.netty源码
go学习内容:
1.gin框架学习
2.简单go项目
3.go基础知识进阶(gmp,gc,channel,map,slice源码等)
中间件学习内容:
1.kafka使用及源码
框架学习内容:
1.从零开始学架构
算法学习内容:
1.复习leetcode中top 100
我平时喜欢没事还打游戏,因为有宝宝所以希望在平时时间能尽量完成上述学习内容(希望能戒掉游戏哈哈哈)。
也希望有和我一样的一起学习的小伙伴共同学习进步,我建一个qq后端交流群:279868576,希望小伙伴们加入共同督促进步。