grpc-go源码剖析九十九之grpc+jwt认证方式介绍

已发表的技术专栏
0  grpc-go、protobuf、multus-cni 技术专栏 总入口

1  grpc-go 源码剖析与实战  文章目录

2  Protobuf介绍与实战 图文专栏  文章目录

3  multus-cni   文章目录(k8s多网络实现方案)

4  grpc、oauth2、openssl、双向认证、单向认证等专栏文章目录)

本文主要介绍jwt相关原理。

1、jwt基本介绍

1.1、jwt格式?

jwt类型的token由三部分组成:

每部分由.作为分隔符。

形式如下:
A.B.C
其中:
A:表示头部信息
B:表示载荷信息
C:表示签名信息

1.2、jwt头部信息 主要存储?

头部信息,主要由两部分组成:
参考形式:

{ "alg": "HS256", "typ": "JWT" }

每部分都是kv键值对;

alg,表示第3部分使用的签名算法

typ,表示类型,JWT

1.3、jwt载荷信息payload 主要用来存储什么?

主要包含两大部分内容:

  • 第一部分是,关于token的元数据信息;JWT的标准定义中,包含5个字段:(对应到代码里jwt.StandardClaims结构体)
    • iss:表示JWT的签发者
    • sub: 表示JWT所面向的用户
    • aud: 表示是由谁来接收JWT
    • exp(expires): 表示什么时候过期,这是一个Unix类型的时间戳
    • iat(issued at): 表示此token的签发时间,何时签发的
  • 第二部分是,业务程序使用到一些信息,如用户名,密码,等等

1.4、如何保证头部信息和载荷信息没有被修改过?

头部信息和载荷信息都是使用Base64进行编码的,窃取者可以获取这两部分内容,并进行读取。

因此,为了保证这两部分内容不被修改,需要使用签名算法进行签名;

1.4.1、如何签名呢?

  • 需要提供一个密钥,此密钥是由应用程序自己生成的,只有应用程序自己知道。
  • 需要将头部信息、载荷信息、密钥进行组合,使用头部信息指定的签名算法,生成签名。
  • 最终由头部信息.载荷信息.签名信息 组成jwt进行传递。

1.4.2、应用程序如何验证此token没有被修改过呢?

  • 使用头部信息、载荷信息、密钥进行组合,使用签名算法 重新生成签名,
  • 然后跟传递过来的签名,进行比较,相同说明没有被修改过。
  • 如果头部信息,载荷信息被修改过的话,签名信息校验失败。

2、jwt-grpc-go认证方式介绍

2.1、参考例子说明

本小节测试用例,是在https://github.com/ec2ainun/jwt-grpc-go的基础上,进行的更新。

jwt-grpc-go底层主要的依赖是github.com/dgrijalva/jwt-go

在引用jwt-go时,可能需要使用
go mod init github.com/dgrijalva/jwt-go
命令为其创建一个go.mod;

不然无法找到jwt-go

2.2、原版jwt-grpc-go中客户端,服务器端,auth服务之间的调用关系?

  • 客户端携带用户名和密码向auth服务发起获取jwt请求,
  • auth服务反馈一个jwt
  • 客户端将jwt作为认证,向服务器端发起rpc请求,如调用SayHello
  • 服务器端执行SayHello,反馈执行结果

2.3、新版jwt-grpc-go中客户端、服务器端、授权服务三者之间的时序图?

调用关系图,如下:
在这里插入图片描述

主要流程说明:

  • 客户端首先向服务器发起请求,获取授权码;
  • 服务器端反馈给客户端一个授权码
  • 客户端携带用户名和授权码,向auth服务发起请求,获取jwt
  • auth服务,根据用户名和授权码反馈一个jwt
  • 客户端将jwt作为认证,向服务器端发起rpc请求
  • 服务器端解析jwt,校验授权码是否有效;有效的话,执行rpc请求方法,将执行结果反馈给客户端。
新版跟旧版的区别是?
  • 客户端的密码是由服务器端自动生成的;也就是服务器端反馈的授权码;
  • 在服务器端需要校验接收到jwt,从载荷信息里校验授权码是否存在

2.4、创建授权接口消息

