1. JWT简介

1.1 什么是JWT

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

最常用的场景是登录授权。用户登录后,每个后续请求都将包含 JWT,从而允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小并且能够在不同的域中轻松使用。

其次还常用于信息交换。可以对 JWT 进行签名(例如,使用公钥/私钥对),所以可以确定发件人就是他们所说的那个人。

1.2 JWT和session的区别

先来看一下用JWT登录认证的过程:

  • ① 客户端使用账号密码登录

  • ② 服务端验证账号密码是否存在数据库,判断有没有该用户

  • ③ 若存在该用户,会在服务端通过JWT生成一个token,并把token返回给客户端

  • ④ 客户端收到token会把它存起来,之后每次向服务端请求都会把该token放到header

  • ⑤ 服务端收到请求后判断header有没有携带token,没有则返回验证失败,即该用户没有权限

再看一下用session登录认证的过程:

  • ① 客户端使用账号密码登录

  • ② 服务端验证账号密码是否存在数据库,判断有没有该用户

  • ③ 若存在该用户,会在服务端生成session id,并把session id返回给客户端

  • ④ 客户端收到session id后会保存到cookie中,以后向服务端的请求都会带上session id

  • ⑤ 服务端根据session id来判断该用户是否有权限和查看其他信息

从上面的流程可以看出jwt和session的认证过程大致相同,但是区别还是很大的:

  • Title 跨域问题 ,cookie无法跨域,而token没有使用cookie,所以jwt方式不存在跨域问题,跨域问题常见于小程序开发,所以移动端特别适合使用jwt技术
  • token无状态,token自身携带了用户的信息,可以通过加解密的方式得出,所以服务器不需要额外的空间来存储多余的信息,而且token本身只是一行字符串,占用空间极小;而session方式中,每个用户的登录信息都会保存到服务器的session中,随着用户的增多,服务器开销会明显增大
  • 分布式,由于session要保存到服务端,当处于分布式系统中时,无法使用该方法,就算可以通过中间件的方式解决,但这样无疑增加了复杂性,而jwt方式因为无状态,更适合于分布式系统

2. JWT结构

JWT由三部分组成,分别是headerpayloadsignature

形成的形式如:xxxxx.yyyyy.zzzzz

  • header由两部分组成:

    {
      "alg": "HS256",	//令牌使用的签名算法
      "typ": "JWT"		//令牌类型
    }
    
  • payload包含了主体信息,如iss(发行人)、 exp(到期时间)、 sub(主题)、 aud(受众)等,还可以添加自定义信息:

    {
      "sub": "1234567890",
      "name": "John Doe",
      "admin": true
    }
    
  • signature,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改,因此要指定一个秘钥SigningKey:

    HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), SigningKey)
    

3. Go+JWT

