抖声项目鉴权实现 | 青训营笔记

221 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第6篇笔记

1. 架构设计

在设计抖声项目的服务认证的时候,参考了企业级的两种服务架构设计,一种是基于JWT实现,第二种是基于redis + token实现

1.1 JWT架构

1655184435391.png

客户应用从授权服务器进行身份验证,验证成功会携带JWT Token去请求服务器的相应服务

1.2 Redis+Token架构

image.png

客户应用从授权服务器进行身份验证,验证成功,redis会以键值对方式存储用户的token和用户的身份信息,客户应用携带token进行访问服务器时,会统一交给redis做验证,如果存在对应的token,便放行请求

2. 了解JWT

2.1 JWT的数据结构

分为3个部分,按顺序:

  • 头部(header)
  • 载荷(payload)
  • 签证(signature

image.png

头部(header)

JWT的头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256

JWT里验证和签名使用的算法,可选择下面的:

JWS算法名称描述
HS256HMAC256HMAC with SHA-256
HS384HMAC384HMAC with SHA-384
HS512HMAC512HMAC with SHA-512
RS256RSA256RSASSA-PKCS1-v1_5 with SHA-256
RS384RSA384RSASSA-PKCS1-v1_5 with SHA-384
RS512RSA512RSASSA-PKCS1-v1_5 with SHA-512
ES256ECDSA256ECDSA with curve P-256 and SHA-256
ES384ECDSA384ECDSA with curve P-384 and SHA-384
ES512ECDSA512ECDSA with curve P-521 and SHA-512

载荷(payload)

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

还可以自定义私有字段,如:

{
    "user_id": "1",
    "status" : "0",
    "name" : "root"
}

JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。 这个 JSON 对象也要使用 Base64URL 算法转成字符串。

签名(signature)

Signature 部分是对前两部分的签名,防止数据篡改。 首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret) 

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就构成整个JWT对象TOKEN返回给用户

2.2 JWT认证原理

image.png

用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT

客户端将 token 保存到本地,通常使用 localstorage,也可以使用 cookie

当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT

服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为

因为 JWT 载荷包含了一些会话信息,因此减少了需要查询数据库的需要

因为 JWT 并不使用 Cookie的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)

因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机

3、设计对比

JWT

  1. 去中心化,便于分布式系统使用
  2. 基本信息可以直接放在token中,减少从数据库的读取
  3. 功能权限较少的话,服务端无法主动让token失效

Redis + token

  1. 服务端可以主动让token失效,实现动态续签等功能
  2. 用户信息由数据中心redis统一管理,数据更安全
  3. 每次都要查询redis,占用redis存储空间

4、项目应用

image.png 为了实现踢人下线,防止多端登录,动态续签设计,使用JSON Web Token+redis来实现身份验证,服务端通过JWT签名算法HS256加密token生成令牌发放给客户端,客户端只持有令牌本身,会话数据放到Redis数据中心统一管理,此模式保证了服务器对会话数据的可控性和服务水平扩展时的会话一致性。当携带token访问API时,如果仍处于有效时间内,Redis会动态刷新token过期时间完成续签。Redis以键值对[token,user]方式存储用户会话数据,如果出现多端相同用户登录,会设置旧token获取的user的登录状态为下线状态,新token获取的user的登录状态为在线状态

4.1 创建令牌

为了防止多端登录,需要在用户信息中加入在线状态status,如果用户在线,则status为1,下线status则为0

type LoginUser struct {
   IssuedAt  int64
   ExpiresAt int64
   UserId    int64
   Name      string
   TokenKey  string
   Status    int //登录状态 0:在线 | 1:被挤下线
}

同时需要在redis维护一个字典hash,记录了用户id与tokenKey的映射

创建token的流程

1、首先生成uuid用于拼接成tokenKey

2、获取用户id与tokenKey的映射,如果存在,说明用户已经登录,说明存在多端登录,需要把之前登录的用户设置下线

