目录
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算法)或使用RSA或ECDSA的公钥/私钥对对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对象的内容认证用户。
这样服务端不用保存任何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>
//这里简化了
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>
//这里简化了
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来代码的key2、针对token声明部分再额外做验证,可一定程度避免key泄露导致token伪造,声明部分的额外验证最好带有环境标识(不公开的,比如环境的id之类的)