Go 語(yǔ)言 gRPC 進(jìn)階

2023-03-22 15:03 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-05-grpc-hack.html


4.5 gRPC 進(jìn)階

作為一個(gè)基礎(chǔ)的 RPC 框架,安全和擴(kuò)展是經(jīng)常遇到的問(wèn)題。本節(jié)將簡(jiǎn)單介紹如何對(duì) gRPC 進(jìn)行安全認(rèn)證。然后介紹通過(guò) gRPC 的截取器特性,以及如何通過(guò)截取器優(yōu)雅地實(shí)現(xiàn) Token 認(rèn)證、調(diào)用跟蹤以及 Panic 捕獲等特性。最后介紹了 gRPC 服務(wù)如何和其他 Web 服務(wù)共存。

4.5.1 證書(shū)認(rèn)證

gRPC 建立在 HTTP/2 協(xié)議之上,對(duì) TLS 提供了很好的支持。我們前面章節(jié)中 gRPC 的服務(wù)都沒(méi)有提供證書(shū)支持,因此客戶端在連接服務(wù)器中通過(guò) grpc.WithInsecure() 選項(xiàng)跳過(guò)了對(duì)服務(wù)器證書(shū)的驗(yàn)證。沒(méi)有啟用證書(shū)的 gRPC 服務(wù)在和客戶端進(jìn)行的是明文通訊,信息面臨被任何第三方監(jiān)聽(tīng)的風(fēng)險(xiǎn)。為了保障 gRPC 通信不被第三方監(jiān)聽(tīng)篡改或偽造,我們可以對(duì)服務(wù)器啟動(dòng) TLS 加密特性。

可以用以下命令為服務(wù)器和客戶端分別生成私鑰和證書(shū):

$ openssl genrsa -out server.key 2048
$ openssl req -new -x509 -days 3650 \
    -subj "/C=GB/L=China/O=grpc-server/CN=server.grpc.io" \
    -key server.key -out server.crt

$ openssl genrsa -out client.key 2048
$ openssl req -new -x509 -days 3650 \
    -subj "/C=GB/L=China/O=grpc-client/CN=client.grpc.io" \
    -key client.key -out client.crt

以上命令將生成 server.key、server.crt、client.key 和 client.crt 四個(gè)文件。其中以. key 為后綴名的是私鑰文件,需要妥善保管。以. crt 為后綴名是證書(shū)文件,也可以簡(jiǎn)單理解為公鑰文件,并不需要秘密保存。在 subj 參數(shù)中的 /CN=server.grpc.io 表示服務(wù)器的名字為 server.grpc.io,在驗(yàn)證服務(wù)器的證書(shū)時(shí)需要用到該信息。

有了證書(shū)之后,我們就可以在啟動(dòng) gRPC 服務(wù)時(shí)傳入證書(shū)選項(xiàng)參數(shù):

func main() {
    creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
    if err != nil {
        log.Fatal(err)
    }

    server := grpc.NewServer(grpc.Creds(creds))

    ...
}

其中 credentials.NewServerTLSFromFile 函數(shù)是從文件為服務(wù)器構(gòu)造證書(shū)對(duì)象,然后通過(guò) grpc.Creds(creds) 函數(shù)將證書(shū)包裝為選項(xiàng)后作為參數(shù)傳入 grpc.NewServer 函數(shù)。

在客戶端基于服務(wù)器的證書(shū)和服務(wù)器名字就可以對(duì)服務(wù)器進(jìn)行驗(yàn)證:

