JWT 认证机制讲解与实现
引入
什么是JWT
JSON Web Token (JWT)是一个开放标准(RFC 7519) ,它定义了一种紧凑和自包含的方式,
用于作为 JSON 对象在各方之间安全地传输信息
。此信息可以进行验证和信任,因为它是经过数字签名的。JWT 可以使用机密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。
JWT的作用
Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。
Information Exchange (信息交换) : 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWT可以被签名,例如,用公钥/私钥对,我们可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,我们还可以验证内容没有被篡改。
JWT 的结构
结构简介
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.FeGzJg7_KXcEvRMEje36wdF2vbC90BnC22CPDbkf1Iw
JWT由三个部分构成——Header
.Payload
.Signature
。
Header
,头部,包含Token
的类型(JWT)和所使用的签名算法,通常用于告知验证所用的算法,以及标识该项为JWT。Payload
,有效载荷,包含有关实体(用户)的数据。Signature
,签名,前面的两个用Base64Url
编码 后,用.
连接在一起,即Base64Url(Header)+"."+Base64Url(Payload)
后 再 经过 私钥加密 形成的一个字符串,用于验证令牌的完整性和真实性。
结构优势
可扩展性
JWT的可扩展性主要是因为它的
Payload
有效载荷部分,并非是钉死的规定的数据,官方只给出了七个建议字段,用户并非一定要按照它的做法来做,相反,我们可以按照自己的需求来编写响应的字段安全性
JWT的头部包含了加密算法,能让接收方知道如何解密JWT的有效载荷;签名则保证了JWT的完整性和真实性,防止相关信息被篡改或者是伪造。
无状态性
相较于利用传统的
session
来存储令牌信息,JWT不需要服务器额外存储一个数据来验证用户,只需要每次对令牌进行解签和验证即可,降低了服务器的负担。
Header
Header
一般包含两个信息:令牌类型和使用的算法。
typ
令牌类型:用于表示令牌的种类,即JWT本身。alg
加密算法:用于表示JWT使用的加密算法,即JWT的第三部分Signature
使用的加密算法。
{
"alg": "HS256",
"typ": "JWT"
}
Payload(Claims)
Payload
包含了要传输的信息,例如用户的身份、权限,以及JWT的签发时间、过期时间。
JWT的官方提供了 7 个建议的声明
iss(Issuer)
:令牌发行人,字段为字符串类型。sub(Subject)
:令牌的主题,通常是令牌所代表的用户或实体,如:主题为验证权限,则字段可以为"Authentication"
。aud (Audience)
:令牌的受众,可以是一个或多个字符串或URI,代表可以接收和处理令牌的目标,即用户。exp (Expiration Time)
:令牌的过期时间,用UNIX时间戳表示。nbf (Not Before)
:令牌的生效时间,以 UNIX 时间戳表示。iat (Issued At)
:令牌的签发时间,以 UNIX 时间戳表示。jti (JWT ID)
:令牌的唯一标识符,通常用于防止重放攻击。
当然,我们也也可以在这个部分中加入自己需要传输的数据,但是,切记 不能在这部分中加入用户敏感信息,因为JWT保护的只是这部分数据 不被修改 ,并非不被人阅读,其实每个人都能够阅读这个部分的信息,只需要用base64Url
解码即可。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Signature
Signature
中包含了对上述的Header
和Payload
的加密,这也是JWT能够无状态和保持信息不被篡改的关键点。
对比传统的session
,我们不需要去对比这个签名是否存在于数据库中,也就避免了去数据库中去搜寻这个是否存在,或者说,JWT把原本需要保存在服务器的Signature
,大大方方的存储在了本身中,只要密钥没有泄露,这部分的Signature
就保护着JWT的信息不被改变。
而Signature
的生成又与上述两部分强关联,它是先利用Base64Url
对上述两个部分进行编码,然后再利用Header
中的签名算法+服务器保存的密钥来生成的。
即
这样子,只要别人不知道密钥,就无法生成这个数字签名,也无法去更改Header
和Payload
了。
工作过程
我们以用户登录这个过程来简述一下JWT的工作过程。
首先前端收集用户的账号密码,并发送到后端,后端先验证用户的密码,如果验证正确,则在响应头中添加Authorization
字段,然后格式为Bearer JWT
。Bearer表示使用Bearer令牌类型。如:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
。
然后前端获取这个响应头,并保存这个响应头,然后在之后的每次的请求,都包含这个JWT,而后端在处理每次的请求之前,都会先检验这个JWT的有效性——即先通过加密算法按照前两部分和保存在服务器的密钥来计算相应的Signature
,并且与前端发来的Signature
做对比,如果一致,则继续检验Payload
部分的过期时间,看是否过期,以及用户权限是否足够请求,如果不够,则返回对应的错误信息。
这样子后端只需要完成鉴权即可,这也就是JWT的无状态性的最重要的点。
如果使用HS256
来作为加密算法的话,我们的密钥绝对不能够泄露,因为HS256
生成签名和验证签名都使用相同的密钥,一旦泄露,则所有人都能够生成签名,来对我们的服务器进行攻击,所以HS256
一般用于传统的单体应用。
而使用RS256
等非对称算法来加密的话,公钥则可以让其他人随意获得,因为公钥只能对密钥进行验证,不能生成密钥,所以即便是所有人都能够验证你签名的正确与否,别人也无法伪造出对应的数据。这样子以RS256
为加密算法的JWT就可以方便的利用于各个分布式的平台,验证的
我们接下来的代码编写以HS256
来作为加密算法讲解。
代码编写
当处理加密相关的数据时,为了保证数据的安全性和完整性,我们建议使用现有的加密库来实现,而不是自己编写算法。Go语言中有很多成熟的加密库可供使用,例如标准库中的crypto/hmac
和crypto/sha256
包,以及第三方库github.com/dgrijalva/jwt-go
。
这次我们以github.com/dgrijalva/jwt-go
来介绍创建JWT和解签的操作。
创建密钥
密钥的创立也是很重要的一个保护数据安全的过程,我们一般采用两种方法来生成密钥。
基于伪随机数生成器生成密钥
基于口令的加密(
Password-based Encryption,PBE
)来生成密钥
当然你也可以使用你自己随意指定的一段字符串,前提是你必须为你使用这个字符串作为密钥的后果负责,如果我是一位有耐心的破解者,我会不厌其烦的来尝试所有的弱密码组合,来试出你的密钥,从而大大方方的拿走你的数据,或者是进行随意的篡改。
这里我们采用crypto/rand
这个包来为我们生成伪随机数密钥
import (
"crypto/rand"
"encoding/base64"
)
func generateRandomString(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
}
这次生成了Aekdp1fhSwGeQgdUeRuzJ01Hx1zBivO7
,这一串长度为32
的一个字符串来作为我们的密钥。
令牌加密
我们采用github.com/dgrijalva/jwt-go
来进行加密和解密。
import "github.com/dgrijalva/jwt-go"
type MyClaims struct {
Username string `json:"username"`
jwt.StandardClaims
}
var Key = []byte("Aekdp1fhSwGeQgdUeRuzJ01Hx1zBivO7")
// MakeClaimsToken Token加密
func MakeClaimsToken(claims MyClaims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(Key)
return tokenString, err
}
上述代码中,我们指定了一个自己的结构体MyClaims
,其中包含了一个自己定义的字段Username
,并且包含了jwt.StandardClaims
,Claims
即为我们之前所说的Payload
,即为有效载荷信息。接着我们使用jwt.NewWithClaims
函数,将JWT初始化,并且指定了加密算法为jwt.SigningMethodHS256
,即HS256
加密算法。
接着返回tokenString
,即我们所求的JWT。
令牌验证
// ParseClaimsToken Token解签
func ParseClaimsToken(tokenStr string) (MyClaims, error) {
ParsedClaims := MyClaims{}
tokenClaims, err := jwt.ParseWithClaims(tokenStr, &ParsedClaims, func(token *jwt.Token) (interface{}, error) {
return Key, nil
})
if err != nil && !tokenClaims.Valid {
log.Printf("Invalid Token:%v\n", err)
}
return ParsedClaims, err
}
上述的函数接受一个JWT令牌字符串作为参数,并返回一个包含MyClaims
结构体和错误信息。
jwt.ParseWithClaims()
函数的第一个参数是要解析的Token,第二个参数则是我们要把解析的数据放入的变量,第三个参数是Keyfunc
,将被Parse
方法用作回调函数,以提供用于验证的键。函数接收已解析但未验证的令牌。
如果Token有问题则会返回相应的错误信息
测试
package main
import (
"JWTdemo/tools"
"github.com/dgrijalva/jwt-go"
"log"
"time"
)
func main() {
token, err := tools.MakeClaimsToken(tools.MyClaims{
Username: "",
StandardClaims: jwt.StandardClaims{
Audience: "test-user",
ExpiresAt: time.Now().Add(10 * time.Hour).Unix(),
Id: "1234",
IssuedAt: time.Now().Unix(),
Issuer: "BY-King",
NotBefore: time.Now().Unix(),
Subject: "test",
},
})
if err != nil {
log.Println(err)
} else {
log.Println(token)
}
orgToken, err := tools.ParseClaimsToken(token)
if err != nil {
log.Println(err)
} else {
log.Printf("%+v\n", orgToken)
}
}
上面的代码里面我们初始化了一个MyClaims
用来测试,首先是获取对应的令牌并打印,接着以打印的结果再对其进行解签与验证。
于是我们可以获得以下的信息
2023/05/15 02:05:14 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IiIsImF1ZCI6InRlc3QtdXNlciIsImV4cCI6MTY4NDEyMzUxNCwianRpIjoiMTIzNCIsImlhdCI6MTY4NDA4NzUxNCwiaXNzIjoiQlktS2luZyIsIm5iZiI6MTY4NDA4NzUxNCwic3ViIjoidGVzdCJ9.S7XlRwkULVRhrbmBXaX42X4W5ZJanl1zEjJelZgIkFs
2023/05/15 02:05:14 {Username: StandardClaims:{Audience:test-user ExpiresAt:1684123514 Id:1234 IssuedAt:1684087514 Issuer:BY-King NotBefore:1684087514 Subject:test}}
那如果我们在解签过程中遇到了其他问题怎么办呢?例如,令牌过期、令牌还未生效、权限不对......这些问题"github.com/dgrijalva/jwt-go"
也已经帮我们处理好了。
错误处理
我们略微修改一下刚刚的测试代码
package main
import (
"JWTdemo/tools"
"github.com/dgrijalva/jwt-go"
"log"
"time"
)
func main() {
token, err := tools.MakeClaimsToken(tools.MyClaims{
Username: "",
StandardClaims: jwt.StandardClaims{
Audience: "test-user",
ExpiresAt: time.Now().Unix() - 1,
Id: "1234",
IssuedAt: time.Now().Unix() - 3,
Issuer: "BY-King",
NotBefore: time.Now().Unix() - 2,
Subject: "test",
},
})
if err != nil {
log.Println(err)
} else {
log.Println(token)
}
orgToken, err := tools.ParseClaimsToken(token)
if err != nil {
log.Println(err)
} else {
log.Printf("%+v\n", orgToken)
}
}
我们在这次的代码中,特地修改了签发时间,生效时间,以及过期时间,这样子当我们验证令牌的时候,该令牌必然处于过期状态,这次又会出现什么呢?
2023/05/15 02:11:50 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IiIsImF1ZCI6InRlc3QtdXNlciIsImV4cCI6MTY4NDA4NzkwOSwianRpIjoiMTIzNCIsImlhdCI6MTY4NDA4NzkwNywiaXNzIjoiQlktS2luZyIsIm5iZiI6MTY4NDA4NzkwOCwic3ViIjoidGVzdCJ9.EsZ9WKs8sHI8To-YmUXw_kWRmsAf9wWM4ZiNTUdXVEo
2023/05/15 02:11:50 Invalid Token:token is expired by 1s
是的,会报错Token已经过期了1秒。如果是实际业务的话,我们这时候就要根据业务的不同,来考虑是对用户颁发新的令牌或者是直接拒绝访问。
后记
我们这次讨论了JWT的基础应用——Authorization(授权),并且学习了JWT的基本格式与算法的使用。
其实JWT是一种利用自己保护自己的方式,利用了自身的一部分的数字签名来保证自身不被篡改。