Go gRPC 系列:
跟我一起學Go系列:gRPC安全認證機制-SSL/TLS認證
接上一篇繼續講 gRPC 認證,本篇內容主要是 Token 認證和自定義認證方式的使用。
說 Token 認證就不得不提 Session。做 Web 端開發的同學應該都了解 Session 和 Token 機制。
Token 校驗
基於 Session 的身份校驗機制
Session 一般由服務端存儲,用戶通過用戶名和密碼登錄之后服務端會在服務器開辟一塊 Session 內存空間存入用戶信息,同時服務器會在 cookie 信息中寫入一個 Session_id 值,用於標識這一塊內存空間。下次用戶再來請求的時候會由 cookie 中帶過來這個 Session_id,服務端拿着這個 Session_id 去尋找對應的 Session,如果能找到說明用戶已經登錄過,不用重新走授權的邏輯。
使用 Session 存在問題在哪里:
- 服務端存儲壓力過大,當用戶量大的時候,所有用戶都會在內存中保存 Session 信息,可想而知需要很大的內存空間。
- 分布式應用下 Session 共享問題會耗費更多的存儲。
- Session 機制是基於 cookie 的,cookie 如果被截取用戶很容易受到 CSFR(跨站偽造請求攻擊)。
- 另外使用 cookie 的另一個弊端就是不支持跨域,當然對於跨域的處理現在已經不是什么問題。
基於 Token 的身份校驗機制
再來說說 Token 機制。token 即令牌的意思,令牌的生成規則是我們自定義的,用戶第一次登錄后服務端生成一個令牌返回給客戶端,以后客戶端在令牌過期內只需要帶上這個令牌以及生成令牌必要的參數,服務端通過生成規則能生成一樣的令牌即表示校驗通過。
原理很簡單但是帶來的效果卻是翻倍提升的:
- 采用生成規則校驗,服務端無需存儲 token 數據,沒有內存壓力,同樣服務端做負載均衡的時候也無需像 session 那樣需要考慮分布式存儲問題。
- 支持跨域,將 token 置於請求頭中即可。
- 對於移動端這種不支持 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 typecty
:Content typealg
: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 的認證和自定義認證的邏輯。大家根據業務場景自行決定使用哪種校驗即可。