func main() {
    creds, err := credentials.NewClientTLSFromFile(
        "server.crt", "server.grpc.io",
    )
    if err != nil {
        log.Fatal(err)
    }

    conn, err := grpc.Dial("localhost:5000",
        grpc.WithTransportCredentials(creds),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    ...
}

其中 credentials.NewClientTLSFromFile 是構(gòu)造客戶端用的證書(shū)對(duì)象,第一個(gè)參數(shù)是服務(wù)器的證書(shū)文件,第二個(gè)參數(shù)是簽發(fā)證書(shū)的服務(wù)器的名字。然后通過(guò) grpc.WithTransportCredentials(creds) 將證書(shū)對(duì)象轉(zhuǎn)為參數(shù)選項(xiàng)傳人 grpc.Dial 函數(shù)。

以上這種方式,需要提前將服務(wù)器的證書(shū)告知客戶端,這樣客戶端在連接服務(wù)器時(shí)才能進(jìn)行對(duì)服務(wù)器證書(shū)認(rèn)證。在復(fù)雜的網(wǎng)絡(luò)環(huán)境中,服務(wù)器證書(shū)的傳輸本身也是一個(gè)非常危險(xiǎn)的問(wèn)題。如果在中間某個(gè)環(huán)節(jié),服務(wù)器證書(shū)被監(jiān)聽(tīng)或替換那么對(duì)服務(wù)器的認(rèn)證也將不再可靠。

為了避免證書(shū)的傳遞過(guò)程中被篡改,可以通過(guò)一個(gè)安全可靠的根證書(shū)分別對(duì)服務(wù)器和客戶端的證書(shū)進(jìn)行簽名。這樣客戶端或服務(wù)器在收到對(duì)方的證書(shū)后可以通過(guò)根證書(shū)進(jìn)行驗(yàn)證證書(shū)的有效性。

根證書(shū)的生成方式和自簽名證書(shū)的生成方式類(lèi)似:

$ openssl genrsa -out ca.key 2048
$ openssl req -new -x509 -days 3650 \
    -subj "/C=GB/L=China/O=gobook/CN=github.com" \
    -key ca.key -out ca.crt

然后是重新對(duì)服務(wù)器端證書(shū)進(jìn)行簽名:

$ openssl req -new \
    -subj "/C=GB/L=China/O=server/CN=server.io" \
    -key server.key \
    -out server.csr
$ openssl x509 -req -sha256 \
    -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 \
    -in server.csr \
    -out server.crt

簽名的過(guò)程中引入了一個(gè)新的以. csr 為后綴名的文件,它表示證書(shū)簽名請(qǐng)求文件。在證書(shū)簽名完成之后可以刪除. csr 文件。

然后在客戶端就可以基于 CA 證書(shū)對(duì)服務(wù)器進(jìn)行證書(shū)驗(yàn)證:

func main() {
    certificate, err := tls.LoadX509KeyPair("client.crt", "client.key")
    if err != nil {
        log.Fatal(err)
    }

    certPool := x509.NewCertPool()
    ca, err := ioutil.ReadFile("ca.crt")
    if err != nil {
        log.Fatal(err)
    }
    if ok := certPool.AppendCertsFromPEM(ca); !ok {
        log.Fatal("failed to append ca certs")
    }

    creds := credentials.NewTLS(&tls.Config{
        Certificates:       []tls.Certificate{certificate},
        ServerName:         tlsServerName, // NOTE: this is required!
        RootCAs:            certPool,
    })

    conn, err := grpc.Dial(
        "localhost:5000", grpc.WithTransportCredentials(creds),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    ...
}

在新的客戶端代碼中,我們不再直接依賴(lài)服務(wù)器端證書(shū)文件。在 credentials.NewTLS 函數(shù)調(diào)用中,客戶端通過(guò)引入一個(gè) CA 根證書(shū)和服務(wù)器的名字來(lái)實(shí)現(xiàn)對(duì)服務(wù)器進(jìn)行驗(yàn)證??蛻舳嗽谶B接服務(wù)器時(shí)會(huì)首先請(qǐng)求服務(wù)器的證書(shū),然后使用 CA 根證書(shū)對(duì)收到的服務(wù)器端證書(shū)進(jìn)行驗(yàn)證。

如果客戶端的證書(shū)也采用 CA 根證書(shū)簽名的話,服務(wù)器端也可以對(duì)客戶端進(jìn)行證書(shū)認(rèn)證。我們用 CA 根證書(shū)對(duì)客戶端證書(shū)簽名:

$ openssl req -new \
    -subj "/C=GB/L=China/O=client/CN=client.io" \
    -key client.key \
    -out client.csr
$ openssl x509 -req -sha256 \
    -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 \
    -in client.csr \
    -out client.crt

因?yàn)橐肓?CA 根證書(shū)簽名,在啟動(dòng)服務(wù)器時(shí)同樣要配置根證書(shū):

func main() {
    certificate, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        log.Fatal(err)
    }

    certPool := x509.NewCertPool()
    ca, err := ioutil.ReadFile("ca.crt")
    if err != nil {
        log.Fatal(err)
    }
    if ok := certPool.AppendCertsFromPEM(ca); !ok {
        log.Fatal("failed to append certs")
    }

    creds := credentials.NewTLS(&tls.Config{
        Certificates: []tls.Certificate{certificate},
        ClientAuth:   tls.RequireAndVerifyClientCert, // NOTE: this is optional!
        ClientCAs:    certPool,
    })

    server := grpc.NewServer(grpc.Creds(creds))
    ...
}

