JWT
jwt全称 Json web token,是一种认证和信息交流的工具。
授权:这是使用JWT最常见的场景。一旦用户登录,每个后续请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。
信息交流:JSON Web令牌是在各方之间安全传输信息的好方法。
jwt.io/
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 的签名,防止数据被窜改。签名还需要一个密钥secret,该密钥仅保存在服务器,签名的算法就是 Header 中指定的签名算法。
==JWT工作模式==
在身份验证中,当用户使用其凭据成功登录时,将返回JSON Web Token令牌。每当用户想要访问受保护的路由或资源时,用户代理应该发送JWT,通常在Authorization头中使用Bearer模式。
Authorization: Bearer
如果通过HTTP头发送JWT令牌,则应尽量防止它们变得太大。有些服务器不接受超过8 KB的头文件。
JWT认证的一般流程为:
JWT在服务端返回token的一般流程为:
==JWT优势==
基于服务器的身份认证,基于session+cookie的会话保持技术,session认证通过后需要将用户的session数据保存在内存中,随着认证用户的增加,内存开销会大;CORS多个终端访问同一数据时会出现禁止访问的情况;用户容易受到CSRF攻击;不利于集群部署,多个集群的session内存共享实现复杂;不利于反向代理,代理服务器也需要session共享。
基于Token的身份认证,基于Token的身份认证是无状态的,服务器直接从token中解析有用信息,不会存储任何用户信息,token文件较小利于网络传输。
OAuth2是一种授权和认证框架 ,JWT是一种认证协议。
JWT规则生成Token
//下载依赖
go get -u github.com/dgrijalva/jwt-go
//引入依赖
import "github.com/dgrijalva/jwt-go"
//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
一项。
//定义签名
// 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
//解密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
对象一般包含以下几个变量。
成员变量 | 描述 |
---|---|
Claims | jwt表头和负载加密生成的不完整Token,此时还未对签名加密,也就是回到之前的sensitiveToken := jwt.NewWithClaims(jwt.SigningMethodHS256, c) 此步骤。一般通过断言转化为自定义Payload结构体 |
SignedString(key inteface{}) | 该方法是传入签名对签名在加密生成完整的token |
Header | 返回表头 |
Method | 返回加密的算法 |
Valid | 验证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?&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认证嵌入并实现登陆后的会话记录。
实现代码如下:
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
}
}
}
*/
}
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状态码。