现在在基于go语言的beego框架中实现jwt鉴权,并在中间件中插入路由拦截

  • 配置文件:

    # Jwt,这是我随机生成的秘钥
    SigningKey = bAlc5pLZek78sOuVZm0p6L3OmY1qSIb8u3ql
    # Jwt token几天后到期
    ExpiresAt = 10
    
  • JWT逻辑实现:

    package adminService
    
    import (
    	"errors"
    	"fmt"
    	"github.com/beego/beego/v2/server/web"
    	"github.com/golang-jwt/jwt/v4"
    	"time"
    )
    
    type Jwt struct {
    	SigningKey []byte
    }
    
    func NewJwt() (*Jwt, error) {
    	SigningKey, err := web.AppConfig.String("SigningKey")
    	if err != nil {
    		return nil, errors.New("未从配置获取到Jwt的SigningKey")
    	}
    	return &Jwt{SigningKey: []byte(SigningKey)}, nil
    }
    
    type BaseClaims struct {
    	Email    string
    	Password string
    }
    
    type RegisteredClaims struct {
    	BaseClaims BaseClaims
    	jwt.RegisteredClaims
    }
    
    // 生成claims
    func (j *Jwt) CreateClaims(baseClaims BaseClaims) (RegisteredClaims, error) {
    	ExpiresAt, err := web.AppConfig.Int64("ExpiresAt")
    	if err != nil {
    		return RegisteredClaims{}, errors.New("未从配置获取到Jwt的过期时间")
    	}
    	return RegisteredClaims{
    		BaseClaims: baseClaims,
    		RegisteredClaims: jwt.RegisteredClaims{
    			Issuer:    baseClaims.Email,                           // 发行人
    			Subject:   "",                                         // 主题
    			Audience:  nil,                                        // 用户
    			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ExpiresAt) * 24 * time.Hour)), // 到期时间
    			NotBefore: jwt.NewNumericDate(time.Now()),             // 在此之前不可用
    			IssuedAt:  jwt.NewNumericDate(time.Now()),             // 发布时间
    			ID:        "",                                         // jwt的id
    		},
    	}, nil
    }
    
    // 检查token
    func CheckToken(token string) (RegisteredClaims, error) {
    	parse, err := jwt.ParseWithClaims(token, &RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
    		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    			return nil, errors.New(fmt.Sprintf("签名方式有误: [%v]", token.Header["alg"]))
    		}
    		SigningKey, _ := web.AppConfig.String("SigningKey")
    		return []byte(SigningKey), nil
    	})
    	if parse == nil {
    		return RegisteredClaims{}, errors.New("token为空/token有误")
    	}
    	if parse.Valid {
    		if claims, ok := parse.Claims.(*RegisteredClaims); ok {
    			return *claims, nil
    		} else {
    			return RegisteredClaims{}, errors.New("token解析不正确")
    		}
    	} else if errors.Is(err, jwt.ErrTokenMalformed) {
    		return RegisteredClaims{}, errors.New("令牌格式不正确")
    	} else if errors.Is(err, jwt.ErrTokenExpired) {
    		return RegisteredClaims{}, errors.New("令牌已过期")
    	} else if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
    		return RegisteredClaims{}, errors.New("令牌签名无效")
    	} else if errors.Is(err, jwt.ErrTokenNotValidYet) {
    		return RegisteredClaims{}, errors.New("令牌尚未生效")
    	} else {
    		return RegisteredClaims{}, err
    	}
    }
    
  • 登录逻辑:

    package adminService
    
    import (
    	"errors"
    	"fmt"
    	"github.com/golang-jwt/jwt/v4"
    	"github.com/jinzhu/gorm"
    	"mobile-mes-api/dto/admin"
    	"mobile-mes-api/models"
    	"mobile-mes-api/util/cryptoUtil"
    	"mobile-mes-api/util/log"
    )
    
    func LoginService(req admin.LoginReq) (string, error) {
    	if req.Email == "" || req.Password == "" {
    		return "", errors.New("未输入用户名或密码")
    	}
    
    	user, err := models.GetLoginUser(req.Email)
    	if err != nil {
    		if err == gorm.ErrRecordNotFound {
    			return "", errors.New("未找到该用户")
    		}
    		return "", err
    	}
    	if cryptoUtil.Encrypt(req.Password) != user.Password {
    		return "", errors.New("密码错误")
    	}
    
    	// 登录成功,开始生成jwt的token
    	token, err := generateToken(req)
    	if err != nil {
    		return "", err
    	}
    
    	return token, nil
    }
    
    func generateToken(req admin.LoginReq) (string, error) {
    	j, err := NewJwt()
    	if err != nil {
    		return "", err
    	}
    	claims, err := j.CreateClaims(BaseClaims{
    		Email:    req.Email,
    		Password: req.Password,
    	})
    	if err != nil {
    		return "", err
    	}
    	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    	tokenStr, err := token.SignedString(j.SigningKey)
    	if err != nil {
    		log.Error(fmt.Sprintf("生成jwt的token失败,err: [%v]", err))
    		return "", err
    	}
    	return tokenStr, nil
    }
    
  • Beego插入中间件做路由鉴权:

    func init() {
    	ns := beego.NewNamespace("/v1",
    		// ......这里写个人的路由
    	)
    	beego.AddNamespace(ns)
    
    	// jwt token鉴权
    	beego.InsertFilter("/*", beego.BeforeExec, controllers.FilterUser)
    }
    
  • 过滤逻辑:

    // 路由鉴权白名单
    var permissionUrl = []string{
    	"/v1/test/test",
    }
    
    func FilterUser(ctx *context.Context) {
    	perMap := make(map[string]bool, len(permissionUrl))
    	for _, v := range permissionUrl {
    		perMap[v] = true
    	}
    	url := ctx.Request.RequestURI
    	if perMap[url] == true { // 不对白名单接口鉴权
    		return
    	}
    
    	// 执行Jwt的token鉴权
    	tokenStr := ctx.Input.Header(HTTP_HEADER_KEY_TOKEN)
    	_, err := adminService.CheckToken(tokenStr)
    	if err != nil {
    		ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized)
    		resp := dto.BaseResponse{
    			ResCode: dto.RESPONSE_STATUS_FAIL,
    			Message: err.Error(),
    		}
    		res, _ := json.Marshal(resp)
    		ctx.ResponseWriter.Write(res)
    		return
    	} else {
    		return
    	}
    }
    

效果如下:

4. uniapp+JWT

  • 登录后保存token到缓存:

    export default {
    		onLoad() {
    			let data = {
    				email: "test.test@test.com",
    				password: "test.test@test.com"
    			}
    			http.login(data).then((response) => {
    				let token = response.data.token
    				uni.setStorageSync('token', token)
    			}).catch((err)=>{
    			})
    		},
    }
    
  • 在http请求处封装token识别:

    const baseUrl = "http://127.0.0.1:8091/v1";
    
    // 白名单,无需Jwt鉴权
    const perUrl = new Map([
    	["/test/test", true],	
    ])
    
    export default (url, method, data, headers) => {
    	let token = uni.getStorageSync('token')
    	if (!token && !perUrl.get(url)) {
    		uni.showModal({
    			title: "",
    			content: "没有权限,请重新登陆",
    			showCancel: false
    		});
    		// todo: 重定向跳转到登录页面
    		uni.redirectTo({
    			url: "/"
    		})
    		return
    	}
    	return new Promise((resolve, reject) => {
    		uni.request({
    			url: baseUrl + url,
    			method: method,
    			data: data,
    			header: headers,
    			timeout: 10000,
    			success: (res) => {
    				if (res) {
    					console.log("response success:", res)
    					resolve(res) // 把数据返回给上层调用
    				}
    			},
    			fail: (err) => {
    				uni.showModal({
    					title: "请求失败",
    					content: "接口请求异常: " + err.errMsg,
    					showCancel: false
    				});
    				console.log("response fail:", err.errMsg)
    			},
    			complete: () => {
    				return
    			}
    		});
    	})
    }
    

在没有token的情况下,得到的效果如下:

5. 参考链接

https://www.cnblogs.com/xiaofua/p/16179330.html

https://baobao555.tech/archives/40#1.header

https://jwt.io/#debugger-io