跳至内容

⽤户认证

⽤户认证

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

Cookie- Session 认证模式

在 Web 应用发展的初期,⼤部分网站采用基于 Cookie-Session 的会话管理⽅式,逻辑如下。

  1. 客户端输入⽤户名、密码进⾏认证
  2. 服务端验证⽤户名、密码正确后生成并存储 Session,将 SessionID 通过 Cookie 返回给客户端
  3. 客户端访问需要认证的接口时在 Cookie 中携带 SessionID
  4. 服务端通过 SessionID 查找 Session 并进⾏鉴权,返回给客户端需要的数据

Cookie-Session 模式

基于 Session 的⽅式存在多种问题。

  • 服务端需要存储 Session,并且由于 Session 需要经常快速查找,通常存储在内存或内存数据库 中,同时在线用户较多时需要占用⼤量的服务器资源。
  • 当需要扩展时,创建 Session 的服务器可能不是验证 Session 的服务器,所以还需要将所有 Session 单独存储并共享。
  • 由于客户端使⽤ Cookie 存储 SessionID,在跨域场景下需要进行兼容性处理,同时这种⽅式也难以防范 CSRF 攻击。

Token 认证模式

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

  1. 客户端使⽤用户名、密码进行认证
  2. 服务端验证⽤户名、密码正确后⽣成 Token 返回给客户端
  3. 客户端保存 Token,访问需要认证的接⼝时在 URL 参数或 HTTP Header 中加⼊ Token
  4. 服务端通过解码 Token 进行鉴权,鉴权通过后返回给客户端需要的数据

基于 Token 的会话管理⽅式有效解决了基于 Session 的会话管理存在的问题。

  • 服务端不需要存储和⽤户鉴权有关的信息,鉴权信息会被加密到 Token 中,服务端只需要读取 Token 中包含的鉴权信息即可
  • 避免了共享 Session 导致的系统不易扩展问题
  • 不需要依赖 Cookie,有效避免 Cookie 带来的 CSRF 攻击问题
  • 使⽤ CORS 可以快速解决跨域问题

JWT介绍

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

一个 JWT Token 就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjE5MjYxOTkwNTg1NTMxMTQ2MjQsIlVzZXJuYW1lIjoiUTFtaSIsImlzcyI6Imxpd2VuemhvdS5jb20iLCJzdWIiOiJkZW1vIiwiZXhwIjoxNzQ4MjI4NTEzfQ.NHFkKk2e87CYDqp3NmDEzI6UNAg5K6Tv3IuyDf8_jlw

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

  • 头部(Header)
  • 负载(Payload)
  • 签名(Signature)

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

JWT (JSON Web Token)

Header

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

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload

Payload 表示负载,也是一个 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 的浏览器环境中正常运行。

而 JWT 的最⼤优势是服务端不再需要存储 Session,使得服务端认证鉴权业务可以⽅方便便扩展,避免存储Session 所需要引入的 Redis 等组件,降低了系统架构复杂度。

但这也是 JWT 最大的劣势,由于有效期存储在 Token 中,JWT Token ⼀旦签发,就会在有效期内⼀直可用,⽆法在服务端废止,当⽤户进行登出操作,只能依赖客户端删除掉本地存储的 JWT Token,如果需要禁⽤用户,单纯使用 JWT 就⽆法做到了。(可以前置增加一个黑名单过滤,在黑名单中的 token 直接拒绝)

基于 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 分钟)和有效时间较长的 Refresh Token(例如7天)
  • 客户端访问需要认证的接⼝时,携带 Access Token
  • 如果 Access Token 没有过期,服务端鉴权通过后返回给客户端需要的数据
  • 如果携带 Access Token 访问需要认证的接⼝时,返回鉴权失败(例如返回 401 错误),则客户端使用 Refresh Token 向刷新接口申请新的 Access Token
  • 如果 Refresh Token 没有过期,服务端向客户端下发新的 Access Token,客户端使⽤新的 Access Token 访问需要认证的接⼝

image-20250531191711400

后端需要对外提供⼀个刷新 Token 的接口,前端需要实现⼀个当 Access Token 过期时⾃动请求刷新 Token 接口获取新 Access Token的拦截器。

Go 语言 Jwt 库

github.com/golang-jwt/jwt/v5

下载依赖

go get -u github.com/golang-jwt/jwt/v5

引入依赖

import "github.com/golang-jwt/jwt/v5"

创建 Token

mySigningKey := []byte("AllYourBase")

type MyCustomClaims struct {
	Foo string `json:"foo"`
	jwt.RegisteredClaims
}

// Create claims with multiple fields populated
claims := MyCustomClaims{
	"bar",
	jwt.RegisteredClaims{
		// A usual scenario is to set the expiration time relative to the current time
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
		IssuedAt:  jwt.NewNumericDate(time.Now()),
		NotBefore: jwt.NewNumericDate(time.Now()),
		Issuer:    "test",
		Subject:   "somebody",
		ID:        "1",
		Audience:  []string{"somebody_else"},
	},
}

fmt.Printf("foo: %v\n", claims.Foo)

// Create claims while leaving out some of the optional fields
claims = MyCustomClaims{
	"bar",
	jwt.RegisteredClaims{
		// Also fixed dates can be used for the NumericDate
		ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)),
		Issuer:    "test",
	},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(mySigningKey)
fmt.Println(ss, err)

解析 Token

tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA"

type MyCustomClaims struct {
	Foo string `json:"foo"`
	jwt.RegisteredClaims
}

token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
	return []byte("AllYourBase"), nil
})
if err != nil {
	log.Fatal(err)
} else if claims, ok := token.Claims.(*MyCustomClaims); ok {
	fmt.Println(claims.Foo, claims.RegisteredClaims.Issuer)
} else {
	log.Fatal("unknown claims type, cannot proceed")
}
最后更新于 • Q1mi