服務(wù)器端同樣改用 credentials.NewTLS 函數(shù)生成證書(shū),通過(guò) ClientCAs 選擇 CA 根證書(shū),并通過(guò) ClientAuth 選項(xiàng)啟用對(duì)客戶端進(jìn)行驗(yàn)證。

到此我們就實(shí)現(xiàn)了一個(gè)服務(wù)器和客戶端進(jìn)行雙向證書(shū)驗(yàn)證的通信可靠的 gRPC 系統(tǒng)。

4.5.2 Token 認(rèn)證

前面講述的基于證書(shū)的認(rèn)證是針對(duì)每個(gè) gRPC 連接的認(rèn)證。gRPC 還為每個(gè) gRPC 方法調(diào)用提供了認(rèn)證支持,這樣就基于用戶 Token 對(duì)不同的方法訪問(wèn)進(jìn)行權(quán)限管理。

要實(shí)現(xiàn)對(duì)每個(gè) gRPC 方法進(jìn)行認(rèn)證,需要實(shí)現(xiàn) grpc.PerRPCCredentials 接口:

type PerRPCCredentials interface {
    // GetRequestMetadata gets the current request metadata, refreshing
    // tokens if required. This should be called by the transport layer on
    // each request, and the data should be populated in headers or other
    // context. If a status code is returned, it will be used as the status
    // for the RPC. uri is the URI of the entry point for the request.
    // When supported by the underlying implementation, ctx can be used for
    // timeout and cancellation.
    // TODO(zhaoq): Define the set of the qualified keys instead of leaving
    // it as an arbitrary string.
    GetRequestMetadata(ctx context.Context, uri ...string) (
        map[string]string,	error,
    )
    // RequireTransportSecurity indicates whether the credentials requires
    // transport security.
    RequireTransportSecurity() bool
}

在 GetRequestMetadata 方法中返回認(rèn)證需要的必要信息。RequireTransportSecurity 方法表示是否要求底層使用安全連接。在真實(shí)的環(huán)境中建議必須要求底層啟用安全的連接,否則認(rèn)證信息有泄露和被篡改的風(fēng)險(xiǎn)。

我們可以創(chuàng)建一個(gè) Authentication 類(lèi)型,用于實(shí)現(xiàn)用戶名和密碼的認(rèn)證:

type Authentication struct {
    User     string
    Password string
}

func (a *Authentication) GetRequestMetadata(context.Context, ...string) (
    map[string]string, error,
) {
    return map[string]string{"user":a.User, "password": a.Password}, nil
}
func (a *Authentication) RequireTransportSecurity() bool {
    return false
}

在 GetRequestMetadata 方法中,我們返回的認(rèn)證信息包裝 user 和 password 兩個(gè)信息。為了演示代碼簡(jiǎn)單,RequireTransportSecurity 方法表示不要求底層使用安全連接。

