高并发系统设计 -- 登录系统设计

同源策略

  1. 同源策略是一种安全策略。是游览器最核心最基本的安全功能。
  2. 防止XSS,CSFR等攻击
  3. 具体表现是游览器在执行脚本之前,会判断脚本是否与打开的网页是同源的,也就是协议,域名,端口是否都相同,相同就是同源,其中一项不相同就叫做跨域访问
  4. 跨域访问会在控制台报一个CORS异常,目的是为了保护本地数据不被js代码获取回来的数据污染。因此拦截的是客户端发出的请求回来的数据接收,即请求发送了,服务器响应了,但是无法被游览器接收。
  5. 在有明确授权的情况下,禁止页面加载或者执行与自身不同意的任何脚本。我们可以使用Nginx或者SpringCloud Gateway解决。

CSRF攻击

CSRF攻击原理

查看CSDN里面存放的相关的文章。

CSRF防御原理

检测CSRF漏洞

  1. 最简单的方法就是抓取一个正常请求的数据包,去掉Referer字段之后再重新提交,如果提交还有效,那么基本上可以确认存在CSRF漏洞。
  2. 以CSRFTester工具为例,它的测试原理如下:抓取游览器中访问过的所有链接以及所有的表单信息,然后通过CSRFTester中修改相应的表单等信息,重新提交,如果修改后的请求成功被网站服务器接收,说明存在CSRF漏洞。这个工具就是使用CSRF的攻击原理来进行检测的。

防御CSRF攻击

  1. 验证HTTP Referer字段

    简单便捷但是不完善

  2. 在请求地址中添加token并且认证

    **要抵御CSRF,关键在于请求中放入黑客所不能伪造的信息,并且该信息不存在于cookie之中。**例如,可以在HTTP请求中以参数的形式加入一个随机产生的token,如果请求中没有token或者token不存在,那么久认为是CSRF攻击而拒绝这个请求。

    这种方法比Referer要安全一些。

  3. HTTP头中自定义属性并验证

XSS攻击

XSS攻击又称为跨站脚本攻击,通过利用网页开发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。

这些恶意网页程序通常是JavaScript,但实际上也可以包括Java、 VBScript、ActiveX、 Flash 或者甚至是普通的HTML。攻击成功后,攻击者可能得到包括但不限于更高的权限(如执行一些操作)、私密网页内容、会话和cookie等各种内容。

跨站脚本攻击(XSS),是最普遍的Web应用安全漏洞。这类漏洞能够使得攻击者嵌入恶意脚本代码到正常用户会访问到的页面中,当正常用户访问该页面时,则可导致嵌入的恶意脚本代码的执行,从而达到恶意攻击用户的目的。

攻击者可以使用户在浏览器中执行其预定义的恶意脚本,其导致的危害可想而知,如劫持用户会话,插入恶意内容、重定向用户、使用恶意软件劫持用户浏览器、繁殖XSS蠕虫,甚至破坏网站、修改路由器配置信息等。

无状态和有状态

专业点的例子:

无状态:任意一个Web请求端提出请求时,请求本身包含了响应端为响应这一请求所需的全部信息(认证信息等)

有状态:Web请求端的请求必须被提交到保存有其相关状态信息(比如session)的服务器上,否则这些请求可能无法被理解,这也就意味着在此模式下服务器端无法对用户请求进行自由调度。

再说直白一点就是状态(公共交互)信息是由请求方还是响应方负责保存,请求方保存就是无状态,响应方保存就是有状态。

无状态应用不关心响应方是谁,需不需要同步各个响应方之间的信息,响应服务可随时被删除也不会影响别人,容错性高,分布式服务的负载均衡失效不会丢数据,无内存消耗,直接部署上线即可使用

有状态应用需要及时同步数据,可能存在数据同步不玩丢失数据,消耗内存资源保存数据等。

Token

在这里插入图片描述

那么应该怎么进行预防这种攻击那?目前主流的框架为了预防这种攻击,都是采用TOKEN机制。也就是说当用户与服务端进行交互的时候,传递一个加密字符串到服务端,服务端来检测这个字符串是否是合法的,如果不合法就有可能是黑客伪造用户信息进行请求的。

那么这个加密字符串是怎么生成的那?加密字符串是由后端程序生成,然后赋值到页面之上。一般是由当前控制器,方法,密钥,时间组合在一起加密而成。传递到服务端以后,服务端重新生成一遍,如果一致就是合法的,否则就是不合法的。

JWT

JWT = Json Web Token

通俗一点讲就是:

