跟我一起學Go系列:Go gRPC 安全認證方式-Token和自定義認證

rickiyang發表於2021-07-09

Go gRPC 系列:

跟我一起學Go系列:gRPC安全認證機制-SSL/TLS認證

跟我一起學 Go 系列:gRPC 攔截器使用

跟我一起學 Go 系列:gRPC 入門必備

接上一篇繼續講 gRPC 認證,本篇內容主要是 Token 認證和自定義認證方式的使用。

說 Token 認證就不得不提 Session。做 Web 端開發的同學應該都瞭解 Session 和 Token 機制。

Token 校驗

基於 Session 的身份校驗機制

Session 一般由服務端儲存,使用者通過使用者名稱和密碼登入之後服務端會在伺服器開闢一塊 Session 記憶體空間存入使用者資訊,同時伺服器會在 cookie 資訊中寫入一個 Session_id 值,用於標識這一塊記憶體空間。下次使用者再來請求的時候會由 cookie 中帶過來這個 Session_id,服務端拿著這個 Session_id 去尋找對應的 Session,如果能找到說明使用者已經登入過,不用重新走授權的邏輯。

使用 Session 存在問題在哪裡:

  1. 服務端儲存壓力過大,當使用者量大的時候,所有使用者都會在記憶體中儲存 Session 資訊,可想而知需要很大的記憶體空間。
  2. 分散式應用下 Session 共享問題會耗費更多的儲存。
  3. Session 機制是基於 cookie 的,cookie 如果被擷取使用者很容易受到 CSFR(跨站偽造請求攻擊)。
  4. 另外使用 cookie 的另一個弊端就是不支援跨域,當然對於跨域的處理現在已經不是什麼問題。
基於 Token 的身份校驗機制

再來說說 Token 機制。token 即令牌的意思,令牌的生成規則是我們自定義的,使用者第一次登入後服務端生成一個令牌返回給客戶端,以後客戶端在令牌過期內只需要帶上這個令牌以及生成令牌必要的引數,服務端通過生成規則能生成一樣的令牌即表示校驗通過。

原理很簡單但是帶來的效果卻是翻倍提升的:

  1. 採用生成規則校驗,服務端無需儲存 token 資料,沒有記憶體壓力,同樣服務端做負載均衡的時候也無需像 session 那樣需要考慮分散式儲存問題。
  2. 支援跨域,將 token 置於請求頭中即可。
  3. 對於移動端這種不支援 cookie 的應用場景來說 token 是更有效的驗證手段。

Token 形式多種多樣,其中,JSON Web Token 是一種比較受歡迎的 Token 規範,其實就是規範 token 該怎麼生成的方式。

JWT 中的 Token 分為 3 部分,Header、Payload 與 Signature,例如:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb3JhLWFwcC1zZXJ2ZXIiLCJleHAiOjE2MjUzMjgyOTYsImlzcyI6ImxvcmEtYXBwLXNlcnZlciIsIm5iZiI6MTYyNTMyNDY5Niwic3ViIjoidXNlciIsInVzZXJuYW1lIjoieGlhb21pbmcifQ.vAeStAhbRa15rhKsTET3_nphRaxr2yVMLd2fGXHnDwY

兩個.字元隔開三部分,即:

Header.Payload.Signature

含義上,Header表示 Token 相關的基本元資訊,如 Token 型別、加密方式(演算法)等,具體如下(alg是必填的,其餘都可選):

  • typ:Token type
  • cty:Content type
  • alg:Message authentication code algorithm

Payload表示 Token 攜帶的資料及其它 Token 元資訊,規範定義的標準欄位如下:

  • iss:Issuer,簽發方
  • sub:Subject,Token 資訊主題(Sub identifies the party that this JWT carries information about)
  • aud:Audience,接收方
  • exp:Expiration Time,過期時間
  • nbf:Not (valid) Before,生效時間
  • iat:Issued at,生成時間
  • jti:JWT ID,唯一標識

這些欄位都是可選的,Payload 只要是合法 JSON 即可。生成之後的三部分又做了一次加密處理:

Base64編碼的Header.Base64編碼的Payload.對前兩部分按指定演算法加密的結果

關於 JWT 規範的 Token 生成暫時就先說這麼多,我們接下來就看看在 gRPC 中如何應用 Token 機制。gRPC 本身不提供 Token 認證機制,而是通過外掛機制支援第三方認證,本示例使用了第三方 jwt 包:

