原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-05-grpc-hack.html
作為一個(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ù)共存。
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)。
前面講述的基于證書(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ò)誤。
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 包的代碼。
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ù)了。
更多建議: