jwt认证和使用go实现的学习及思考

目录

json web token 介绍

1、认证方案

2、组成

JWT结构

Header头部

Claims声明

Signature 签名

3、令牌的校验

 使用go实现jwt

主要代码:

处理逻辑

验证:

一些安全问题的思考

参考


json web token 介绍

1、认证方案

  • 传统认证方案(session + cookie)

HTTP协议是一种无状态的协议,这意味着用户提供账号和密码进行登录认证后,下次再请求的时候,仍然需要认证,因为服务器并不知道是谁发送的请求,并不知道该用户已经认证过一次。 所以为了解决这一问题,保持客户端与服务端的会话状态,在服务器的缓存中需要为每位用户分配一份存储空间,用于存储用户的个人登录信息等,且每份存储空间有个唯一标识ID作为自己的身份证;   这样在作响应的时候,将该ID返回给浏览器,浏览器存储到本地,以便后续再次请求时都可以携带着这个ID,服务器就能根据这个ID去对应缓存空间中去查找是否存在对应的存储区,能找到则表示该用户之前已经访问过了,存储区存储的登录信息等也可以直接使用,就不用再次登录了。

过程:

  • 用户向服务器发送username+password;
  • 服务器验证以后,存储该用户的登录信息,如userId/role/loginTime等;
  • 服务器向用户返回 session_id,写入用户的cookie;
  • 用户随后的每一次请求,都通过Cookie,将session_id传回服务器;
  • 服务器收到session_id,找到前期保留的数据,由此得到用户的身份(userId/role等);

弊端:

  • 随着用户增加服务器的开销也会显然增大;
  • 在处理分布式应用的情境下会相应的限制负载均衡器的能力;
  • Cookie存储在客户端,如果被拦截窃取,会很容易受到CSRF跨域伪造请求攻击;
  • 除浏览器之外的其他设备对Cookie的支持并不友好,对跨平台支持不好;
  • JWT认证方案(Json Web Token)

JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以JSON方式安全地传输信息。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用秘密(使用HMAC算法)或使用RSAECDSA的公钥/私钥对对JWT进行签名

直白的讲jwt就是一种用户认证(区别于session、cookie)的解决方案。

过程:

JWT的认证过程是,客户端将用户名和密码传入服务端,服务端经过认证后,将生成一个JSON对象,发回给用户,JSON对象大概的格式:

{
  "姓名": "张三",
  "角色": "管理员",
  "到期时间": "1693202985"
}
HTTP/1.1 200 OK
...
Set-Cookie:token=JWT令牌
Authorization:JWT令牌
Token:JWT令牌
...

{..., token:JWT令牌}

以后客户端再与服务端通信的时候,都要带上这个JSON对象,服务端校验JSON对象的内容认证用户。

jwt认证和使用go实现的学习及思考_第1张图片

 

这样服务端不用保存任何session信息,服务端变成无状态的,扩展性较好。

缺点:

  • 用户无法主动登出,只要token在有效期内就有效。这里可以考虑redis设置同token有效期一直的黑名单解决此问题。
  • token过了有效期,无法续签问题。可以考虑通过判断旧的token什么时候到期,过期的时候刷新token续签接口产生新token代替旧token。

2、组成

JWT结构

jwt由以下三部分构成:
* Header:头部 (对应:Header):令牌头部,记录了整个令牌的类型和签名算法
* Claims:声明 (对应:Payload):令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里
* Signature:签名 (对应:Signature):令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改

token的完整格式由上面三部分通过  ". " 连接,比如如下所示

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidXNlcl9uYW1lIjoiYWExMSIsImV4cCI6MTY4OTczNjkxNCwiaXNzIjoidG9kb19saXN0In0.5J4OrWR1gbqmmtgYs4e0zlru86sVNAr99pwGCpoK0j4

各部分对应的值为:

  • eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • eyJpZCI6MiwidXNlcl9uYW1lIjoiYWExMSIsImV4cCI6MTY4OTczNjkxNCwiaXNzIjoidG9kb19saXN0In0
  • 5J4OrWR1gbqmmtgYs4e0zlru86sVNAr99pwGCpoK0j4

Header头部

Header中指明jwt的签名算法,如:

{
  "typ": "JWT",
  "alg": "HS256"
}
  • alg:signature部分使用的签名算法,通常可以取两个值
    • HS256:一种对称加密算法,使用同一个秘钥对signature加密解密
    • RS256:一种非对称加密算法,使用私钥加密,公钥解密
  • typ:整个令牌的类型,固定写JWT即可

 Ps:设置好了header的结构之后,还需要对header的JSON对象进行Base64 URL编码,最后编码后生成的字符串才是最终上述的header部分。