github.com/dgrijalva/jwt-go

通過該包我們就不用自己去寫 jwt 規範下的這一套加密方式。

基於 JWT 規範的 Token 認證機制程式碼位於 Gitbub倉庫,大家自行檢視。

首先我們建立一個新的測試 API,token.proto:

syntax = "proto3";
option go_package = "/";

package test.grpcTest.tokenTls;


service TokenService {
  rpc Login (LoginRequest) returns (LoginResp) {}
  rpc SayHello(PingMessage) returns (PingMessage) {}
}

message LoginRequest{
  string username = 1;
  string password = 2;
}
message LoginResp{
  string status = 1;
  string token = 2;
}


message PingMessage {
  string greeting = 1;
}

一個方法是登入的時候獲取服務端 token,一個方法模擬拿到服務端 token 之後是否能用 token 通過校驗。

接下來我們定義 token 的生成方式以及校驗方式,這裡使用了 第三方 JWT 元件:

package __

import (
	"context"
	"fmt"
	"time"

	"github.com/dgrijalva/jwt-go"
	"google.golang.org/grpc/metadata"
)

func CreateToken(userName string) (tokenString string) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iss":      "lora-app-server",
		"aud":      "lora-app-server",
		"nbf":      time.Now().Unix(),
		"exp":      time.Now().Add(time.Hour).Unix(),
		"sub":      "user",
		"username": userName,
	})
	tokenString, err := token.SignedString([]byte("verysecret"))
	if err != nil {
		panic(err)
	}
	return tokenString
}

// AuthToken 自定義認證
type AuthToken struct {
	Token string
}

func (c AuthToken) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
	return map[string]string{
		"authorization": c.Token,
	}, nil
}

func (c AuthToken) RequireTransportSecurity() bool {
	return false
}

// Claims defines the struct containing the token claims.
type Claims struct {
	jwt.StandardClaims

	// Username defines the identity of the user.
	Username string `json:"username"`
}

// Step1. 從 context 的 metadata 中,取出 token

func getTokenFromContext(ctx context.Context) (string, error) {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return "", fmt.Errorf("ErrNoMetadataInContext")
	}
	// md 的型別是 type MD map[string][]string
	token, ok := md["authorization"]
	if !ok || len(token) == 0 {
		return "", fmt.Errorf("ErrNoAuthorizationInMetadata")
	}
	// 因此,token 是一個字串陣列,我們只用了 token[0]
	return token[0], nil
}

func CheckAuth(ctx context.Context) (username string) {
	tokenStr, err := getTokenFromContext(ctx)
	if err != nil {
		panic("get token from context error")
	}
	var clientClaims Claims
	token, err := jwt.ParseWithClaims(tokenStr, &clientClaims, func(token *jwt.Token) (interface{}, error) {
		if token.Header["alg"] != "HS256" {
			panic("ErrInvalidAlgorithm")
		}
		return []byte("verysecret"), nil
	})
	if err != nil {
		panic("jwt parse error")
	}

	if !token.Valid {
		panic("ErrInvalidToken")
	}

	return clientClaims.Username
}

CreateToken 方法呼叫了 jwt 生成 token 的規範,包括 token 的過期時間設定。

另一個重要的點是 AuthToken,它實現了 PerRPCCredentials 介面。gRPC 可以為每個方法的呼叫進行認證,從而對不同的使用者 token 進行不同的訪問許可權控制,首先需要實現 grpc.PerRPCCredentials 介面:

type PerRPCCredentials interface {

    GetRequestMetadata(ctx context.Context, uri ...string) (
        map[string]string,    error,
    )// 返回認證需要的資訊
     RequireTransportSecurity() bool // 是否要求底層使用安全連線
}

可以認為 PerRPCCredentials 介面就是 gRPC 提供的自定義認證方式的入口。注意到我在 GetRequestMetadata 方法中 set進去一個 authorization 欄位,用來儲存 token。

JWT 認證方式實現完畢,我們可以寫服務端和客戶端的程式碼:

服務端:

package __

import (
	"context"
	"fmt"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/grpclog"
	"google.golang.org/grpc/reflection"
	"net"
	"testing"
)

