こんにちは。
今回は「ディレクティブを利用した認証機構の追加」について説明していきます。
この章について
GraphQLを使っているときにも、
- 認証済みのユーザーにのみリクエストを許可したい
- 特定のフィールとは権限のあるユーザーのみに公開したい
といったアクセス制御を行いたい場面は存在するかと思います。
今回は、そのような認証機構をサーバーに追加する様子を紹介します。
Authorization
ヘッダーによる認証機構の追加
まずは「Authorization
ヘッダーにトークンが入っているかどうか判定する」機構を組み込みたいと思います。
HTTPリクエストの中身を見る機構はGraphQL層の前に
GraphQLサーバーがレスポンスに必要なデータを集めてくる処理は全てリゾルバの中で行っていましたが、リゾルバ関数の中には「HTTP通信」の要素を感じられるような引数は存在しません。
例えば、node
クエリに対応するリゾルバを以下に示します。
引数に入っているのは、ユーザーがリクエスト時に渡したid
パラメータとcontextのみであり、http.Request
型のようなnet/http
パッケージにあるような素のHTTP通信を彷彿とさせる要素は存在しません。
// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id string) (model.Node, error)
これは、GraphQLのリゾルバが「レスポンスに必要なデータを集めて、それを構造体型といったある種の決まった値にまとめる」というところに集中できるよう、「データをjsonエンコードしてhttp.ResponseWriter
に書き込む」などのHTTP通信に関わる部分を隠蔽された状態のものだからです。
そのため、リクエストヘッダーのようなHTTPリクエストに関わる生データが見たいというのであれば、そのような処理はリゾルバの前段に入れる必要があるのです。
認証ミドルウェアの作成
ディレクトリ・ファイルの作成
HTTPリクエストの中身を見るような前後処理を挟むためには、通常のHTTPサーバーを作るときと同様の手段でミドルウェアを実装すれば良いのです。
まずはそのためのパッケージとファイルを作りましょう。
.
├─ internal
│ └─ generated.go
├─ graph
│ ├─ db # SQLBoilerによって生成されたORMコード
│ │ └─ (略)
│ ├─ services # サービス層
│ │ └─ (略)
│ ├─ model
│ │ └─ (略)
│ ├─ complexity.go
│ ├─ dataloader.go
│ ├─ resolver.go
│ └─ schema.resolvers.go
├─ schema.graphqls # スキーマ定義
+├─ middlewares
+│ └─ auth
+│ └─ auth.go
├─ gqlgen.yml # gqlgenの設定ファイル
├─ server.go # エントリポイント
├─ go.mod
└─ go.sum
ミドルウェアの実装
auth.go
の中に、
-
Authorization
ヘッダーがない場合には、認証されていないユーザーという扱いで後続処理を行う -
Authorization
ヘッダーに有効なトークンが入っていたら、ユーザー情報を取り出してcontextに格納・後続処理を行う -
Authorization
ヘッダーに格納されているトークンが無効なものなら、その場で401 Unauthorizedを返却する
という処理を行うミドルウェアを実装します。
package auth
type userNameKey struct{}
const (
tokenPrefix = "UT"
)
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
token := req.Header.Get("Authorization")
if token == "" {
next.ServeHTTP(w, req)
return
}
userName, err := validateToken(token)
if err != nil {
log.Println(err)
http.Error(w, `{"reason": "invalid token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(req.Context(), userNameKey{}, userName)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
func validateToken(token string) (string, error) {
tElems := strings.SplitN(token, "_", 2)
if len(tElems) < 2 {
return "", errors.New("invalid token")
}
tType, tUserName := tElems[0], tElems[1]
if tType != tokenPrefix {
return "", errors.New("invalid token")
}
return tUserName, nil
}
認証ミドルウェアの導入
作成したミドルウェアをGraphQLサーバーに導入するには、サーバー起動時にミドルウェアを使用するようにラップすればOKです。
// (一部抜粋)
func main() {
// 1. GraphQLサーバーの作成
db, err := sql.Open("sqlite3", fmt.Sprintf("%s?_foreign_keys=on", dbFile))
if err != nil {
log.Fatal(err)
}
defer db.Close()
service := services.New(db)
srv := handler.NewDefaultServer(internal.NewExecutableSchema(internal.Config{
Resolvers: &graph.Resolver{
Srv: service,
Loaders: graph.NewLoaders(service),
},
}))
// 2. サーバーを起動
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
- http.Handle("/query", srv)
+ http.Handle("/query", auth.AuthMiddleware(srv))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
動作確認
この状態でサーバーを稼働させ、本当にトークンを利用した認証の仕組みが動いているのか確認してみましょう。
トークンなしの状態
まずは普通にリクエストを送ってみます。
query {
user(name: "hsaki") {
id
name
projectV2(number: 1) {
title
}
}
}
すると、以下のような正常応答が返ってくることがわかります。
{
"data": {
"user": {
"id": "U_1",
"name": "hsaki",
"projectV2": {
"title": "My Project"
}
}
}
}
有効なトークンをつけた場合
以下のように、UT_xxxという形のトークンをヘッダにつけてリクエストを送ってみます。
{
"Authorization": "UT_hsaki"
}
すると、以下のような正常応答が返ってくることがわかります。
{
"data": {
"user": {
"id": "U_1",
"name": "hsaki",
"projectV2": {
"title": "My Project"
}
}
}
}
無効なトークンを使った場合
UT_xxx
という形になっていないめちゃくちゃなトークンも送ってみます。
{
"Authorization": "aaaaaa"
}
するとinvalid token
というエラーを得られて、GraphQLによるデータ取得処理にたどり着いていないことが確認できました。
{
"reason": "invalid token"
}
ディレクティブを利用したGraphQL層での認証機構の追加
GraphQL層には認証認可の機構を組み込めないのか、というとそうではありません。
ここからは「Authorization
ヘッダーの中からユーザー情報をとってこれた場合のみ、user
クエリを実行できるようにする」というアクセス制御を追加してみたいと思います。
ディレクティブとは
GraphQLのスキーマには、ディレクティブというカスタムデコレータを追加することができます。
今回の場合、user
クエリを定義するところに@isAuthenticated
というディレクティブをつけていました。
directive @isAuthenticated on FIELD_DEFINITION
type Query {
user(
name: String!
): User @isAuthenticated
}
しかし、ただディレクティブをつけただけでは「〇〇の条件のときには~する」といったフック処理を実現させることはできません。
そのように実装を追加する必要があるので、これからその作業をしていきましょう。
ディレクティブごとの処理をサーバーConfigで指定する
サーバーで利用するディレクティブごとのフック処理は、サーバーエンドリポイントにて記述するConfigの内容で決定されます。
func main() {
// (中略)
srv := handler.NewDefaultServer(internal.NewExecutableSchema(internal.Config{
Resolvers: &graph.Resolver{
Srv: service,
Loaders: graph.NewLoaders(service),
},
+ Directives: /*TODO: 適切な設定を記述*/,
Complexity: graph.ComplexityConfig(),
}))
srv.Use(extension.FixedComplexityLimit(10))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", auth.AuthMiddleware(srv))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
internal.Config
構造体の内容と、そのConmplexity
フィールドの内容に何を指定すればいいのかは、gqlgen
コマンドによって自動生成されたコード内にて定義が記述されています。
type Config struct {
Resolvers ResolverRoot
Directives DirectiveRoot
Complexity ComplexityRoot
}
type DirectiveRoot struct {
IsAuthenticated func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error)
}
つまり、ディレクティブによるフック処理を定義するためには、
-
func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error)の
関数シグネチャにあう形で、フック処理を実装する - 1で作成した関数を
IsAuthenticated
フィールドに詰めたDirectiveRoot
構造体を作る - 2で作成した
DirectiveRoot
構造体をDirectives
フィールドに詰めたサーバーConfigを作成して使用する
という流れを踏むことになります。
ディレクティブのフック処理実装
それではここからは実際にその実装をしていきましょう。
ファイルの作成
ディレクティブによる処理を記述するためのファイルをまずは作成します。
.
├─ internal
│ └─ generated.go
├─ graph
│ ├─ db # SQLBoilerによって生成されたORMコード
│ │ └─ (略)
│ ├─ services # サービス層
│ │ └─ (略)
│ ├─ model
│ │ └─ (略)
│ ├─ complexity.go
+│ ├─ directive.go
│ ├─ dataloader.go
│ ├─ resolver.go
│ └─ schema.resolvers.go
├─ schema.graphqls # スキーマ定義
├─ middlewares
│ └─ auth
│ └─ auth.go
├─ gqlgen.yml # gqlgenの設定ファイル
├─ server.go # エントリポイント
├─ go.mod
└─ go.sum
フック処理の実装
「func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error)
の関数シグネチャにあう形で、フック処理を実装し、それをIsAuthenticated
フィールドに詰めたDirectiveRoot
構造体を作る」という部分をやっていきます。
var Directive internal.DirectiveRoot = internal.DirectiveRoot{
IsAuthenticated: IsAuthenticated,
}
func IsAuthenticated(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
if _, ok := auth.GetUserName(ctx); !ok {
return nil, errors.New("not authenticated")
}
return next(ctx)
}
func GetUserName(ctx context.Context) (string, bool) {
switch v := ctx.Value(userNameKey{}).(type) {
case string:
return v, true
default:
return "", false
}
}
今回は「Authorization
ヘッダーに格納されていたトークンから取得したユーザー情報がcontextに格納されていた場合には処理を実行する」というロジックにしてみました。
Configをサーバーに反映
@isAuthenticated
ディレクティブが指定されたときの処理をConfigに実装できたため、それをサーバーの中で使用できるようにします。
func main() {
// (中略)
srv := handler.NewDefaultServer(internal.NewExecutableSchema(internal.Config{
Resolvers: &graph.Resolver{
Srv: service,
Loaders: graph.NewLoaders(service),
},
- Directives: /*TODO: 適切な設定を記述*/,
+ Directives: graph.Directive,
Complexity: graph.ComplexityConfig(),
}))
srv.Use(extension.FixedComplexityLimit(10))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", auth.AuthMiddleware(srv))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
動作確認
この状態でサーバーを稼働させ、本当に「トークン認証できているときのみuserクエリが実行可能」になっているかどうか確認してみましょう。
トークンなしでリクエストを送信
先ほどと同様に、以下のようなuser
クエリを送信します。
query {
user(name: "hsaki") {
id
name
projectV2(number: 1) {
title
}
}
}
すると、以下のようなエラーが返ってきて、クエリが実行できていないことがわかります。
{
"errors": [
{
"message": "not authenticated",
"path": [
"user"
]
}
],
"data": {
"user": null
}
}
有効なトークンをつけてリクエスト
今度は以下のように、UT_xxx
から始まる有効なトークンを付与してuser
クエリを実行します。
{
"Authorization": "UT_hsaki"
}
すると、無事にレスポンスデータを得ることができました。
{
"data": {
"user": {
"id": "U_1",
"name": "hsaki",
"projectV2": {
"title": "My Project"
}
}
}
}
以上です。
次章予告
次は、「GraphQLサーバーから返却されるエラーメッセージ」をご紹介したいと思います。
ありがとうございました。
よろしくお願いいたします。