然后在每次請(qǐng)求 gRPC 服務(wù)時(shí)就可以將 Token 信息作為參數(shù)選項(xiàng)傳人:

func main() {
    auth := Authentication{
        User:    "gopher",
        Password: "password",
    }

    conn, err := grpc.Dial("localhost"+port, grpc.WithInsecure(), grpc.WithPerRPCCredentials(&auth))
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    ...
}

通過(guò) grpc.WithPerRPCCredentials 函數(shù)將 Authentication 對(duì)象轉(zhuǎn)為 grpc.Dial 參數(shù)。因?yàn)檫@里沒(méi)有啟用安全連接,需要傳人 grpc.WithInsecure() 表示忽略證書(shū)認(rèn)證。

然后在 gRPC 服務(wù)端的每個(gè)方法中通過(guò) Authentication 類(lèi)型的 Auth 方法進(jìn)行身份認(rèn)證:

type grpcServer struct {auth *Authentication}

func (p *grpcServer) SomeMethod(
    ctx context.Context, in *HelloRequest,
) (*HelloReply, error) {
    if err := p.auth.Auth(ctx); err != nil {
        return nil, err
    }

    return &HelloReply{Message: "Hello" + in.Name}, nil
}

func (a *Authentication) Auth(ctx context.Context) error {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return fmt.Errorf("missing credentials")
    }

    var appid string
    var appkey string

    if val, ok := md["user"]; ok { appid = val[0] }
    if val, ok := md["password"]; ok { appkey = val[0] }

    if appid != a.User || appkey != a.Password {
        return grpc.Errorf(codes.Unauthenticated, "invalid token")
    }

    return nil
}

詳細(xì)的認(rèn)證工作主要在 Authentication.Auth 方法中完成。首先通過(guò) metadata.FromIncomingContext 從 ctx 上下文中獲取元信息,然后取出相應(yīng)的認(rèn)證信息進(jìn)行認(rèn)證。如果認(rèn)證失敗,則返回一個(gè) codes.Unauthenticated 類(lèi)型的錯(cuò)誤。

4.5.3 截取器

gRPC 中的 grpc.UnaryInterceptor 和 grpc.StreamInterceptor 分別對(duì)普通方法和流方法提供了截取器的支持。我們這里簡(jiǎn)單介紹普通方法的截取器用法。

要實(shí)現(xiàn)普通方法的截取器,需要為 grpc.UnaryInterceptor 的參數(shù)實(shí)現(xiàn)一個(gè)函數(shù):

func filter(ctx context.Context,
    req interface{}, info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (resp interface{}, err error) {
    log.Println("filter:", info)
    return handler(ctx, req)
}

函數(shù)的 ctx 和 req 參數(shù)就是每個(gè)普通的 RPC 方法的前兩個(gè)參數(shù)。第三個(gè) info 參數(shù)表示當(dāng)前是對(duì)應(yīng)的那個(gè) gRPC 方法,第四個(gè) handler 參數(shù)對(duì)應(yīng)當(dāng)前的 gRPC 方法函數(shù)。上面的函數(shù)中首先是日志輸出 info 參數(shù),然后調(diào)用 handler 對(duì)應(yīng)的 gRPC 方法函數(shù)。

要使用 filter 截取器函數(shù),只需要在啟動(dòng) gRPC 服務(wù)時(shí)作為參數(shù)輸入即可:

server := grpc.NewServer(grpc.UnaryInterceptor(filter))

然后服務(wù)器在收到每個(gè) gRPC 方法調(diào)用之前,會(huì)首先輸出一行日志,然后再調(diào)用對(duì)方的方法。

如果截取器函數(shù)返回了錯(cuò)誤,那么該次 gRPC 方法調(diào)用將被視作失敗處理。因此,我們可以在截取器中對(duì)輸入的參數(shù)做一些簡(jiǎn)單的驗(yàn)證工作。同樣,也可以對(duì) handler 返回的結(jié)果做一些驗(yàn)證工作。截取器也非常適合前面對(duì) Token 認(rèn)證工作。

下面是截取器增加了對(duì) gRPC 方法異常的捕獲:

func filter(
    ctx context.Context, req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (resp interface{}, err error) {
    log.Println("filter:", info)

    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()

    return handler(ctx, req)
}

不過(guò) gRPC 框架中只能為每個(gè)服務(wù)設(shè)置一個(gè)截取器,因此所有的截取工作只能在一個(gè)函數(shù)中完成。開(kāi)源的 grpc-ecosystem 項(xiàng)目中的 go-grpc-middleware 包已經(jīng)基于 gRPC 對(duì)截取器實(shí)現(xiàn)了鏈?zhǔn)浇厝∑鞯闹С帧?/p>

以下是 go-grpc-middleware 包中鏈?zhǔn)浇厝∑鞯暮?jiǎn)單用法