//攔截器 - 列印日誌
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler) (interface{}, error) {
	fmt.Printf("gRPC method: %s, %v", info.FullMethod, req)
	resp, err := handler(ctx, req)
	fmt.Printf("gRPC method: %s, %v", info.FullMethod, resp)
	return resp, err
}

type login struct {
}

func (login *login) Login(ctx context.Context, request *LoginRequest) (resp *LoginResp, err error) {
	if request.Username == "xiaoming" && request.Password == "123456" {
		token := CreateToken(request.Username)
		return &LoginResp{Status: "200", Token: token}, nil
	}
	return &LoginResp{Status: "401", Token: ""}, nil
}

func (login *login) SayHello(ctx context.Context, request *PingMessage) (resp *PingMessage, err error) {
	auth := CheckAuth(ctx)
	return &PingMessage{Greeting: auth}, nil
}

func TestGrpcServer(t *testing.T) {
	// 監聽本地的8972埠
	lis, err := net.Listen("tcp", ":8972")
	if err != nil {
		fmt.Printf("failed to listen: %v", err)
		return
	}

	// TLS認證
	creds, err := credentials.NewServerTLSFromFile("/Users/rickiyang/server.crt", "/Users/rickiyang/server.key")
	if err != nil {
		grpclog.Fatalf("Failed to generate credentials %v", err)
	}

	//開啟TLS認證 註冊攔截器
	s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(LoggingInterceptor)) // 建立gRPC伺服器
	RegisterTokenServiceServer(s, &login{})                                           // 在gRPC服務端註冊服務

	reflection.Register(s) //在給定的gRPC伺服器上註冊伺服器反射服務
	// Serve方法在list上接受傳入連線,為每個連線建立一個ServerTransport和server的goroutine。
	// 該goroutine讀取gRPC請求,然後呼叫已註冊的處理程式來響應它們。
	err = s.Serve(lis)
	if err != nil {
		fmt.Printf("failed to serve: %v", err)
		return
	}

}

服務端在 Login 方法中要為使用者生成 token,所以呼叫了 CreateToken 方法。同樣 SayHello 方法中就要去校驗客戶端提供的 token 是否有效。

繼續看客戶端邏輯:

package __

import (
	"fmt"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/grpclog"
	"testing"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
)


func TestGrpcClient(t *testing.T) {
	var err error
	var opts []grpc.DialOption

	// TLS連線
	creds, err := credentials.NewClientTLSFromFile("/Users/rickiyang/ca.crt", "www.rickiyang.com")
	if err != nil {
		grpclog.Fatalf("Failed to create TLS credentials %v", err)
	}
	opts = append(opts, grpc.WithTransportCredentials(creds))

	//連線服務端
	conn, err := grpc.Dial(":8972", opts...)
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()

	c := NewTokenServiceClient(conn)
	// 呼叫服務端的SayHello
	r, err := c.Login(context.Background(), &LoginRequest{Username: "xiaoming", Password: "123456"})
	if err != nil {
		fmt.Printf("could not greet: %v", err)
	}
	requestToken := new(AuthToken)
	requestToken.Token = r.Token

	//連線服務端
	conn, err = grpc.Dial(":8972", grpc.WithTransportCredentials(creds),
		grpc.WithPerRPCCredentials(requestToken))
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()
	c = NewTokenServiceClient(conn)
	hello, err := c.SayHello(context.Background(), &PingMessage{Greeting: "hahah"})
	if err != nil {
		fmt.Printf("could not greet: %v", err)
	}

	fmt.Printf("Greeting: %s, %s !\n", r.Token, hello)
}

客戶端校驗分為兩個部分,Login 方法呼叫之前我們是不能加 token 校驗的,因為此刻還沒有拿到 token。

呼叫 Login 獲取到 Token 之後將 token set 進 metadata,重新建立連線,此後的呼叫就使用 token 來進行校驗。大家可以執行示例實驗一下。

自定義校驗方式

上面 Token 校驗方式中說過,實現自定義校驗方式要實現 PerRPCCredentials 介面。Token 校驗本身就是一折特殊的自定義校驗方式,我們再來舉個示例:

先來看客戶端程式碼:

package normal

import (
	"fmt"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/grpclog"
	"testing"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
	pb "gorm-demo/models/pb"
)

const (
	// OpenTLS 是否開啟TLS認證
	OpenTLS = true
)

