已发表的技术专栏
0 grpc-go、protobuf、multus-cni 技术专栏 总入口
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. )
8. if err != nil {
9. log.Infof("Failed to Connect : %v", err)
10. }
11. defer connAuth.Close()
12. clientAuth := authpb.NewAuthServiceClient(connAuth)
13. req := &authpb.ReqLogin{Username: username, Password: codeResq.Code}
14. res, err := clientAuth.Login(context.Background(), req)
15. if err != nil {
16. log.Warnf("Failed to Login : %v", err)
17. }
18. err = ioutil.WriteFile(jwtToken, []byte(res.Token), 0600)
19. if 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)
2. if err != nil {
3. log.Warnf("%v", err)
4. }
5. connHello, err := grpc.Dial(
6. dialAddrHello,
7. grpc.WithTransportCredentials(tCa),
8. grpc.WithPerRPCCredentials(jwtCreds),
9. )
10. if err != nil {
11. log.Warnf("Failed to Connect : %v", err)
12. }
13. defer connHello.Close()
14. clientHello := hellopb.NewHelloServiceClient(connHello)
15. activateUnary(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方法内部进行的。
1.func (s *Server) Greet(ctx context.Context, req *hellopb.ReqGreet) (*hellopb.RespGreet, error) {
2. log.Infof("Received Greet RPC...")
3. claims, err := getClaim(ctx, s)
4. if err != nil {
5. return 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]
9. if !strings.EqualFold(cachePd, claims.PassWord){
10. return nil, errors.New("授权码不符合要求")
11. }
12. return &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函数内部:
1.func getClaim(ctx context.Context, s *Server) (*customClaims, error) {
2. md, ok := metadata.FromIncomingContext(ctx)
3. if !ok {
4. return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required")
5. }
6. jwtToken, ok := md["authorization"]
7. if !ok {
8. return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required")
9. }
10. _, claims, err := validateJwtToken(jwtToken[0], s.jwtPubKey)
11. if err != nil {
12. return nil, grpc.Errorf(codes.Unauthenticated, "Valid JWT Token Required: %v", err)
13. }
14. return claims, nil
15.}
前文分析已经知道,第2-9行,是从上下文中获取客户端传递过来的认证信息;
因此,我们主要关心10行:
1.func 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
4. if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
5. return nil, fmt.Errorf("Valid JWT Token Required")
6. }
7. return key, nil
8. })
9. if claims, ok := jwtToken.Claims.(*customClaims); ok && jwtToken.Valid {
10. return jwtToken, claims, nil
11. }
12.
13. return 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方法里:
1.func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {
2. token, parts, err := p.ParseUnverified(tokenString, claims)
//---省略异常处理逻辑代码
3. if p.ValidMethods != nil {
4. var signingMethodValid = false
5. var alg = token.Method.Alg()
6. for _, m := range p.ValidMethods {
7. if m == alg {
8. signingMethodValid = true
9. break
10. }
11. }
//---省略异常处理逻辑代码
12. if key, err = keyFunc(token); err != nil {
//---省略异常处理逻辑代码
13. if !p.SkipClaimsValidation {
14. // 校验claims里的设置,如token是否过期等
15. if err := token.Claims.Valid(); err != nil {
//---省略异常处理逻辑代码
16. token.Signature = parts[2]
17. if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil {
//---省略异常处理逻辑代码
18. return 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、创建载荷信息? |
1.func (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)
19. if err != nil {
20. return nil, grpc.Errorf(codes.Internal, err.Error())
21. }
22. return &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函数,进入内部:
1.func NewWithClaims(method SigningMethod, claims Claims) *Token {
2. return &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:
1.func (t *Token) SignedString(key interface{}) (string, error) {
2. var sig, sstr string
3. var err error
4. if sstr, err = t.SigningString(); err != nil {
5. return "", err
6. }
7.
8. if sig, err = t.Method.Sign(sstr, key); err != nil {
9. return "", err
10. }
11. return 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文件)
1.func (m *SigningMethodRSA) Sign(signingString string, key interface{}) (string, error) {
2. var rsaKey *rsa.PrivateKey
3. var ok bool
4. // Validate type of key
5. if rsaKey, ok = key.(*rsa.PrivateKey); !ok {
6. return "", ErrInvalidKey
7. }
8. // Create the hasher
9. if !m.Hash.Available() {
10. return "", ErrHashUnavailable
11. }
12. hasher := m.Hash.New()
13. hasher.Write([]byte(signingString))
14. // Sign the string and return the encoded bytes
15. if sigBytes, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil)); err == nil {
16. return EncodeSegment(sigBytes), nil
17. } else {
18. return "", 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最终保证,头部信息,载荷信息没有被修改过;