3、然后设置新的用户id与tokenKey的映射,构建好用户信息后以[tokenKey,loginUser]存储方式存储进redis中

4、最后封装tokenKey,通过私钥和JWT加密算法HS256加密tokenKey,生成加密后的token

func CreateToken(user *models.User) (string, error) {
    uid, err := uuid.NewUUID()
    if err != nil {
        return "", err
    }
    tokenKey := LoginTokenKey + uid.String()
    //查找是否存在映射
    do, err := RedisDo("HGET", TokenUserHash, user.Id)
    if err != nil {
        return "", err
    }
    //如果存在映射
    if do != nil {
        t, err := redis.String(do, err)
        if err != nil {
            return "", err
        }
        //获取在线登录用户设置成下线状态
        loginUser, err := RedisTokenKeyValue(t)
        if err != nil {
            return "", err
        }
        loginUser.Status = 1
        //三分钟后下线状态移除tokenKey,转为下线用户tokenKey失效
        err = RedisCacheTokenKey(t, loginUser, 180)
        if err != nil {
            return "", err
        }
    }
    //设置新的用户id与tokenkey的映射hash
    err = RedisDoHash("HSET", TokenUserHash, user.Id, tokenKey)
    if err != nil {
        return "", err
    }
    fmt.Println("key:" + tokenKey)
    issuedTime := time.Now().Unix()
    //设置过期时间
    t := fmt.Sprintf("%dm", DefaultExpirationTime/60)
    am, err := time.ParseDuration(t)
    if err != nil {
        return "", err
    }
    expireTime := time.Now().Add(am).Unix()
    loginUser := &LoginUser{
        TokenKey:  tokenKey,
        IssuedAt:  issuedTime,
        ExpiresAt: expireTime,
        Name:      user.Name,
        UserId:    user.Id,
        Status:    0,
    }
    err = RedisCacheTokenKey(tokenKey, loginUser, DefaultExpirationTime)
    if err != nil {
        return "", err
    }
    stdClaims := jwt.StandardClaims{}
    uClaims := userStdClaims{
        StandardClaims: stdClaims,
        TokenKey:       tokenKey,
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, uClaims)
    tokenString, err := token.SignedString([]byte(PrivateKey))
    return tokenString, nil
}

4.3 动态续签

动态续签流程

1、首先是对token进行解密获得存储在Redis里面的tokenKey

2、判断tokenKey在redis是否为空

3、获取用户登录信息,检验登录状态,如果为下线状态,返回被挤下线

4、获取过期时间,与当前时间相比,判断token是否过期

5、如果当前时间与过期时间相差允许动态刷新的时间区内,更新用户信息里的过期时间和键的过期时间

func VeifyToken(token string) (*LoginUser, error) {
   tokenKey, err := JwtParseTokenKey(token)
   if err != nil {
      return nil, err
   }
   //判断tokenKey是否存在
   exist, err := RedisCheckKey(tokenKey)
   if exist == false || err != nil {
      return nil, errors.New("token is not exist")
   }
   //获取loginUser登录信息
   loginUser, err := RedisTokenKeyValue(tokenKey)
   if err != nil {
      return nil, err
   }
   fmt.Printf("loginUserStatus = %d", loginUser.Status)
   //判断loginUser登录状态
   if loginUser.Status == 1 {
      return nil, errors.New("your account is already logged in elsewhere")
   }
   //过期时间刷新
   expireTime := loginUser.ExpiresAt
   curTime := time.Now()
   //验证token是否失效
   if expireTime < curTime.Unix() {
      return nil, errors.New("token valid")
   }
   // token有效且相差不足20分钟,自动刷新
   am, _ := time.ParseDuration("20m")
   if expireTime <= curTime.Add(am).Unix() {
      err = RefreshToken(loginUser)
      if err != nil {
         return nil, err
      }
   }
   return loginUser, nil
}