为了让客户端能向服务器端发起授权请求,需要新创建一个接口消息grant.proto。如下:

syntax = "proto3";

package grant2;
option go_package = "grantpb";

message ReqGreet {
 //客户端的标识,表明身份的
 string name = 1;
}

message RespGreet {
   string name = 1;
   // 针对客户端的标识的授权码
   string code = 2;
 }

service GrantCodeService {
  // 反馈给客户端授权码
  rpc GreetCode(ReqGreet) returns (RespGreet);
}

使用下面的命令,生成go语言的接口代码:

protoc --go_out=plugins=grpc:. grant.proto

2.5、客户端测试用例说明

客户端测试用例代码,在wt-grpc-go/client/main.go文件中;

接下来,主要是对代码做一个简单的解释

2.5.1、客户端向服务器端发起授权码请求?

相关代码如下:

	/**
		1、向服务器发送请求,获取授权码
	 */
	ca, err := grpc.Dial(
		dialAddrHello,
		grpc.WithTransportCredentials(tCa),
		grpc.WithBlock(),
	)

	grantCodeClient := grantpb.NewGrantCodeServiceClient(ca)

	codeResq , e := grantCodeClient.GreetCode(context.Background(), &grantpb.ReqGreet{Name: username})
	if e == nil {
		log.Infof("----grantCode:%v", codeResq.Code)
	}
	// 关闭连接
	defer ca.Close()

2.5.2、向auth服务发起jwt请求?

相关代码如下:

1/**
2.		2、向auth服务器发送请求,反馈一个token
3.	 */
4.	connAuth, err := grpc.Dial(
5.		dialAddrAuth,
6.		grpc.WithTransportCredentials(tCa),
7)
8if err != nil {
9.		log.Infof("Failed to Connect : %v", err)
10}
11defer connAuth.Close()

12.	clientAuth := authpb.NewAuthServiceClient(connAuth)
13.	req := &authpb.ReqLogin{Username: username, Password: codeResq.Code}
14.	res, err := clientAuth.Login(context.Background(), req)
15if err != nil {
16.		log.Warnf("Failed to Login : %v", err)
17}
18.	err = ioutil.WriteFile(jwtToken, []byte(res.Token), 0600)
19if err != nil {
20.		log.Warnf("Failed to Save Token : %v", err)
21}

主要流程说明:

  • 第13行:将授权码作为密码,进行传输
  • 第14行:向auth服务发起rpc请求,获取jwt码
  • 第18行:将获取到的jwt码,写入jwtToken文件中

2.5.3、向服务器端发送rpc请求

相关代码如下:

1.	jwtCreds, err := jwt.NewFromTokenFile(jwtToken)
2if err != nil {
3.		log.Warnf("%v", err)
4}

5.	connHello, err := grpc.Dial(
6.		dialAddrHello,
7.		grpc.WithTransportCredentials(tCa),
8.		grpc.WithPerRPCCredentials(jwtCreds),
9)
10if err != nil {
11.		log.Warnf("Failed to Connect : %v", err)
12}
13defer connHello.Close()
14.	clientHello := hellopb.NewHelloServiceClient(connHello)

15activateUnary(clientHello)

主要流程说明:

  • 第1行:从文件jwtToken中读取jwt码,
  • 第8行:调用grpc.WithPerRPCCredentials函数将授权码jwt作为认证内容。

最终,客户端携带jwt认证,向服务器端发起了rpc请求。

2.6、服务器端测试用例说明

服务器端的启动文件代码在jwt-grpc-go/server/svc/main.go文件中。

服务器端跟旧版相比,增加了授权码服务;

2.6.1、创建授权码服务

相关代码如下:

type GrantCode struct {
	grantpb.UnimplementedGrantCodeServiceServer
}
func (*GrantCode) GreetCode(ctx context.Context, greet *grantpb.ReqGreet) (*grantpb.RespGreet, error) {

	name := greet.Name

	ti := time.Now().UnixNano()

	grantCode := fmt.Sprintf("%s:%v", name, ti)

	// 在服务器端缓存,以备校验监测
	server.GreetCodeMap[name] = grantCode

	return &grantpb.RespGreet{Name:name, Code:grantCode}, nil
}