import "github.com/grpc-ecosystem/go-grpc-middleware"

myServer := grpc.NewServer(
    grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
        filter1, filter2, ...
    )),
    grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
        filter1, filter2, ...
    )),
)

感興趣的同學(xué)可以參考 go-grpc-middleware 包的代碼。

4.5.4 和 Web 服務(wù)共存

gRPC 構(gòu)建在 HTTP/2 協(xié)議之上,因此我們可以將 gRPC 服務(wù)和普通的 Web 服務(wù)架設(shè)在同一個(gè)端口之上。

對(duì)于沒(méi)有啟動(dòng) TLS 協(xié)議的服務(wù)則需要對(duì) HTTP/2 特性做適當(dāng)?shù)恼{(diào)整:

func main() {
    mux := http.NewServeMux()

    h2Handler := h2c.NewHandler(mux, &http2.Server{})
    server = &http.Server{Addr: ":3999", Handler: h2Handler}
    server.ListenAndServe()
}

啟用普通的 https 服務(wù)器則非常簡(jiǎn)單:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(w, "hello")
    })

    http.ListenAndServeTLS(port, "server.crt", "server.key",
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            mux.ServeHTTP(w, r)
            return
        }),
    )
}

而單獨(dú)啟用帶證書(shū)的 gRPC 服務(wù)也是同樣的簡(jiǎn)單:

func main() {
    creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
    if err != nil {
        log.Fatal(err)
    }

    grpcServer := grpc.NewServer(grpc.Creds(creds))

    ...
}

因?yàn)?gRPC 服務(wù)已經(jīng)實(shí)現(xiàn)了 ServeHTTP 方法,可以直接作為 Web 路由處理對(duì)象。如果將 gRPC 和 Web 服務(wù)放在一起,會(huì)導(dǎo)致 gRPC 和 Web 路徑的沖突,在處理時(shí)我們需要區(qū)分兩類(lèi)服務(wù)。

我們可以通過(guò)以下方式生成同時(shí)支持 Web 和 gRPC 協(xié)議的路由處理函數(shù):

func main() {
    ...

    http.ListenAndServeTLS(port, "server.crt", "server.key",
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.ProtoMajor != 2 {
                mux.ServeHTTP(w, r)
                return
            }
            if strings.Contains(
                r.Header.Get("Content-Type"), "application/grpc",
            ) {
                grpcServer.ServeHTTP(w, r) // gRPC Server
                return
            }

            mux.ServeHTTP(w, r)
            return
        }),
    )
}

首先 gRPC 是建立在 HTTP/2 版本之上,如果 HTTP 不是 HTTP/2 協(xié)議則必然無(wú)法提供 gRPC 支持。同時(shí),每個(gè) gRPC 調(diào)用請(qǐng)求的 Content-Type 類(lèi)型會(huì)被標(biāo)注為 "application/grpc" 類(lèi)型。

這樣我們就可以在 gRPC 端口上同時(shí)提供 Web 服務(wù)了。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)