JWT是通过Json的形式作为Web应用中的令牌,用于在各方之间安全的将信息作为Json对象船速,在数据传输过程中还可以完成数据加密,签名等相关处理

JWT可以做什么?

  1. 授权,安全认证,这是JWT最常见的方案,一旦用户登录,每一个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登录是当今广泛使用JWT的一个功能,因为它的开销小,可以在不同的域中轻松使用。

在这里插入图片描述

例如我要访问淘宝购物车购买东西的页面,我要向你的服务器发起请求。当然这是肯定不可以的,因为你还没有进行登录。你进行登录之后,你可以用JWT标志生成一个token,以后每次访问的时候,在你的URL路径上面带着这个token,服务器就认为你是成功登录了的,就像是一个令牌一样,你把令牌给我看,我就让你执行对应的操作。当然,这个里面肯定设计到一些安全的问题,我们的服务器里面有一个secret密钥,而且只有服务器才有,所以不存在被盗用的情况,因为就算中间人获取到了你的token,也无能为力。

  1. 信息交换:是各方之间安全的传输信息的好方法,因为可以对JWT进行签名,例如使用公钥和私钥对,所以你可以确保发件人是他们所说的人。此外,由于签名是使用表头和有效载荷计算的,因此你还可以验证内容是否遭到了篡改。不常用。

数字签名

数字签名(又称公钥数字签名)是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。它是一种类似写在纸上的普通的物理签名,但是在使用了公钥加密领域的技术来实现的,用于鉴别数字信息的方法。一套数字签名通常定义两种互补的运算,一个用于签名,另一个用于验证。数字签名是非对称密钥加密技术与数字摘要技术的应用。

为什么是JWT?

基于传统的Session认证

在这里插入图片描述

先来看一下传统的Session认证的代码方式:

我们打开黑马的瑞吉外卖项目的UserController和Filter进行查看。

在这里插入图片描述

在我们的前后端分离的分布式架构中,有很多代理层(例如SpringCloud GateWay),我们如果用sessionId来验证的话,那么我们前端发过来请求之后,代理要拿到sessionId,拿到sessionId之后要把它传给我真正的服务,而sessionId在传递的过程中会耗费大量的资源。并且如果我们真正的服务是集群部署的话,那么还要实现sessionId的共享,把sessionId拷贝多份。

基于JWT认证

在这里插入图片描述

在这里插入图片描述

JWT和Token

JWT可以生成一个独一无二的Token字符串。

JWT适用于轻量级一点的,而Token + redis使用于重量级的系统。

如果项目并不大,JWT是个好选择,天然的去中心化模式,会话状态由令牌本身自解释,简单粗暴但是缺点页很明显,如题所示,一旦下发便不受服务端控制,如果发生token泄露,服务器也只能任其蹂躏,在其未过期期间不能有任何措施对此模式践行比较完善的框架推荐你了解一下token+redis,此框架扩展了token-session模型,将会话数据放在了Redis下,并提供了多种Session序列化方案,诸如注销登录、权限认证、踢人下线等常见功能全部一行代码就可以完成。

token是一个很宽泛的概念,翻译为令牌,一般用来表示经过验证之后得到的凭证,长度没有什么限制,多长都可以。

jwt是 JSON Web Token,它也自称是一种token,jwt就是一个很具体的标准了,用点号分为三段,分别表示头、信息和签名。

token有很多种,可以是标准的,也可以是你自己定义的,jwt则是其中一种token,而且是标准的token。和我们自己随意定义的token差别大是很自然的,因为我们自己定义的token只需要用来识别用户登录状态,一般很短的uuid都可以实现,所以比较短。

Token是无状态协议中认证用户的一种形式,相比于传统的cookie,不受域名限制
JWT只是一种实现形式,通过在客户端存储payload来降低服务端压力

在这里插入图片描述

为什么cookie不支持跨域访问?

在这里插入图片描述

JWT的结构是什么?

在这里插入图片描述

在这里插入图片描述

我用前面两个东西加上我的secret可以生成一个数字签名,然后等我用户给我发请求的那个token,把前面的取出来再加上secret,如果一样就代表合法。所以这个secret没有任何人可以知道。

在这里插入图片描述

在这里插入图片描述

JWT被盗用了怎么办?

在这里插入图片描述

密码盐值加密

把密码铭文存储到MySQL是不合理的,同时,使用MD5也是不合理的,因为虽然MD5是不可逆的,但是有人可以通过暴力的方式破解密码,所以这里相对好的方式是盐值加密,在MD5的基础上,加入一个盐值,同一个加密内容,盐值不同,加密出来的东西就不同,因此,只要破解方不知道我们的盐值,就几乎不可能破解成功。其次,使用手机验证码登录,可以从根本上解决这些问题。