Claims声明

声明中有jwt自身预置的,使用时可选。当然,我们也可以加入自定义的声明,
如uid,userName之类信息,但一定不要声明重要或私密的信息(比如密码),因为这些信息是可破解的

包含演示:

{
  	"iss":"发行者",
    "iat":"发布时间",
    "exp":"到期时间",
    "sub":"主题",
    "aud":"听众",
    "nbf":"在此之前不可用",
  	"jti":"JWT ID"
}

以上属性可以全写,也可以一个都不写,它只是一个规范,就算写了,也需要你在将来验证这个JWT令牌时手动处理才能发挥作用。上述属性表达的含义分别是:

ss:发行该jwt的是谁,可以写公司名字,也可以写服务名称
iat:该jwt的发放时间,通常写当前时间的时间戳
exp:该jwt的到期时间,通常写时间戳
sub:该jwt是用于干嘛的
aud:该jwt是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点
nbf:一个时间点,在该时间点到达之前,这个令牌是不可用的
jti:jwt的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)
{
	// 签发者
	"issuer":"whereabouts.icu",
	// 令牌所有者,存放ID等标识
	"owner":"korbin",
	// 用途,默认值authentication表示用于登录认证
	"purpose":"Authentication",
	// 接受方,表示申请该令牌的设备来源,如浏览器、Android等
	"recipient":"Browser",
	// 令牌签发时间
	"time":1614074776,
	// 过期时间
	"expire":1614078376,
	// 令牌持续时间,即生命周期
	"duration":1800000000000,
	// 其他扩展的自定义参数
	"external":{}
}

Signature 签名

在生成jwt的token(令牌的意思)串时,先将Header和Claims用base64编码,再用Header中指定的加密算法,
将编码后的2个字符串(header+ claims 使用 “.” 串联起来)进行加密(签名)。加密时需要用到一个signString签名串,我们可指定自己的signString,
不同的signString生成的加密结果不一样(解密时可能也需要同样的串,视加密算法而定)。
 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MiwidXNlcl9uYW1lIjoiYWExMSIsImV4cCI6MTY4OTczNjkxNCwiaXNzIjoidG9kb19saXN0In0.
5J4OrWR1gbqmmtgYs4e0zlru86sVNAr99pwGCpoK0j4

则第三部分就是用对称加密算法HS256对该字符串进行加密,并指定一个密钥,如:secret

HS256(`header.claims`, "secret")
//得到:5J4OrWR1gbqmmtgYs4e0zlru86sVNAr99pwGCpoK0j4

最终,将三部分通过.组合在一起,就得到了完整的JWT。并且由于签名使用的秘钥保存在服务器,客户端就无法伪造出签名,因为它拿不到秘钥。换句话说,之所以说无法伪造JWT,就是因为第三部分signature的存在。而前面两部分并没有加密,只是一个编码结果而已,可以认为几乎是明文传输,粘贴到在线解码网站里面解码出来就是个JSON对象。

3、令牌的校验

JWT的Sigature签名算法可以保证token不被伪造,那么如何保证令牌不被篡改呢?或者说服务器如何验证这个令牌是有效的呢?

  • header+payload用同样的秘钥和加密算法进行重新加密;
  • 然后把加密的结果和传入JWT中的signature进行对比,如果完全相同,则表示前面两部分没有动过,就是自己颁发的,如果不同,肯定是被篡改过了。

ps:当然还可以由其他验证,比如token是否过期。。。。

 使用go实现jwt

演示代码,参照:GitHub - lwangrabbit/golang-jwt

主要代码:

1、calaims.go
package main

import (
	"errors"
	"time"

	"github.com/dgrijalva/jwt-go"
)

type AuthClaim struct {
	UserId uint64 `json:"userId"`
	jwt.StandardClaims
}

var secret = []byte("this is key") //设置加密key

const TokenExpireDuration = 2 * time.Hour //设置token的有效期,两个小时

