JWT
jwt的全称是Json web token,是一种身份验证和信息交换的工具。
授权:这是使用 JWT 最常见的场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。
信息交换:JSON Web 令牌是在各方之间安全传输信息的好方法。
JWT包含三个部分,标题,有效载荷,签名
。因此jwt的格式为Header.Payload.Signature
。如下是一个JWT生成的token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW94dSIsImV4cCI6MTY4MTUyNzcyNSwibmJmIjoxNjgxNTI3OTA1fQ.zOKqaUl2Z9BzuOIB9P0GmoHqAkHLp7O6yMy4lQ6FJ9U
标头通常由两部分组成:令牌类型 (JWT) 和使用的签名算法(例如 HMAC SHA256 或 RSA)。
{ "alg": "HS256", "typ": "JWT" }
alg 表示签名的算法,默认是 HMAC SHA256(写成 HS256)
typ 表示这个 token 的类型,类型为 “JWT”
这个JSON被Base64Url编码以形成JWT的第一部分即生成header
有效负载通常是用户信息和附加数据的声明
{ "sub": "1234567890", "name": "xiaoxu", "admin": true }
Base64Url 对有效负载进行编码以形成 JSON Web 令牌的第二部分。
签名部分主要对header和payload进行签名,防止数据被篡改。签名还需要一个密钥,该密钥仅存储在服务器上。签名算法为Header中指定的签名算法。
智威汤逊工作模式
在身份验证中,当用户使用其凭据成功登录时,将返回 JSON Web 令牌。每当用户想要访问受保护的路由或资源时,用户代理应该发送 JWT,通常在授权标头中使用承载模式。
Authorization: Bearer <token>
如果您通过 HTTP 标头发送 JWT 令牌,则应尽量防止它们变得太大。某些服务器不接受大于 8 KB 的头文件。
JWT认证的大致流程是:
- 登录认证返回token
- 每次访问维度时都携带令牌
- token过期或过期时重新登录认证
JWT在服务器端返回token的大致流程是:
- 实现Header的base64编码
- 实现 Payload 的 basse64 编码
- 使用签名和加密算法对Header和Payload进行加密,返回Signature,也就是token。
智威汤逊的优势
基于服务器的身份认证,基于session+cookie的会话保持技术,通过会话认证后,需要将用户的会话数据保存在内存中。随着认证用户数量的增加,内存开销也会增加;当多个CORS终端访问相同数据时,会出现禁止访问的情况;用户容易受到CSRF攻击;不利于集群部署,多集群会话内存共享实现复杂;不利于反向代理,因为代理服务器也需要会话共享。
基于Token的身份认证是无状态的。服务器直接从令牌中解析出有用的信息,不存储任何用户信息。 token文件较小,方便网络传输。
OAuth2是一个授权和认证框架,JWT是一个认证协议。
JWT规则生成Token
- 下载并引入jwt-go包并引入JWT
//下载依赖
go get -u github.com/dgrijalva/jwt-go
//引入依赖
import "github.com/dgrijalva/jwt-go"
- 构建Payload数据格式并配置有用信息
//Pyaload
type MyClaim struct {
Username string `json:"username"`
jwt.StandardClaims
}
jwt.StandardClaims
是jwt包下的结构体,定义了Payload的一些标准预定义信息,用户自定义其余信息需要通过继承该类实现,即通过结构体嵌套实现。
type StandardClaims struct {
Audience string `json:"aud,omitempty"`(受众,即接受 JWT 的一方)
ExpiresAt int64 `json:"exp,omitempty"`(所签发的JWT的过期时间)
Id string `json:"jti,omitempty"`(JWT的Id)
IssuedAt int64 `json:"iat,omitempty"`(签发时间)
Issuer string `json:"iss,omitempty"`(JWT的签发者)
NotBefore int64 `json:"nbf,omitempty"`(JWT的生效时间)
Subject string `json:"sub,omitempty"`(主题)
}
在自定义负载仅仅添加了Username
一项。
- 根据 JWT 的 Header、Payload 和加密算法生成 JSON Web Token
//定义签名
// secret签名
var mySignatureSecret []byte = []byte("!@#qwe")
//实例化负载payload
c := MyClaim{
Username: "xiaoxu",
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix() + 60, //JWT的生效时间
ExpiresAt: time.Now().Unix() - 120, //签发JWT的过期时间
},
}
//生成Header
//Header一般都是如下的json使用默认即可不用专门实例化,因此直接默认即可
/* { "alg": "HS256", "typ": "JWT" } */
//生成token
//返回未加密signature
sensitiveToken := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
//利用secret签名对token加密
signature, err := sensitiveToken.SignedString(mySignatureKey)
通过上述步骤得到signature
即最终的token。所有代码如下:
package main
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"time"
)
//Pyaload
type MyClaim struct {
Username string `json:"username"`
jwt.StandardClaims
}
// secret签名
var mySignatureKey []byte = []byte("!@#qwe")
func main() {
/* engine := gin.Default() engine.GET("/", func(context *gin.Context) { context.String(200, "Hello World") }) engine.Run("127.0.0.1:80") */
//实例化负载payload
c := MyClaim{
Username: "xiaoxu",
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix() + 60, //JWT的生效时间
ExpiresAt: time.Now().Unix() - 120, //签发JWT的过期时间
},
}
//Header就是默认也是不用专门实例化
/* { "alg": "HS256", "typ": "JWT" } */
//返回未加密signature
sensitiveToken := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
//利用secret签名对token加密
signature, err := sensitiveToken.SignedString(mySignatureKey)
if err != nil {
panic(err)
}
fmt.Println(signature)
}
生成代币
解密Token
//解密Token
token1, _ := jwt.ParseWithClaims(signature, &MyClaim{
}, func(token *jwt.Token) (interface{
}, error) {
return mySignatureKey, nil
})
fmt.Println(token1)
fmt.Println(token1.Claims)
fmt.Println(token1.Claims.(*MyClaim).Username)
解密后得到jwt.Token
对象,从该对象可以获取Header,Payload,Signature(claims)等信息。
jwt.ParseWithClaims()
方法用于解密Token,第一个参数为生成的token,第二个参数为自定义Payload的结构体类型,第三个参数为一个方法返回签名。
解密后得到的jwt.Token
对象一般包含以下几个变量。
成员变量 | 描述 |
---|---|
索赔 | jwt表头和负载加密生成的不完整Token,此时还未对签名加密,也就是回到之前的sensitiveToken := jwt.NewWithClaims(jwt.SigningMethodHS256, c) 此步骤。一般通过断言转化为自定义Payload结构体 |
SignedString(密钥接口{}) | 该方法是传入一个签名,并对签名进行加密,生成完整的token。 |
标头 | 返回页眉 |
方法 | 返回加密算法 |
有效的 | 验证token是否过期 |
另外需要注意的是,定义的负载必须是公共的即结构体名和成员名首字母都必须大写,不然无法将为加密的*Token
的Claims
类型断言成自定义的结构体类型。如下,如果将字段改为小写在同包下的测试包都无法生成和解析Token。
拦截器
Go的拦截器可以在方法执行之前和之后执行拦截器方法,并通过拦截器释放或拦截该方法。在Gin框架中,路由方法有多种,因此在执行路由后的逻辑时,可以传入任意数量的拦截器方法作为参数,实现对不逻辑逻辑的释放和控制。
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes
如上所示,gin.Default().GET()
方法中,第一个参数为路由地址,其后为若干拦截器方法,对于拦截器方法type HandlerFunc func(*Context)
其实就是实现了gin.context
的方法的重命名函数。
因此任意以gin.context
为参数的方法均为拦截器方法。
//定义权限认证中间件
func Certification() gin.HandlerFunc {
return func(context *gin.Context) {
context.Set("username", "xiaoxu")
if context.PostForm("username") != "xiaoxu" {
context.String(200, "用户名错误!")
context.Abort()
} else {
context.Next()
}
}
}
//gin的路由调用拦截器
import "github.com/gin-gonic/gin"
func main() {
engine := gin.Default()
engine.GET("/", Certification(), func(context *gin.Context) {
context.String(200, "Hello World")
})
engine.GET("/index", Certification(), func(context *gin.Context) {
context.String(200, "welcome index !")
})
engine.GET("/test", Certification(), func(context *gin.Context) {
context.String(200, "welcome test")
})
engine.Run("127.0.0.1:80")
}
当请求地址携带正确的用户名和密码时,会出现“用户名错误”字样!
当携带正确的用户名时,任意路径都可以访问
注意表单类型为
form-data
的text
类型,而x-www-form-urlencoded
时key-value类型。
上面的案例实现了拦截器对资源的拦截,然后实现数据查询模拟用户登录,使用SQL数据库,ORM框架是gorm。
//下载mysql驱动
go get -u gorm.io/driver/mysql
//gorm框架
go get -u gorm.io/gorm
//引入依赖
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
//构建如下的数据库表
mysql> select * from user;
+----+---------+----------+----------+
| id | user | password | role |
+----+---------+----------+----------+
| 1 | xiaoxu | 123 | admin |
| 2 | zhansan | 123 | personal |
| 3 | lisi | 123 | role |
+----+---------+----------+----------+
3 rows in set (0.01 sec)
//结构体映射数据库表
//定义数据表映射结构体
type User struct {
Id int `json:"id"`
User string `json:"user"`
Password string `json:"password"`
Role string `json:"role"`
}
//数据库驱动程序返回数据库操作对象
//数据库驱动
func ConnMysql() *gorm.DB {
datasource := "root:root@tcp(127.0.0.1:3306)/user?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(datasource), &gorm.Config{
})
if err != nil {
fmt.Println("error connect mysql", err)
}
return db
}
//主函数映入数据库对象(略)
import "xxx/../db"
//重构拦截器,添加查询数据库操作
func Certification() gin.HandlerFunc {
return func(context *gin.Context) {
//fmt.Println(context.PostForm("username"))
var user db.User
db.ConnMysql().Where("user = ?", context.PostForm("username")).Find(&user)
//fmt.Println("user----------", user)
//判断用户是否存在
if user.User == "" {
context.String(200, "用户名不存在!")
context.Abort()
//判断用户密码
} else if user.Password != context.PostForm("password") {
context.String(200, "用户名或密码错误!")
context.Abort()
} else {
//fmt.Println("password-----------", user.Password)
context.Next()
}
}
}
效果如下
Gin结合JWT实现认证
上一节已经实现了登录认证,遇到了新的问题。如果多个用户在同一时间间隔内登录系统,例如用户A登录自己的CSDN账号,发布了自己的文章。两个用户 B 和 C 在该时间间隔内登录。发表的文章如何识别为用户A?
这就需要用到会话技术。在之前的系统中,采用了Session技术和服务器端会话技术来在服务器端记录用户信息。每个用户都有自己独立的身份。
在session中存储用户信息,比如用户A的用户名,那么当用户A发布文章时,用户A的用户名和文章一起存储在数据库中,这样就可以区分该文章是属于用户A的但随着用户数量的增加,一段时间内访问用户的突然增加会增加服务的负载。
因此,JWT这种用于用户会话记录的新技术应运而生。在第一节中,JWT完美取代了SEESION,成为主流的身份验证和会话记录技术。
在拦截器章节实现了用户登录,本章节将介绍如何利用JWT实现会话记录。
//下载引入go-jwt依赖
//下载依赖
go get -u github.com/dgrijalva/jwt-go
//引入依赖
import "github.com/dgrijalva/jwt-go"
项目结构如下
//JWTConfig包的源码
import (
"github.com/dgrijalva/jwt-go"
"time"
)
// MyPayload 定义负载继承jwt的标准负载
type MyPayload struct {
Username string
jwt.StandardClaims
}
// 定义secret签名
var signatureKey []byte = []byte("!@#qwe")
// MakeUserToken 生成加密token
func MakeUserToken(user string) string {
//传入用户信息生成负载实例
payload := MyPayload{
Username: user,
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix() + 10,
ExpiresAt: time.Now().Unix() - 10,
},
}
//生成加密Signature
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, payload).SignedString(signatureKey)
if err != nil {
panic(err)
}
return token
}
// 解密token
func ParserUserToken(token string) string {
//解密后jwt.Token对象,从该对象可以获取Header,Payload,Signature(claims)等信息
unsafeToken, _ := jwt.ParseWithClaims(token, &MyPayload{
}, func(token *jwt.Token) (interface{
}, error) {
return signatureKey, nil
})
user := unsafeToken.Claims.(*MyPayload).Username
return user
}
引入之后应该可以使用JWTConfig包下的生成token和解析token的方法。
由于会话记录也是全局的,因此在全局拦截器中引入jwt,嵌入jwt认证,实现登录后的会话记录。
实现代码如下:
- 用于生成令牌和解析令牌的jwt代码
package jwtconfig
import (
"github.com/dgrijalva/jwt-go"
"time"
)
// MyPayload 定义负载继承jwt的标准负载
type MyPayload struct {
Username string
jwt.StandardClaims
}
// 定义secret签名
var signatureKey []byte = []byte("!@#qwe")
// MakeUserToken 生成加密token
func MakeUserToken(user string) string {
//传入用户信息生成负载实例
payload := MyPayload{
Username: user,
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix() + 10,
ExpiresAt: time.Now().Unix() - 10,
},
}
//生成加密Signature
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, payload).SignedString(signatureKey)
if err != nil {
panic(err)
}
return token
}
// 解密token
func ParserUserToken(token string) (*MyPayload, error) {
//解密后jwt.Token对象,从该对象可以获取Header,Payload,Signature(claims)等信息
unsafeToken, err1 := jwt.ParseWithClaims(token, &MyPayload{
}, func(token *jwt.Token) (interface{
}, error) {
return signatureKey, nil
})
//将负载转化为结构体
claims, ok := unsafeToken.Claims.(*MyPayload)
if ok && unsafeToken.Valid {
return claims, nil
} else {
return claims, err1
}
/* //验证token是否有效 if unsafeToken.Valid { //错误判断并返回错误信息 if err1 != nil { return "未携带有效token", unsafeToken.Claims } else if ve, ok := err1.(*jwt.ValidationError); ok { if ve.Errors&jwt.ValidationErrorMalformed != 0 { return "无效token", nil } else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 { return "token已过期", nil } else { return "", nil } } } */
}
- 路由jwt验证码
package main
import (
"fmt"
"github.com/gin-gonic/gin"
"go-jwt/db"
"go-jwt/jwtconfig"
)
func main() {
engine := gin.Default()
engine.POST("/login", Certification(), func(context *gin.Context) {
user := context.PostForm("user")
fmt.Println(jwtconfig.MakeUserToken(user))
context.String(200, "welcome register")
})
engine.GET("/", JWTHandler(), func(context *gin.Context) {
context.String(200, "Hello World")
})
engine.GET("/test1", JWTHandler(), func(context *gin.Context) {
context.String(200, "welcome index !")
})
engine.GET("/test2", JWTHandler(), func(context *gin.Context) {
context.String(200, "welcome test")
})
engine.Run("127.0.0.1:80")
}
//定义权限认证中间件
func Certification() gin.HandlerFunc {
return func(context *gin.Context) {
//fmt.Println(context.PostForm("username"))
var user db.User
db.ConnMysql().Where("user = ?", context.PostForm("username")).Find(&user)
//fmt.Println("user----------", user)
//判断用户是否存在
if user.User == "" {
context.String(500, "用户名不存在!")
context.Abort()
//判断用户密码
} else if user.Password != context.PostForm("password") {
context.String(500, "用户名或密码错误!")
context.Abort()
} else {
//fmt.Println("password-----------", user.Password)
context.Next()
}
}
}
//jwt拦截器
func JWTHandler() gin.HandlerFunc {
return func(context *gin.Context) {
//引入jwt实现登录后的会话记录,登录会话发生登录完成之后
//header获取token
token := context.Request.Header.Get("token")
if token == "" {
context.String(302, "请求未携带token无法访问!")
context.Abort()
}
//解析token
claims, err := jwtconfig.ParserUserToken(token)
if claims == nil || err != nil {
context.String(401, "未携带有效token或已过期")
context.Abort()
} else {
//context.Set("user", claims.Username)
context.Next()
}
}
}
依据JWT规则加密解密Token不在赘述,这里主要阐明JWT会话逻辑,首先定义了JWTHandler()
jwt拦截器,主要实现步骤是,从请求头获取Token(当然也可以放在http协议的其他位置),然后token判空,解密Token判断token是否过期是否有效,有效就放行。(这里只实现了简单的验证,官网又详细的验证)
需要注意的是,登录页面验证用户名身份,而其他路由则实现jwt会话验证。
案例演示如下:
如下是控制太的返回
在验证token之前,会返回302错误状态码。 http header携带token后,返回200状态码。