// customCredential 自定義認證
type customCredential struct{}

// GetRequestMetadata 實現自定義認證介面
func (c customCredential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
	return map[string]string{
		"appId":  "110",
		"appKey": "token",
	}, nil
}

// RequireTransportSecurity 自定義認證是否開啟TLS
func (c customCredential) RequireTransportSecurity() bool {
	return OpenTLS
}

func TestGrpcClient(t *testing.T) {
	var err error
	var opts []grpc.DialOption

	if OpenTLS {
		// TLS連線
		creds, err := credentials.NewClientTLSFromFile("/Users/yangyue2/ca.crt", "www.yangyue.com")
		if err != nil {
			grpclog.Fatalf("Failed to create TLS credentials %v", err)
		}
		opts = append(opts, grpc.WithTransportCredentials(creds))
	} else {
		opts = append(opts, grpc.WithInsecure())
	}
	// 使用自定義認證
	opts = append(opts, grpc.WithPerRPCCredentials(new(customCredential)))
	//連線服務端
	conn, err := grpc.Dial(":8972", opts...)
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()

	c := pb.NewGreeterClient(conn)
	// 呼叫服務端的SayHello
	r, err := c.SayHello(context.Background(), &pb.HelloRequest{Name: "CN"})
	if err != nil {
		fmt.Printf("could not greet: %v", err)
	}

	fmt.Printf("Greeting: %s !\n", r.Message)
}

自定義了一個 根據 appid 和 appkey 來校驗許可權的功能,服務端檢查存在即放過。服務端程式碼:

package normal

import (
	"context"
	"fmt"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/grpclog"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/reflection"
	pb "gorm-demo/models/pb"
	"net"
	"testing"
)

//攔截器 - 列印日誌
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler) (interface{}, error) {
	fmt.Printf("gRPC method: %s, %v", info.FullMethod, req)
	resp, err := handler(ctx, req)
	fmt.Printf("gRPC method: %s, %v", info.FullMethod, resp)
	return resp, err
}

// 定義helloService並實現約定的介面
type helloService struct{}


// SayHello 實現Hello服務介面
func (h helloService) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	// 解析metadata中的資訊並驗證
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, grpc.Errorf(codes.Unauthenticated, "無Token認證資訊")
	}
	var (
		appId  string
		appKey string
	)
	if val, ok := md["appid"]; ok {
		appId = val[0]
	}

	if val, ok := md["appkey"]; ok {
		appKey = val[0]
	}

	if appId != "110" || appKey != "token" {
		return nil, grpc.Errorf(codes.Unauthenticated, "Token認證資訊無效: appid=%s, appkey=%s", appId, appKey)
	}

	resp := new(pb.HelloReply)
	resp.Message = fmt.Sprintf("Hello %s.\nToken info: appid=%s,appkey=%s", in.Name, appId, appKey)

	return resp, nil
}

func TestGrpcServer(t *testing.T) {
	// 監聽本地的8972埠
	lis, err := net.Listen("tcp", ":8972")
	if err != nil {
		fmt.Printf("failed to listen: %v", err)
		return
	}

	// TLS認證
	creds, err := credentials.NewServerTLSFromFile("/Users/yangyue2/server.crt", "/Users/yangyue2/server.key")
	if err != nil {
		grpclog.Fatalf("Failed to generate credentials %v", err)
	}

	//開啟TLS認證 註冊攔截器
	s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(LoggingInterceptor)) // 建立gRPC伺服器
	pb.RegisterGreeterServer(s, &helloService{})                                      // 在gRPC服務端註冊服務

	reflection.Register(s) //在給定的gRPC伺服器上註冊伺服器反射服務
	// Serve方法在lis上接受傳入連線,為每個連線建立一個ServerTransport和server的goroutine。
	// 該goroutine讀取gRPC請求,然後呼叫已註冊的處理程式來響應它們。
	err = s.Serve(lis)
	if err != nil {
		fmt.Printf("failed to serve: %v", err)
		return
	}

}

服務端校驗是否存在對應的 appid 和 appkey即可。瞭解瞭如何使用之後寫起來就很簡單。

關於 gRPC 安全認證方式就到這裡,兩篇內容分別講了基於 TLS 的認證和自定義認證的邏輯。大家根據業務場景自行決定使用哪種校驗即可。

相關文章