はじめに
ユーザの認証とか認可って面倒ですよね。
面倒なので処理を共通化して設定を入れるだけで対応できるようにしましょう、というお話です。
本記事のメインは認可の方です。認証は雑な感じなのでご注意ください。
goのgRPC実装であるgRPC-Goは、よくあるフレームワークたちと同様にInterceptorを本処理の前後に挟むことができるため、そこで認証/認可を実装します。
認証についてはGo gRPC Middlewareにgrpc_authというものがあるため、これを使います。
最終的に作ったものはこちら。
https://github.com/arenahito/go-grpc-auth-demo
処理の流れ
下図の青いところが今回作るものです。
API I/F
まずはAPIのI/Fを定義したprotoファイルです。
HelloService
にHello
とTellMeSecret
の2つのメソッドを定義して、それぞれに異なる権限が必要となるようにします。
https://github.com/arenahito/go-grpc-auth-demo/blob/master/pb/hello.proto
syntax = "proto3";
package pb;
service HelloService {
rpc Hello (HelloRequest) returns (HelloResponse);
rpc TellMeSecret (TellMeSecretRequest) returns (TellMeSecretResponse);
}
message HelloRequest {
string message = 1;
}
message HelloResponse {
string answer = 1;
}
message TellMeSecretRequest {
string message = 1;
}
message TellMeSecretResponse {
string answer = 1;
}
認証と称する何か
前述したように認証はgrpc_authを使いますが、こいつからコールされる認証処理の本体は自分で実装する必要があります。
https://github.com/arenahito/go-grpc-auth-demo/blob/master/server/authentication.go
認証処理ではgRPCのメタデータからJWTトークンを取り出し、それをパースします。
本来はJWTの署名が正しいことをもって認証成功とすべきですが、デモ実装ということもあり、ここでは署名を検証していません。ですから、これは認証というより、ただJWTをパースしてるだけの処理ですね
本番ではちゃんと検証すべきですが、それをアプリに実装するかは要検討ですね。署名の検証をアプリコンテナのサイドカーとして実装し、アプリ本体ではJWTが常に正しいものとして扱うのもありだと思います。
パースしたJWTは、後続の処理で使用するためContextに設定しています。
token, err := grpc_auth.AuthFromMD(ctx, "bearer")
if err != nil {
return nil, status.Errorf(
codes.Unauthenticated,
"could not read auth token: %v",
err,
)
}
// デモ実装であるため、署名は検証しない。
parser := new(jwt.Parser)
parsedToken, _, err := parser.ParseUnverified(token, &jwt.StandardClaims{})
if err != nil {
return nil, status.Errorf(
codes.Unauthenticated,
"could not parsed auth token: %v",
err,
)
}
return setToken(ctx, parsedToken.Claims.(*jwt.StandardClaims)), nil
作成した認証処理を下記のようにgrpc_authに渡すことによって、Interceptorを生成することができます。
grpc_auth.UnaryServerInterceptor(server.AuthFunc)
認可
メインの認可処理です。認証処理でパースしたJWTからユーザを取得し、そのユーザの権限とアクセス先のgRPCメソッド名からアクセス許可を確認します。
https://github.com/arenahito/go-grpc-auth-demo/blob/master/server/authorization.go
Interceptor
まずはInterceptorから。
grpc.UnaryServerInfo
のFullMethod
にgRPCメソッド名のフルパスが入っています。認可処理以外でも、メソッドごとに処理を分けたい場合に使えるので覚えておくと便利です。
getUser
でJWTのSubjectをキーとしてユーザを取得し、canAccess
でアクセス先のgRPCメソッド名とユーザからアクセス可否を判断します。
func AuthorizationUnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// info.FullMethodにメソッドのフルパスが入っている。
if canAccess(info.FullMethod, getUser(GetToken(ctx).Subject)) {
return handler(ctx, req)
}
return nil, status.Error(
codes.PermissionDenied,
"could not access to specified method",
)
}
}
権限の定義
認可の判断に行く前に、権限の定義です。
権限コードは文字列で持っており、gRPCメソッド名に対して複数の権限コードを紐づけることができます。ユーザがgRPCメソッドに紐づいているすべての権限を保有している場合のみ、アクセスが許可されます。
const (
PermissionHello = "HELLO"
PermissionSecret = "SECRET"
)
var routes = map[string][]string {
"/pb.HelloService/Hello": {PermissionHello},
"/pb.HelloService/TellMeSecret": {PermissionSecret},
}
ユーザ取得
驚くほど雑なユーザ取得です。
ユーザの保存先は本記事の本質ではないため、固定値を返しています。
aliceさんはHello
に対する権限のみ、bobさんはHello
とSecret
に対する権限を保有しています。
func getUser(id string) *User {
// 本番ではDBから取得する。
switch id {
case "alice":
return &User{permissions: []string{PermissionHello}}
case "bob":
return &User{permissions: []string{PermissionHello, PermissionSecret}}
}
return &User{}
}
アクセス権限の確認
権限の定義と取得したユーザ情報から、アクセス権限を確認します。
ぐるぐる回して確認しているだけなので、特に説明することはないです。
goは標準のコレクション操作機能がないため微妙な書き方になっていますが、go-linqとか使うときれいに書けるのでお勧めです。
func canAccess(method string, user *User) bool {
r, ok := routes[method]
if !ok {
return false
}
// 検索しやすいように詰めなおす。
permissions := map[string]bool{}
for _, p := range user.permissions {
permissions[p] = true
}
// 1つでも保有していない権限があればfalseとする。
for _, p := range r {
if !permissions[p] {
return false
}
}
return true
}
動かし方
JWTの署名を検証しないようにしているため、適当なJWTを突っ込めば動きます。
下記サイトでPAYLOADのsubにaliceとかbobとか入れてJWTを生成して、gRPCのmetadataにセットしてメソッドをコールしてみてください。
https://jwt.io/
gRPCクライアントは何でも良いですが、BloomRPCがGUIでポチポチできてお手軽です。
metadataに下記のような値をセットすればOKです。
{
"authorization": "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSJ9.EatpUa1U7XoJATgksux2oCD5aIuqDstE8_2mfcmaQXc"
}
おわりに
各メソッドから認可処理を呼び出すのではなく、Interceptorでやると楽だよという話でした。
これはメソッドレベルの認可であるため、データレベルの詳細な権限確認も忘れずに。