基于Cookie、Session和基于Token的认证模式介绍 | 青训营笔记

281 阅读10分钟

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

用户认证

HTTP是一个无状态的协议,一次请求结束后,下次在发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户),在Web应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。

1.Cookie- Session 认证模式

在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下:

  • 客户端使用用户名、密码进行认证

  • 服务端验证用户名、密码正确后生成并存储Session,将SessionID通过Cookie返回给客户端

  • 客户端访问需要认证的接口时在Cookie 中携带SessionlD

  • 服务端通过SessionID查找Session并进行鉴权,返回给客户端需要的数据 image.png 基于Session的方式存在多种问题。

  • 服务端需要存储Session,并且由于Session需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。

  • 当需要扩展时,创建Session的服务器可能不是验证Session的服务器,所以还需要将所有Session单独存储并共享。

  • 由于客户端使用Cookie存储SessionlD,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。

2.Token认证模式

鉴于基于Session 的会话管理方式存在上述多个缺点,基于Token 的无状态会话管理方式诞生了,所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session,逻辑如下:

  • 客户端使用用户名、密码进行认证
  • 服务端验证用户名、密码正确后生成Token返回给客户端
  • 客户端保存Token,访问需要认证的接口时在URL参数或HTTP Header中加入Token
  • 服务端通过解码Token进行授权,返回给客户端需要的数据

image.png

3.JWT介绍

JWT是JSON Web Token 的缩写,是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC7519)。JWT本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token 的生成过程,特别适用于分布式站点的单点登录(SSO)场景。

JWT就是一种基于Token的轻量级认证模式,服务端认证通过后,会生成一个JSON对象,经过签名后得到一个Token(令牌)再发回给用户,用户后续请求只需要带上这个Token,服务端解密之后就能获取该用户的相关信息了。

其他内容,详见在gin框架中使用JWT

image.png

以一个JWT Token 为例:

eyJhbGcioiJIUzI1NiIsInR5cCI6IkpxVCJ9
.eyJ1c2VyX2lkIjoyODAxODcyNzQ4ODMyMZU4NSwiZXhwIjoxNTkONTQwMjkxLCJpc3MiOiJibHVlYmVsbCJ9
.lk_ZrAtYGCeZhK3iupHxP1kgjBTzQTVTtX0izYFx9wU

它是由.分隔的三部分组成,这三部分依次是:

  • 头部(Header):存储了所使用的加密算法和Token类型
  • 负载(Payload):自定义信息内容
  • 签名(Signature):头部Base64通过加密算法和密钥生成的,防止令牌信息被篡改

头部和负载以JSON形式存在,这就是JWT中的JSON,三部分的内容都分别单独经过了Base64编码,以.拼接成一个JWT Token。

image.png

Header

JWT的Header中存储了所使用的加密算法和Token类型。

{
  "alg": "HS256", // HS256:带有SHA-256的HMAC
  "TYP": "jwt"
}

Payload

Payload表示负载(将Token当做是一个载体,表示Token里面装的是什么),也是一个JSON对象,JWT规定了7个官方字段供选用

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

除了官方字段,开发者也可以自己指定字段和内容,例如下面的内容。

{
  "sub" : "1234567890",
  "name " : "John Doe" ,
  "admin " : true
}

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

Signature

Signature部分是对前两部分的签名,防止数据篡改。

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

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

JWT优缺点

JWT拥有基于Token的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用Cookie的浏览器环境中正常运行。

1、jwt基于json,非常方便解析
2、可以在令牌中自定义丰富的内容,易扩展;
3、通过非对称加密算法及数字签名技术。

而JWT的最大优势是服务端不再需要存储Session,使得服务端认证鉴权业务可以方便扩展,避免存储Session所需要引入的Redis等组件,降低了系统架构复杂度。但这也是JWT最大的劣势,由于有效期存储在Token 中,JWTToken一旦签发,就会在有效期内一直可用,无法在服务端废止,当用户进行登出操作,只能依赖客户端删除掉本地存储的JWT Token,如果需要禁用用户,单纯使用JWT就无法做到了。

JWT常见的加密算法

常见的加密算法:

1、可逆加密算法

加密后,密文可以解密得到原文

一般用于签名和认证。私钥服务器保存, 用来加密, 公钥客户拿着用于对于令牌或 者签名的解密或者校验使用.

常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用 SHA‐256 的 RSA 签名)

2、不可逆加密算法

加密后,不能反向解密得到原文

一般用于敏感信息,密码,卡号等不可解密的信息

常见的不可逆加密算法有:MD5、SHA、HMAC 4.3.Base64编码 HS256:带有SHA-256的HMAC

3、对称加密

4、非对称加密

base64是网络上最常见的用于传输8bit字节代码的编码方式之一。Base64编码可用于在 HTTP环境下传递较长的标识信息。采用Base64编码解码具有不可读性,即所编码的数据 不会被人用肉眼所直接看到。

注意:Base64只是一种编码方式,不算加密方法。

JWT签名算法中HS256和RS256有什么区别