当客户端第一次向服务器端发起请求时,就会调用GreetCode方法:

GreetCode最主要的是,根据某种策略生成随机码,这里仅仅是获取当前的时间戳作为授权码反馈给客户端。

并且,将用户名和时间戳全局缓存起来,以备后面的jwt认证校验。

2.6.2、启动、注册授权码服务

在启动文件中,使用下面的语句,进行启动

	// 注册授权码服务
    grantpb.RegisterGrantCodeServiceServer(gs, &GrantCode{})

2.6.3、服务器端接收到客户端的rpc请求后,如请求SayHello后,如何对jwt进行校验?

假设客户端请求的是Greet服务(在jwt-grpc-go/server/svc/serve/serve.go文件中)

服务器端并没有使用拦截器,进行统一认证校验;

校验是在Greet方法内部进行的。

1func (s *Server) Greet(ctx context.Context, req *hellopb.ReqGreet) (*hellopb.RespGreet, error) {
2.	log.Infof("Received Greet RPC...")

3.	claims, err := getClaim(ctx, s)
4if err != nil {
5return nil, fmt.Errorf("Failed to Get Claims JWT : %v", err)
6}
7.	log.Infof("In Greet RPC, your Username is: %s", claims.User)

8.	cachePd :=  server.GreetCodeMap[claims.User]
9if !strings.EqualFold(cachePd, claims.PassWord){
10return nil, errors.New("授权码不符合要求")
11}

12return &hellopb.RespGreet{
13.		Message: "Hello " + req.Name + ", your Username is: " + claims.User + ", Welcome!",
14}, nil
15}

主要流程说明:

  • 第3行:对jwt进行解析,并进行验证
  • 第8-11行:从jwt中获取授权码,跟服务器端缓存里GreetCodeMap的授权码进行校验
2.6.3.1、如何具体校验jwt?

进入getClaim函数内部:

1func getClaim(ctx context.Context, s *Server) (*customClaims, error) {
2.	md, ok := metadata.FromIncomingContext(ctx)
3if !ok {
4return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required")
5}

6.	jwtToken, ok := md["authorization"]
7if !ok {
8return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required")
9}

10_, claims, err := validateJwtToken(jwtToken[0], s.jwtPubKey)
11if err != nil {
12return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required: %v", err)
13}

14return claims, nil
15}

前文分析已经知道,第2-9行,是从上下文中获取客户端传递过来的认证信息;

因此,我们主要关心10行:

1func validateJwtToken(token string, key *rsa.PublicKey) (*jwt.Token, *customClaims, error) {
2.	jwtToken, err := jwt.ParseWithClaims(token, &customClaims{}, func(t *jwt.Token) (interface{}, error) {
3// 用来校验Method方法的类型是不是jwt.SigningMethodRSA
4if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
5return nil, fmt.Errorf("Valid JWT Token Required")
6}

7return key, nil
8})

9if claims, ok := jwtToken.Claims.(*customClaims); ok && jwtToken.Valid {
10return jwtToken, claims, nil
11}
1213return nil, nil, err
14}

主要流程说明:

  • 第2-8行:解析jwt,并进行校验
  • 第9行:将jwtToken转换为customClaims类型; customClaims是自定义的;(在jwt-grpc-go/server/svc/serve/serve.go文件中)
type customClaims struct {
	User string `json:"usr"`
	PassWord string `json:"password"`
	jwt.StandardClaims
}

其中,PassWord是在旧版的基础上,新增的。

用来存储授权码的。

最终进入到jwt-go/parser.go文件中的ParseWithClaims方法里:

