寫給 go 開發者的 gRPC 教程 - 用戶認證
gRPC 的用戶認證
用戶認證,簡單來說就是驗證請求的用戶身份,避免破壞者僞造身份獲取他人數據隱私。比如當訪問微博網站時,微博服務端通過用戶認證來識別你的身份,並返回正確的主頁數據
用戶認證有很多方式。例如 HTTP 中使用的 cookie、session、oauth、jwt 等等。gRPC 框架並不限制用戶認證的方式,而是提供了開放的能力來支持各種各樣的用戶認證
gRPC 的用戶認證可以用兩句話總結
-
gRPC 客戶端提供在每一次調用注入用戶憑證的能力
-
gRPC 服務端使用攔截器來驗證每一個客戶端的請求
要實現在每一次調用注入用戶憑證的能力,我們需要實現credentials.PerRPCCredentials
接口,並且在客戶端創建鏈接的時候指定grpc.WithPerRPCCredentials(credentials.PerRPCCredentials)
type PerRPCCredentials interface {
// GetRequestMetadata 獲取當前請求的metadata
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
// RequireTransportSecurity 是否使用安全的傳輸協議
RequireTransportSecurity() bool
}
要驗證每一個客戶端的請求,我們需要用到前幾期提到的攔截器:寫給 go 開發者的 gRPC 教程 - 攔截器
下面我們來介紹在 gRPC 中使用比較常見的兩種認證方式:Basic Authentication
和JWT
Basic Authentication
Basic Authentication
是最簡單的認證方式
使用
Basic Authentication
時,客戶端攜帶一個Authorization
header 頭,值爲Basic
+空格
+base64編碼的用戶名:密碼
例如一個用戶名和密碼都是 admin,那麼 header 頭如下
Authorization: Basic YWRtaW46YWRtaW4=
通常並不推薦使用Basic Authentication
,gRPC 也沒有內置組件支持,但在 gRPC 中很容易做到。
客戶端代碼
我們定義一個結構體BasicAuthentication
並讓它實現credentials.PerRPCCredentials
接口,就可以把用戶憑證添加到客戶端的上下文中
type PerRPCCredentials interface {
// GetRequestMetadata 獲取當前請求的metadata
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
// RequireTransportSecurity 是否使用安全的傳輸協議
RequireTransportSecurity() bool
}
var _ credentials.PerRPCCredentials = BasicAuthentication{}
type BasicAuthentication struct {
password string
username string
}
func (b BasicAuthentication) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
auth := b.username + ":" + b.password
enc := base64.StdEncoding.EncodeToString([]byte(auth))
return map[string]string{
"authorization": "Basic " + enc,
}, nil
}
func (b BasicAuthentication) RequireTransportSecurity() bool {
return true
}
在創建連接時使用grpc.WithPerRPCCredentials(auth)
設置每一次請求的用戶憑證
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
auth := BasicAuthentication{
username: "admin",
password: "admin",
}
creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt", "www.example.com")
if err != nil {
panic(err)
}
conn, err := grpc.Dial("localhost:8009",
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(auth))
if err != nil {
panic(err)
}
defer conn.Close()
client := pb.NewOrderManagementClient(conn)
// Get Order
retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})
if err != nil {
panic(err)
}
log.Print("GetOrder Response -> : ", retrievedOrder)
}
⚠️ 注意
type PerRPCCredentials interface {
// GetRequestMetadata 獲取當前請求的metadata
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
// RequireTransportSecurity 是否使用安全的傳輸協議
RequireTransportSecurity() bool
}
RequireTransportSecurity()
代表是否使用安全的傳輸協議。如果設置了true
,則必須通過grpc.WithTransportCredentials()
設置合理的傳輸層加密方式,否則會導致建立連接時失敗
gRPC 官方庫裏有個insecure.NewCredentials()
,這段函數含義爲禁用傳輸層安全協議,因此grpc.WithTransportCredentials(insecure.NewCredentials())
是無效的,依舊會導致建立連接時失敗
auth := BasicAuthentication{
username: "admin",
password: "admin",
}
conn, err := grpc.Dial("localhost:8009",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(auth))
if err != nil {
panic(err)
}
$ go run basic-authentication/client/main.go
panic: grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)
服務端代碼
服務端使用攔截器來驗證請求是否合法
對於不合法的 token 返回codes.Unauthenticated
如果 token 合法,在ensureValidBasicCredentials
中調用handler
來繼續請求的處理
var (
errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
errInvalidToken = status.Errorf(codes.Unauthenticated, "invalid credentials")
)
func ensureValidBasicCredentials(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMissingMetadata
}
authorization := md["authorization"]
if len(authorization) < 1 {
return nil, errInvalidToken
}
token := strings.TrimPrefix(authorization[0], "Basic ")
if token != base64.StdEncoding.EncodeToString([]byte("admin:admin")) {
return nil, errInvalidToken
}
return handler(ctx, req)
}
func main() {
l, err := net.Listen("tcp", ":8009")
if err != nil {
panic(err)
}
creds, err := credentials.NewServerTLSFromFile("./x509/server.crt", "./x509/server.key")
s := grpc.NewServer(
grpc.UnaryInterceptor(ensureValidBasicCredentials),
grpc.Creds(creds),
)
pb.RegisterOrderManagementServer(s, &server{})
if err := s.Serve(l); err != nil {
panic(err)
}
}
JWT
關於 jwt 的介紹參考:JSON Web Token 入門教程
這裏簡述如下
-
客戶端進行登陸操作
-
服務器認證以後,生成一個 JWT 字符串,發回給用戶
JWT 中間用點(
.
)分隔成三個部分Header.Payload.Signature
-
客戶端收到服務器返回的 JWT,可以儲存在 Cookie 裏面,也可以儲存在 localStorage
-
此後,客戶端每次與服務器通信,都要帶上這個 JWT。你可以把它放在 Cookie 裏面自動發送,但是這樣不能跨域,所以更好的做法是放在 HTTP 請求的頭信息
Authorization
字段裏面Authorization: Bearer <token>
-
服務器會校驗簽名,對這個對象認定用戶身份
gRPC 提供的 jwt
整個 gRPC 或者說谷歌 golang 生態提供的 jwt 包很混亂
如果不想了解細節直接看結論:不能用golang.org/x/oauth2
實現我們常規意義上的 jwt 功能,雖然這個包裏有各式各樣的含有 jwt 字樣的函數
下面是詳細梳理
golang.org/x/oauth2 包含常見平臺如谷歌,亞馬遜等 oauth2 的認證功能
⚠️ golang.org/x/oauth2/google
這個包專門提供谷歌 api 的認證功能,它使用了谷歌的serviceaccount
的 json 文件作爲用戶憑證
但這個包不是 oauth2 的標準流程,而是創建 jwt 作爲 oauth2 的 access token。它作爲一種優化的認證方式被部分谷歌的服務支持。這部分說明可以參考:https://developers.google.com/identity/protocols/oauth2/service-account#jwt-auth
⚠️ golang.org/x/oauth2/jwt
這是一個標準的 jwt oauth2.0 的流程,它的交互可以參考谷歌文檔,它能支持所有 two-legged oauth2.0 的服務,不僅限於谷歌的服務
但它不是我們常規認爲的 jwt,而是使用 jwt 作爲 oauth2.0 的一環
⚠️ google.golang.org/grpc/credentials/oauth
它使用 golang.org/x/oauth2/google
來實現 gRPC 的credentials.PerRPCCredentials
,也就意味着它主要用作訪問谷歌服務的認證
import (
"context"
"log"
"time"
pb "github.com/liangwt/note/grpc/authentication/ecommerce"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/oauth"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
jwtAuth, err := oauth.NewJWTAccessFromFile("./x509/winged-axon-372312-154a8b3aa89d.json")
if err != nil {
panic(err)
}
creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt", "www.example.com")
if err != nil {
panic(err)
}
conn, err := grpc.Dial("localhost:8009",
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(jwtAuth))
if err != nil {
panic(err)
}
// ...
}
三個包的關係如下
自定義的 jwt
上文說了 golang.org/x/oauth2 不能用
自定義實現 jwt 可以使用 github.com/golang-jwt/jwt/v4 庫
ps 在發佈這篇文章時,又去看了下這個庫,已經 v5 了......
客戶端代碼
客戶端的 token 應該是由服務端返回的,而不是客戶端自己生成的,這裏只是爲了方便演示
主要邏輯是聲明 claims 然後使用 secret key 進行簽名
package main
import (
"context"
"log"
"time"
"github.com/golang-jwt/jwt/v4"
pb "github.com/liangwt/note/grpc/authentication/ecommerce"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/protobuf/types/known/wrapperspb"
)
var _ credentials.PerRPCCredentials = JwtAuthentication{}
type JwtAuthentication struct {
Key []byte
}
func (a JwtAuthentication) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
// Create a new token object, specifying signing method and the claims
// you would like it to contain.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ID: "example",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
})
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(a.Key)
if err != nil {
return nil, err
}
return map[string]string{
"authorization": "Bearer " + tokenString,
}, nil
}
func (b JwtAuthentication) RequireTransportSecurity() bool {
return true
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt", "www.example.com")
if err != nil {
panic(err)
}
jwtAuth := JwtAuthentication{[]byte("154a8b3aa89d3d4c49826f6dbbbe5542b5a9fbbb")}
conn, err := grpc.Dial("localhost:8009",
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(jwtAuth))
if err != nil {
panic(err)
}
defer conn.Close()
client := pb.NewOrderManagementClient(conn)
// Get Order
retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})
if err != nil {
panic(err)
}
log.Printf("GetOrder Response -> : %+v\n", retrievedOrder)
}
服務端代碼
服務端代碼使用攔截器,來對 jwt 進行驗證
package main
import (
"context"
"fmt"
"net"
"strings"
"github.com/golang-jwt/jwt/v4"
pb "github.com/liangwt/note/grpc/authentication/ecommerce"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
var (
errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
)
func ensureValidBasicCredentials(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMissingMetadata
}
tokenString := strings.TrimPrefix(md["authorization"][0], "Bearer ")
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte("154a8b3aa89d3d4c49826f6dbbbe5542b5a9fbbb"), nil
})
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok || !token.Valid {
return nil, status.Errorf(codes.Unauthenticated, err.Error())
}
fmt.Println(claims.ID)
return handler(ctx, req)
}
func main() {
l, err := net.Listen("tcp", ":8009")
if err != nil {
panic(err)
}
creds, err := credentials.NewServerTLSFromFile("./x509/server.crt", "./x509/server.key")
s := grpc.NewServer(
grpc.UnaryInterceptor(ensureValidBasicCredentials),
grpc.Creds(creds),
)
pb.RegisterOrderManagementServer(s, &server{})
if err := s.Serve(l); err != nil {
panic(err)
}
}
總結
🌲 gRPC 可以支持各種各樣的用戶認證
gRPC 客戶端提供在每一次調用注入用戶憑證的能力
我們需要實現credentials.PerRPCCredentials
接口,並且在客戶端創建鏈接的時候指定grpc.WithPerRPCCredentials(credentials.PerRPCCredentials)
type PerRPCCredentials interface {
// GetRequestMetadata 獲取當前請求的metadata
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
// RequireTransportSecurity 是否使用安全的傳輸協議
RequireTransportSecurity() bool
}
gRPC 服務端使用攔截器來驗證每一個客戶端的請求
type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)
s := grpc.NewServer(
grpc.UnaryInterceptor(UnaryServerInterceptor),
)
利用以上兩個特性,gRPC 可以支持各種各樣的用戶認證
🌲 傳輸層加密
實現grpc.WithPerRPCCredentials()
時RequireTransportSecurity
如果設置了true
,則必須設置合理的傳輸層加密方式(grpc.WithTransportCredentials()
),否則會導致建立連接時失敗
🌲 gRPC 中的 jwt 認證
自定義實現 jwt 推薦使用 github.com/golang-jwt/jwt/v4 庫,golang.org/x/oauth2/jwt 不是一個我們常規理解的 jwt 庫
參考資料
[1]
JSON Web Token 入門教程: https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
[2]
golang.org/x/oauth2: https://pkg.go.dev/golang.org/x/oauth2#section-directories
[3]
golang.org/x/oauth2/google: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/google
[4]
golang.org/x/oauth2/jwt: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/jwt
[5]
google.golang.org/grpc/credentials/oauth: https://pkg.go.dev/google.golang.org/grpc@v1.51.0/credentials/oauth
[6]
github.com/golang-jwt/jwt/v4: https://github.com/golang-jwt/jwt
[7]
github.com/golang-jwt/jwt/v4: https://github.com/golang-jwt/jwt
[8]
golang.org/x/oauth2/jwt: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/jwt
[9]
JSON Web Token 入門教程: https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/rqPsGx7GxK3EUJvWdYmi6Q