风险控制

多设备登录校验

唯一token有效

  • 第一次登录的token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInVzZXJuYW1lIjoidG9tIiwiZXhwIjoxNjU3OTgzNzAxLCJpc3MiOiJnaW4tSU0iLCJuYmYiOjE2NTc4OTczMDF9.ipiIDgAdTwrv8EX45y0UD6wy0fOOdzhIDysyB8kJais
  • 第二次登录的token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInVzZXJuYW1lIjoidG9tIiwiZXhwIjoxNjU3OTgzODAxLCJpc3MiOiJnaW4tSU0iLCJuYmYiOjE2NTc4OTc0MDF9.3ZDrBr0FaFKpcicJpNkvEVCd8UdEQp079mg4fr2jBcc

通过测试可以发现,两个token都是有效的。只有到了指定的日期后,token才会失效。这显然是不合理的。所以我在这里的处理是:引入Redis

具体逻辑:使用redis中的hash结构。key为user的唯一标识uid;filed为该user的User-Agent,表示是哪一个设备(同一个设备只能有1个token有效);value存储该user的唯一有效token。

结构如下:

在这里插入图片描述

  • service/user.go
    

    中的

    UserLogin()
    

    增加逻辑:登录成功后签发token时,确定

    uid
    

    User-Agent
    

    。直接

    Rdb.HSet()
    

    即可

    • 因为Rdb.HSet()的处理逻辑是,如果存在就更新value;不存在就新建。
  • middleware/jwt.go中增加判定逻辑。通过uidUser-Agent(从解析token中包含的相关信息claims中获取uid),查出redis中的token。判定携带的token是否和redis中的token一样。如果不一样说明是旧的token,直接c.Abort()然后return

这只是我自己的一个想法,如果以后发现更好的解决方案,会继续更新的。

更新后:

  • 第一次token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInVzZXJuYW1lIjoidG9tIiwiZXhwIjoxNjU4MDU0NzUxLCJpc3MiOiJnaW4tSU0iLCJuYmYiOjE2NTc5NjgzNTF9.-FvhHHpJokeigiSJOUkTWaQ4ytsYDZcxaTklPLzJGR4
  • 第二次token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInVzZXJuYW1lIjoidG9tIiwiZXhwIjoxNjU4MDU0NzgwLCJpc3MiOiJnaW4tSU0iLCJuYmYiOjE2NTc5NjgzODB9.uBkmCpbTfEbr3fBiMQ26XrxOQc-hl6H5jvS_3BfW-2o

可以发现使用第一次token去请求会403:

img

据说微信就是这样做的(跟群友讨论的):

  • 这不就是提掉线吗?
  • 登录后 将以前此用户的token删除掉即可
  • 如果想多设备登录 就加入设备就可以 当前token和用户id,设备绑定
  • 微信就是这样做的

跟大家讨论,感觉基本都是基于redis缓存token的,踢掉用户也是这么干的。

不过感觉这样就跟jwt的无状态背道而驰了,回到了session。如果以后有更优雅更好的方式,会再记录的。

验证码接口防刷

验证码我们需要防刷的,后台也要做!!

有的人可能会问,为什么后台也要做呢?前端这边做一下限制不就可以了吗。我发送一次验证码值用户在一定的时间内就不可以继续发送了,这样不就实现了防刷了吗?但是实际上并不是这样的。因为前端虽然可以防住小白用户,但是有些很懂的人,可以抓包或者发送验证码的请求,然后用JMeter爆刷!!这样前端的限制就没有用了。

但是这个时候存在两个问题:

  • 我要的前台和后台都要去防止对面去恶意刷验证码。因为我们的请求的时候,发送验证码的请求已经被暴露了。对方懂行的人可以去用JMeter刷验证码的
  • 虽然我的前端限制验证码是过60秒之后才能继续刷的,但是我们刷新一次页面之后就会发现,这个限制没有了,因为我们没有后台记录所剩下的时间,我只需要在游览器上面刷新游览器的缓存,那么保存在游览器里面的时间就被刷新了,等于说这个东西是无意义的

我们接下来要解决这两个问题。

首先验证码可以存到redis里面,并且设置过期时间。

解决页面刷新之后验证码就可以马上发送的情况:

密码多次错误惩戒

跟验证码是做法是一样的,这里不做过多的展示。

核心代码