1func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {
2.	token, parts, err := p.ParseUnverified(tokenString, claims)
	//---省略异常处理逻辑代码
3if p.ValidMethods != nil {
4var signingMethodValid = false
5var alg = token.Method.Alg()
6for _, m := range p.ValidMethods {
7if m == alg {
8.				signingMethodValid = true
9break
10}
11}
//---省略异常处理逻辑代码
12if key, err = keyFunc(token); err != nil {
	//---省略异常处理逻辑代码

13if !p.SkipClaimsValidation {
14// 校验claims里的设置,如token是否过期等
15if err := token.Claims.Valid(); err != nil {
//---省略异常处理逻辑代码

16.	token.Signature = parts[2]
17if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil {
//---省略异常处理逻辑代码
18return token, vErr
19}

主要流程说明:

  • 第2行:p.ParseUnverified的tokenString, 就是接收到的jwt;claims就是customClaims; keyFunc,就是上面定义的函数;该方法主要做了以下事情:
    • 对tokenString按照分隔符.进行分割后,存储到parts切片里;一共三部分:头部信息,载荷信息,签名信息
    • 将头部信息,载荷信息,签名信息进行反序列化,或者解码等操作,存储到token里
  • 第3-11行:对jwt的头部部分指定的签名算法跟系统支持的签名算法进行比较,查看是否支持jwt中设置的签名算法。
  • 第12行:判断是否传递了keyFunc,如果参数传递了的话,就执行。也就是执行前文validateJwtToken方法中第4行,校验Method方法的类型是不是jwt.SigningMethodRSA;校验正确的话,返回的key就是公钥
  • 第13-15行:是对jwt中的载荷信息Claims的校验;具体校验是通过第38行中的Valid方法进行的;如校验了token是否过期、校验token是否已经使用过了等。(具体校验方法在jwt-go/claims.go文件中StandardClaims结构下的Valid方法)
  • 第16-17行:将头部信息,载荷信息,公钥、签名内容作为参数,传入校验方法Verify,校验jwt是否被修改过。既然这里是用公钥解密的话,auth服务签名时,肯定用的是私有。

至此,服务器端执行Greet方法时,如何校验jwt的内容介绍完了。

2.7、auth服务测试用例说明

auth服务的启动文件在jwt-grpc-go/server/auth/main.go文件

在auth服务中,主要是介绍一下,如何生成一个jwt。

2.7.1、如何生成一个jwt?

我们的分析入口,是jwt-grpc-go/server/auth/serve/serve.go文件中的Server结构体下的Login方法;

当客户端向auth服务发起请求时,就是调用这个方法。通过Login方法来获取jwt。

2.7.1.1、创建载荷信息?
1func (s *Server) Login(ctx context.Context, req *authpb.ReqLogin) (*authpb.RespLogin, error) {
2.	log.Infof("Received Login RPC...")

3//if req.Username != s.username || req.Password != s.password {
4//	return nil, grpc.Errorf(codes.PermissionDenied, "Invalid Username Password")
5//}

6.	now := time.Now()
7.	exp := now.Add(time.Hour * 24)
8// 模拟token过期时间
9//exp := now.Add(time.Millisecond * 100)
10.	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
11"aud": "grpc.hello.default",
12"iss": "grpc.auth.default",
13"exp": exp.Unix(),
14"iat": now.Unix(),
15"usr": req.Username,
16"password": req.Password,
17})

18.	tokenStr, err := token.SignedString(s.jwtPrivateKey)
19if err != nil {
20return nil, grpc.Errorf(codes.Internal, err.Error())
21}

22return &authpb.RespLogin{Token: tokenStr}, nil
23}

主要流程说明:

  • 第3-5行:我们是在jwt-grpc-go基础上对流程进行了改动。实际传输的密码值是服务器端发送的授权码;因此,需要将这里注释掉。
  • 第9行:取消注释后,可以给jwt添加过期时间;服务器端接收到jwt后,会对jwt的过期时间进行校验。
  • 第10-17行:指明签名算法为jwt.SigningMethodRS256;以及创建载荷信息,这里采用的是map结构的Claims; 其中,第16行,是在原版的基础上新增的,用来存储服务器端颁发的授权码;以便在服务器端校验此值是否是授权码。
  • 第18行:使用私钥进行签名
  • 第22行:将jwt反馈给客户端

其中,jwt.MapClaims 就是创建的载荷信息;

第11-14行是标准定义中的字段。

第15-16行是应用数据,以便服务器端进行校验。

2.7.1.2、创建头部信息?

点击第10行中的NewWithClaims函数,进入内部:

1func NewWithClaims(method SigningMethod, claims Claims) *Token {
2return &Token{
3.		Header: map[string]interface{}{
4"typ": "JWT",
5"alg": method.Alg(),
6},
7.		Claims: claims,
8.		Method: method,
9}
10}

主要流程说明:

  • 第3-6行:用来创建头部信息的;头部信息的结构是map[string]interface{}; 也就是说,头部信息还可以添加其他内容。
2.7.1.3、如何对头部信息和载荷信息进行签名?

返还到Login方法中,点击第18行中的SignedString:

1func (t *Token) SignedString(key interface{}) (string, error) {
2var sig, sstr string
3var err error
4if sstr, err = t.SigningString(); err != nil {
5return "", err
6}
78if sig, err = t.Method.Sign(sstr, key); err != nil {
9return "", err
10}
11return strings.Join([]string{sstr, sig}, "."), nil
12}

主要流程说明:

  • 第4行:调用SigningString方法,得到的是签名符串
    • 这是待签名的字符串,并不是签名本身。
    • 内部主要逻辑:对头部信息和载荷信息分别进行序列化,然后进行base64编码,使用.分隔符进行拼接后形成的sstr
  • 第8行:将签名字符串sstr、私钥作为参数,调用Sign方法,开始签名。
  • 第11行:将签名后的结果sig跟签名字符串sstr,通过分隔符.拼接后,反馈给客户端。

我们主要是关心,如何签名的;

在Login方法的第10行中指明了签名方法jwt.SigningMethodRS256,

而jwt.SigningMethodRS256 是如何初始化的呢?

在jwt-go/rsa.go文件中的init()里:

func init() {
	// RS256
	SigningMethodRS256 = &SigningMethodRSA{"RS256", crypto.SHA256}
	RegisterSigningMethod(SigningMethodRS256.Alg(), func() SigningMethod {
		return SigningMethodRS256})
}        

此处的init函数主要是用来初始化签名算法的;

SigningMethodRS256 是由SigningMethodRSA结构体进行的初始化;

因此,SignedString方法中的第8行中的Sign,其实就是调用SigningMethodRSA下的Sign方法,进入:(jwt-go/rsa.go文件)

1func (m *SigningMethodRSA) Sign(signingString string, key interface{}) (string, error) {
2var rsaKey *rsa.PrivateKey
3var ok bool

4// Validate type of key
5if rsaKey, ok = key.(*rsa.PrivateKey); !ok {
6return "", ErrInvalidKey
7}

8// Create the hasher
9if !m.Hash.Available() {
10return "", ErrHashUnavailable
11}

12.	hasher := m.Hash.New()
13.	hasher.Write([]byte(signingString))

14// Sign the string and return the encoded bytes
15if sigBytes, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil)); err == nil {
16return EncodeSegment(sigBytes), nil
17} else {
18return "", err
19}
20}

主要流程说明:

  • 第4-11行:主要是做私钥类型的校验,hash校验工作。
  • 第12-13行:将签名字符串写入hash里
  • 第15行:调用SignPKCS1v15,开始签名。其中参数包括:
    • rand.Reader 会自动生成一段32位随机数
    • rsaKey私钥
    • m.Hash hash
    • hasher.Sum 对签名字符串进行hash计算。

具体SignPKCS1v15算法,就不在详细看了。

也就是说,jwt中的签名,最终是由一段随机数,私钥,签名字符串组成的。

服务器端解析的时候,用的是公钥。

由于私钥不对外公布,用私钥签名,可以确保的确是某个服务发送的,并不是伪造的。

至此,jwt是如何产生的,如何签名的,都介绍完了。

2.8、总结:jwt的形成?

  • 创建头部信息,至少指定token类型,指定签名算法;头部信息结构是 map[string]interface{}
  • 创建载荷信息,包含两类信息,标准字段(iss,sub等),应用类数据(如用户名,密码);
  • 签名信息,由签名算法使用私钥,随机数,签名字符串,hash值作为参数值,进行计算,最终得到签名信息。
    • 签名字符串,是由头部信息,载荷信息分别序列化,base64编码后,通过.分隔符拼接而成的。
    • 最终,jwt=头部信息.载荷信息.签名字符串
    • 头部信息,载荷信息,是可用窃取的;

jwt最终保证,头部信息,载荷信息没有被修改过;

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码二哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值