【golang自学之路(四)】实现一账号N地登录问题!redis+JWT实现详讲


需求背景

在这里插入图片描述

在进行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相应速度快,用户体验好。
整体业务改造逻辑如下图,红色部分为应该改造部分。
在订单按钮按下的时候先请求生成一个秒杀令牌,只有在秒杀令牌申请成功之后才会进入我们之前写的OrderController中的createItem方法。
而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,希望小伙伴们加入共同督促进步。

  • 12
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值