// 生成token
/*
1、在 GenToken 函数中,它首先根据传入的 userId 构建了一个 AuthClaim 结构体,其中包括用户ID和标准的JWT声明部分(过期时间和发布者)。

2、然后,使用 jwt.NewWithClaims 创建一个新的 JWT 对象,使用 jwt.SigningMethodHS256 算法对 JWT 进行签名。这里的 secret 是用于对 JWT 进行签名的密钥。
3、最后,通过 token.SignedString(secret) 方法将 JWT 进行签名并生成字符串表示,作为函数的返回结果。
*/
func GenToken(userId uint64) (string, error) {
	c := AuthClaim{
		UserId: userId,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
			Issuer:    "youzi",
		},
	}

	//构建一个token,其中claims声明部分用上面定义的authclaim结构体
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) //设置这个方法用于创建一个新的 JWT 对象,其中 jwt.SigningMethodHS256 是用于签名 JWT 的加密算法之一,它使用 HMAC SHA-256 算法。第二个参数 c 是一个声明(Claim)的结构体,你可以在声明中设置一些信息,例如过期时间、主题等。
	return token.SignedString(secret)                     //设置签名部分的加密的key这个方法是将 JWT 进行签名并生成字符串表示。secret 是用于对 JWT 进行签名的密钥。在签名时,使用指定的签名算法(在这里是 HS256)对 JWT 的头部和负载进行哈希运算,并使用密钥对哈希结果进行签名,生成最终的 JWT。

}

// 解析 验证token
/*
在 ParseToken 函数中,它接受一个 JWT 字符串作为参数,并尝试使用密钥 secret 来解析和验证 JWT。

使用 jwt.ParseWithClaims 方法解析 JWT,并传入一个用于验证的回调函数。如果解析成功且令牌有效,这个回调函数将返回密钥 secret,否则返回一个错误。

如果解析和验证成功,函数会返回解析后的声明结构 claim,否则返回一个错误。
*/
func ParseToken(tokenStr string) (*AuthClaim, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &AuthClaim{}, func(tk *jwt.Token) (interface{}, error) {
		return secret, nil
	})

	if err != nil {
		return nil, err
	}
	if claim, ok := token.Claims.(*AuthClaim); ok && token.Valid {
		return claim, nil
	}

	return nil, errors.New("Invalid token")
}




2、main.go

package main

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.POST("/login", loginHandler)

	api := r.Group("/api")
	api.Use(jwtAuthMiddleware())
	api.POST("/order", orderHandler)

	r.Run(":8888")

}

type LoginReq struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

func loginHandler(c *gin.Context) {
	var req LoginReq
	if err := c.BindJSON(&req); err != nil {
		c.JSON(http.StatusInternalServerError, "internal error")
		return
	}

	if req.Username == "admin" && req.Password == "admin" {
		token, err := GenToken(uint64(1001))
		if err != nil {
			c.JSON(http.StatusInternalServerError, err)
		} else {
			c.JSON(http.StatusOK, map[string]string{"token": token})
		}
		return

	}
	c.JSON(http.StatusForbidden, "forbidden")
}

type OrderReq struct {
	Product string `json:"product"`
	Count   string `json:"count"`
}

func orderHandler(c *gin.Context) {
	var req OrderReq
	if err := c.BindJSON(&req); err != nil {
		c.JSON(http.StatusInternalServerError, "invalid request")
		return
	}
	userId, _ := c.Get("userId")
	greet := fmt.Sprintf("你好 %v, 我打算给你 %v %v", userId, req.Count, req.Product)
	c.JSON(http.StatusOK, greet)

}

func jwtAuthMiddleware() func(c *gin.Context) {
	return func(c *gin.Context) {

		//标准做法: Authorization: Bearer 
		//这里简化了
		token := c.Request.Header.Get("token")
		if token == "" {
			c.JSON(http.StatusForbidden, "token为空")
			c.Abort()
			return
		}
		claim, err := ParseToken(token)
		if err != nil {
			c.JSON(http.StatusForbidden, "token 无效")
			return

		}
		c.Set("userId", claim.UserId)
		c.Next()
	}
}

处理逻辑

1、登录生成token

1、main.go
func loginHandler(c *gin.Context) {
	var req LoginReq
	if err := c.BindJSON(&req); err != nil {
		c.JSON(http.StatusInternalServerError, "internal error")
		return
	}

	if req.Username == "admin" && req.Password == "admin" {
		token, err := GenToken(uint64(1001))
		if err != nil {
			c.JSON(http.StatusInternalServerError, err)
		} else {
			c.JSON(http.StatusOK, map[string]string{"token": token})
		}
		return

	}
	c.JSON(http.StatusForbidden, "forbidden")
}

2、claim.go
type AuthClaim struct {
	UserId uint64 `json:"userId"`
	jwt.StandardClaims
}

var secret = []byte("this is key") //设置加密key

const TokenExpireDuration = 2 * time.Hour //设置token的有效期,两个小时