JWT签名算法中,通常有两个选择,一个采用HS256,另一个就是采用RS256。
签名其实是一个加密的过程,生成一段标识(也是JWT的一部分)做为接收方验证信息是否被篡改的依据。算法

  • RS256 (采用SHA-256 的 RSA 签名) 是一种非对称算法, 它使用公共/私钥对: 标识提供方采用私钥生成签名, JWT 的使用方获取公钥以验证签名。因为公钥 (与私钥相比) 不须要保护, 所以大多数标识提供方使其易于使用方获取和使用 (一般经过一个元数据URL)。
  • 另外一方面, HS256 (带有 SHA-256 的 HMAC 是一种对称算法, 双方之间仅共享一个 密钥。因为使用相同的密钥生成签名和验证签名, 所以必须注意确保密钥不被泄密。编程

在开发应用的时候启用JWT,使用RS256更加安全,你能够控制谁能使用什么类型的密钥。另外,若是你没法控制客户端,没法作到密钥的彻底保密,RS256会是个更佳的选择,JWT的使用方只须要知道公钥。安全

因为公钥一般能够从元数据URL节点得到,所以能够对客户端进行进行编程以自动检索公钥。若是采用这种方式,从服务器上直接下载公钥信息,能够有效的减小配置信息。

基于jwt实现认证实践

前面讲的Token,都是Access Token,也就是访问资源接口时所需要的Token,还有另外一种Token,Refresh Token,通常情况下,Refresh Token的有效期会比较长,而Access Token 的有效期比较短,当Access Token由于过期而失效时,使用Refresh Token就可以获取到新的Access Token如果Refresh Token也失效了,用户就只能重新登录了。

在JWT的实践中,引入Refresh Token,将会话管理流程改进如下。

  • 客户端使用用户名密码进行认证
  • 服务端生成有效时间较短的Access Token(例如10分钟),和有效时间较长的RefreshToken(例如7天)
  • 客户端访问需要认证的接口时,携带Access Token
  • 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
  • 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用Refresh Token向刷新接口申请新的Access Token
  • 如果Refresh Token没有过期,服务端向客户端下发新的Access Token客户端使用新的Access Token访问需要认证的接口

image.png 后端需要对外提供一个刷新Token的接口,前端需要实现一个当Access Token过期时自动请求刷新Token接口获取新Access Token的拦载器。

gin框架使用jwt

jwt-go库的基本使用详见:在gin框架中使用JWT

鉴权中间件开发

const (
    ContextUserIDKey = "userID"
)

var (
    ErrorUserNotLogin = errors.New("当前用户未登录")
)

// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
    return func(c *gin.Context) {
        // 客户端携带Token有三种方式 1.放在请求头 2.放在请求体 3.放在URI
        // 这里假设Token放在Header的Authorization中,并使用Bearer开头
        // 这里的具体实现方式要依据你的实际业务情况决定
        authHeader := c.Request.Header.Get("Authorization")
        if authHeader == "" {
            controller.ResponseErrorWithMsg(c, controller.CodeInvalidToken, "请求头缺少Auth Token")
            c.Abort()
            return
        }
        // 按空格分割
        parts := strings.SplitN(authHeader, " ", 2)
        if !(len(parts) == 2 && parts[0] == "Bearer") {
                controller.ResponseErrorWithMsg(c, controller.CodeInvalidToken, "Token格式不对")
                c.Abort()
                return
        }
        // parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它
        mc, err := jwt.ParseToken(parts[1])
        if err != nil {
            fmt.Println(err)
            controller.ResponseError(c, controller.CodeInvalidToken)
            c.Abort()
            return
        }
        // 将当前请求的userID信息保存到请求的上下文c上
        c.Set(.ContextUserIDKey, mc.UserID)
        c.Next() // 后续的处理函数可以用过c.Get(ContextUserIDKey)来获取当前请求的用户信息
    }
}

生成access token和refresh token

// GenToken 生成access token 和 refresh token 
func GenToken(userID uint64, username string) (Token string, err error) {
    // 创建一个我们自己的声明
    c := MyClaims{
        userID, 	// 自定义字段
        "username",	// 自定义字段
        jwt.StandardClaims{	// JWT规定的7个官方字段
            ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), // 过期时间
            Issuer:    "bluebell",                                 // 签发人
        },
    }
    // 加密并获得完整的编码后的字符串token
    Token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(mySecret)

    // refresh token 不需要存任何自定义数据
    // rToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
        // ExpiresAt: time.Now().Add(time.Second * 30).Unix(), // 过期时间
        // Issuer:    "bluebell",                              // 签发人
    //}).SignedString(mySecret)	// 使用指定的secret签名并获得完整的编码后的字符串token
    return
}

解析access token

// 解析JWT
func ParseToken(tokenString string) (claims *MyClaims, err error) {
    // 解析token
    var token *jwt.Token
    claims = new(MyClaims)
    token, err = jwt.ParseWithClaims(tokenString, claims, keyFunc)
    if err != nil {
        return
    }
    if !token.Valid { // 校验token
        err = errors.New("invalid token")
    }
    return
}

refresh token

// RefreshToken 刷新AccessToken
func RefreshToken(aToken, rToken string) (newAToken, newRToken string, err error) {
    // refresh token无效直接返回
    if _, err = jwt.Parse(rToken, keyFunc); err != nil {
        return
    }

    // 从旧access token中解析出claims数据
    var claims MyClaims
    _, err = jwt.ParseWithClaims(aToken, &claims, keyFunc)
    v, _ := err.(*jwt.ValidationError)

    // 当access token是过期错误 并且 refresh token没有过期时就创建一个新的access token
    if v.Errors == jwt.ValidationErrorExpired {
        return GenToken(claims.UserID)
    }
    return
}