// JWTAuth 定义一个JWTAuth中间件
func JWTAuth() gin.HandlerFunc {
   return func(c *gin.Context) {
      // 通过http header中的token解析来认证
      token := c.GetHeader("token")
      if token == "" {
         c.JSON(http.StatusForbidden, gin.H{
            "status": http.StatusForbidden,
            "msg":    "请求未携带token,无权访问",
         })
         // 在被调用的函数中阻止后续中间件的执行
         c.Abort()
         return
      }
      // 解析token中包含的相关信息(有效载荷)
      claims, err := util.ParseToken(token)
      if err != nil {
         c.JSON(http.StatusForbidden, gin.H{
            "status": http.StatusForbidden,
            "msg":    "token解析失败",
            "error":  err.Error(),
         })
         c.Abort()
         return
      }
      // 判断该token是不是最新的token(从redis里面查)
      ua := c.GetHeader("User-Agent")
      val, err := dao2.Rdb.HGet(dao2.RCtx, strconv.Itoa(int(claims.Uid)), ua).Result()
      if err != nil {
         // 说明该token是其他User-Agent的token(你如说电脑端的token,当然不能用来登录手机端)
         c.JSON(http.StatusForbidden, gin.H{
            "status": http.StatusForbidden,
            "msg":    "token所属的User-Agent不匹配",
            "error":  err.Error(),
         })
         c.Abort()
         return
      }
      if token != val {
         // 请求携带的token与redis中存储的token不一致,说明是旧的token
         c.JSON(http.StatusForbidden, gin.H{
            "status": http.StatusForbidden,
            "msg":    "token失效!",
         })
         c.Abort()
         return
      }
      // 处理过期token
      if time.Now().Unix() > claims.ExpiresAt {
         c.JSON(http.StatusForbidden, gin.H{
            "status": http.StatusForbidden,
            "msg":    "token已经过期了", // token过期就得重新登录的
         })
         c.Abort()
         return
      }
   }
}
func (AuthService) GetCode(ctx context.Context, req *GetCodeRequest) (*GetCodeResponse, error) {
   redisCode, err := dao.Rdb.Get(dao.RCtx, common.AuthCode+req.Phone).Result()
   if !errors.Is(err, redis.Nil) {
      // 如果没有这个错误的话,就说明我的redis里面已经有验证码了
      // 我做出相应的处理,避免接口被刷
      split := strings.Split(redisCode, "_")
      s := split[1]
      redistime, _ := strconv.ParseInt(s, 10, 64)
      if time.Now().UnixNano()-redistime < 60*1000 {
         // 60s
         return &GetCodeResponse{Code: common.CodeRepeat}, nil
      }
   }
   // 我的redis里面没有验证码,那么此时就要往redis里面加入验证码了
   phone := req.Phone
   // 生成验证码
   code := util.Code()
   // 把验证码保存到redis里面去
   // 设计5分钟的过期时间
   dao.Rdb.Set(dao.RCtx, common.AuthCode+phone, code, time.Minute*5)
   // TODO 整合阿里云的短信服务
   return &GetCodeResponse{
      Code: http.StatusOK,
   }, nil
}

func (AuthService) Login(ctx context.Context, req *LoginRequest) (*Response, error) {
   var user model.User
   result := dao.MysqlDB.Where(&model.User{UserName: req.Username}).First(&user)
   err := result.Error
   if err != nil {
      if errors.Is(err, gorm.ErrRecordNotFound) {
         // 如果数据库中没有找到记录
         return &Response{
            Code: common.UserNotExist,
            Msg:  "用户不存在,请先注册",
         }, errors.New("该用户不存在")
      }
      // 不是用户不存在却还是继续出错,就说明是其他不可抗拒的因素
      return &Response{
         Code: http.StatusInternalServerError,
         Msg:  "查询数据库出现错误",
      }, nil
   }
   // 用户从数据库中找到了,检验密码
   ok, err := user.CheckPassword(req.Password)
   if err != nil {
      return &Response{
         Code: http.StatusInternalServerError,
         Msg:  "登录失败",
      }, nil
   }
   if !ok {
      return &Response{
         Code: common.PasswordErr,
         Msg:  "密码错误,登录失败",
      }, nil
   }
   // 登录成功要分发token(其他功能需要身份验证,给前端存储的)
   token, err := util.GenerateToken(uint(user.ID), user.UserName)
   if err != nil {
      return &Response{
         Code: http.StatusInternalServerError,
         Msg:  "token签发失败",
      }, nil
   }
   // 签发token后,存储到redis中
   m := map[string]string{req.UserAgent: token}
   dao.Rdb.HSet(dao.RCtx, common.AuthToken+strconv.FormatUint(uint64(user.ID), 10), m)
   return &Response{
      Code:  http.StatusOK,
      Msg:   "登录成功",
      Token: token,
   }, nil
}