// 生成token
/*
1、在 GenToken 函数中,它首先根据传入的 userId 构建了一个 AuthClaim 结构体,其中包括用户ID和标准的JWT声明部分(过期时间和发布者)。

2、然后,使用 jwt.NewWithClaims 创建一个新的 JWT 对象,使用 jwt.SigningMethodHS256 算法对 JWT 进行签名。这里的 secret 是用于对 JWT 进行签名的密钥。
3、最后,通过 token.SignedString(secret) 方法将 JWT 进行签名并生成字符串表示,作为函数的返回结果。
*/
func GenToken(userId uint64) (string, error) {
	c := AuthClaim{
		UserId: userId,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
			Issuer:    "youzi",
		},
	}

	//构建一个token,其中claims声明部分用上面定义的authclaim结构体
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) //设置这个方法用于创建一个新的 JWT 对象,其中 jwt.SigningMethodHS256 是用于签名 JWT 的加密算法之一,它使用 HMAC SHA-256 算法。第二个参数 c 是一个声明(Claim)的结构体,你可以在声明中设置一些信息,例如过期时间、主题等。
	return token.SignedString(secret)                     //设置签名部分的加密的key这个方法是将 JWT 进行签名并生成字符串表示。secret 是用于对 JWT 进行签名的密钥。在签名时,使用指定的签名算法(在这里是 HS256)对 JWT 的头部和负载进行哈希运算,并使用密钥对哈希结果进行签名,生成最终的 JWT。

}

2、login登录成功后,服务器返回token,后续请求其他接口时,比如/api/order,请求头中带上token参数,由服务器端的middleware认证,认证ok,才会处理该请求

1、claim.go
// 解析 验证token
/*
在 ParseToken 函数中,它接受一个 JWT 字符串作为参数,并尝试使用密钥 secret 来解析和验证 JWT。

使用 jwt.ParseWithClaims 方法解析 JWT,并传入一个用于验证的回调函数。如果解析成功且令牌有效,这个回调函数将返回密钥 secret,否则返回一个错误。

如果解析和验证成功,函数会返回解析后的声明结构 claim,否则返回一个错误。
*/
func ParseToken(tokenStr string) (*AuthClaim, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &AuthClaim{}, func(tk *jwt.Token) (interface{}, error) {
		return secret, nil
	})

	if err != nil {
		return nil, err
	}
	if claim, ok := token.Claims.(*AuthClaim); ok && token.Valid {
		return claim, nil
	}

	return nil, errors.New("Invalid token")
}

2、main.go
func jwtAuthMiddleware() func(c *gin.Context) {
	return func(c *gin.Context) {

		//标准做法: Authorization: Bearer 
		//这里简化了
		token := c.Request.Header.Get("token")
		if token == "" {
			c.JSON(http.StatusForbidden, "token为空")
			c.Abort()
			return
		}
		claim, err := ParseToken(token)
		if err != nil {
			c.JSON(http.StatusForbidden, "token 无效")
			return

		}
		c.Set("userId", claim.UserId)
		c.Next()
	}
}

3、接口路由及jwtmiddleware认证

func main() {
	r := gin.Default()
	r.POST("/login", loginHandler)

	api := r.Group("/api")
	api.Use(jwtAuthMiddleware())
	api.POST("/order", orderHandler)

	r.Run(":8888")

}

验证:

1、 /login
curl -X POST http://localhost:8888/login -H "Content-type:application/json" -d '{"username": "admin", "password": "admin"}'

2、 /api/order

curl -X POST http://localhost:8888/api/order -H "Content-type:application/json" -H "token: login登录成功后返回的token" -d '{"product": "apple", "count": 3}'

一些安全问题的思考

1、token 的声明部分中如果包含password字段,会引发什么问题?

如开始结构声明介绍中所示,在claims部分中,是一定不要包含敏感信息的,因为这部分内容是可以解码的,也就是说如果token泄露,比如被中间人抓包等,那么通过解密就可以获知其中私密信息,比如如果包含密码,那么账号密码就不安全了。

解决方式:

1、禁止将私密信息包含在声明部分,比如密码字段

2、假如项目中,打包后,每个token的加密key是一样的,那么会引发什么什么问题?

如果项目中,加密key是一致的,同时没有对token的声明部分做额外的验证,那么会导致所有用这个项目搭建的系统,都会存在token伪造的问题,如上面项目所示,假如不同环境系统中userid存在一样的,那么可以扣出项目的token签发部分代码,在自己机器上生成token,然后用这个token去访问其他系统,导致伪造token成功

解决方式:
1、签发token的key需要自己定义,避免直接复用copy来代码的key

2、针对token声明部分再额外做验证,可一定程度避免key泄露导致token伪造,声明部分的额外验证最好带有环境标识(不公开的,比如环境的id之类的)

 

参考

GitHub - lwangrabbit/golang-jwt

JWT详解及Golang实现 - 掘金

你可能感兴趣的:(golang,web安全,安全,网络安全,golang,开发语言)