func (AuthService) Register(ctx context.Context, rep *RegisterRequest) (*Response, error) {
   var user model.User
   var count int64
   dao.MysqlDB.Where(&model.User{
      UserName: rep.Username,
   }).First(&user).Count(&count)
   if count == 1 {
      return &Response{
         Code: common.UserHaveBeenRegister,
         Msg:  "用户已经注册过了",
      }, nil
   }
   // 如果数据库中没有该用户,就开始注册
   user.UserName = rep.Username
   worker, _ := util.NewWorker(0)
   id := worker.GetId()
   user.ID = id
   err := user.SetPassword(rep.Password)
   if err != nil {
      return &Response{
         Code:  http.StatusInternalServerError,
         Msg:   "数据库插入错误",
         Token: "",
      }, nil
   }
   // 加密成功就可以创建用户了
   err = dao.MysqlDB.Create(&user).Error
   if err != nil {
      return &Response{
         Code:  http.StatusInternalServerError,
         Msg:   "数据库添加数据出错",
         Token: "",
      }, nil
   }
   return &Response{
      Code: http.StatusOK,
      Msg:  "用户注册成功",
   }, nil
}

func (AuthService) Phone(ctx context.Context, req *PhoneRequest) (*Response, error) {
   phone := req.Phone
   userAgent := req.UserAgent
   code := req.Code // 验证码
   // 我们首先验证验证码,如果验证码都错误了,那么后续的工作就是免谈的
   result, err := dao.Rdb.Get(dao.RCtx, common.AuthCode+phone).Result()
   if errors.Is(err, redis.Nil) {
      // 说明没有所谓的验证码
      return &Response{
         Code: http.StatusForbidden,
         Msg:  "请输入验证码",
      }, nil
   }
   // 到这里就说明是有验证码的
   if result != code {
      // 如果验证码错误的话
      return &Response{
         Code: common.CodeErr,
         Msg:  "验证码错误",
      }, nil
   }
   var user model.User
   // 到这里说明验证码是没有任何问题的,我们就开始验证此用户是否存在
   err = dao.MysqlDB.Where(&model.User{Phone: phone}).First(&user).Error
   if err != nil {
      if errors.Is(err, gorm.ErrRecordNotFound) {
         // 说明这个用户不存在,我们给它注册一个新账号
         worker, _ := util.NewWorker(0)
         id := worker.GetId()
         user.ID = id
         user.Phone = phone
         user.UserName = common.UserNamePrefix + phone // 这里就直接把用户名随机的进行定义
         err = dao.MysqlDB.Create(&user).Error
         if err != nil {
            return &Response{
               Code: http.StatusInternalServerError,
               Msg:  "数据库添加数据出错",
            }, nil
         }
      }
      return &Response{
         Code: http.StatusInternalServerError,
         Msg:  "查询数据库出现错误",
      }, nil
   }
   // 登录成功,分发token
   token, err := util.GenerateToken(uint(user.ID), user.UserName)
   if err != nil {
      return &Response{
         Code: http.StatusInternalServerError,
         Msg:  "token签发失败",
      }, nil
   }
   // 签发token之后存储到redis中
   m := map[string]string{userAgent: token}
   dao.Rdb.HSet(dao.RCtx, strconv.FormatUint(uint64(user.ID), 10), m)
   // 到这里的时候,一定是已经存在账户了,那么我们就直接登录成功就可以了
   // 注意,这里需要删除验证码!否则我们的用户登录之后马上退出登录,然后用同一个验证码是可以成功的
   dao.Rdb.Del(dao.RCtx, common.AuthCode+phone)
   return &Response{
      Code: http.StatusOK,
      Msg:  "登录成功",
   }, nil
}

func (AuthService) Logout(ctx context.Context, req *LogoutRequest) (*LogoutResponse, error) {
   // 把redis里面的数据删除就可以了,其他的前端会给我们解决的
   userAgent := req.UserAgent
   // 我们从JWT里面来解析用户的ID
   token := req.Token
   parseToken, err := util.ParseToken(token)
   if err != nil {
      log.Println("token解析失败")
   }
   uid := parseToken.Uid
   err = dao.Rdb.HDel(dao.RCtx, common.AuthToken+strconv.Itoa(int(uid)), userAgent).Err()
   if err != nil {
      log.Println(err)
   }
   return &LogoutResponse{Code: http.StatusOK}, nil
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值