うさぎ小屋(仮)

📅  2021-11-28

graphQLサーバにログイン処理を実装してみる


sample-graphqlにログイン処理を実装していきます。

ログインの実装

schema.graphqlでemailとパスワードをUserに追加し、LoginAPIを叩いたらログイン用のトークンが返ってくるようにする。

 type User {
   id: ID!
   name: String!
+  email: String!
+  password: String!
 }

input NewUser {
   name: String!
+  email: String!
+  password: String!
}

+input AuthorizationRequest {
+  email: String!
+  password: String!
+}
+
+type AuthorizationResponse {
+  token: String!
+}

type Mutation {
   createTodo(input: NewTodo!): Todo!
   createUser(input: NewUser!): User!
+  authorization(input: AuthorizationRequest!): AuthorizationResponse! # 認可
 }

gqlgen generateの実行

gqlgen generate

app/domain/authToken/authToken.goにトークンのドメインモデルを実装する。

package authToken

import (
	"context"
	"fmt"
	"time"

	"github.com/dgrijalva/jwt-go"
)

type authToken struct {
	userID string
	token  string
}

const (
	// secret は openssl rand -base64 40 コマンドで作成した。TODO:環境変数化
	secret = "1ptTwOz/dcTcYVuzDbHS+S7nLCN9eO/LU+iSirrlTPyBkRl3lXbw0A=="

	// contextにセットするキー
	contextKey = "auth_token"

	// userIDKey はユーザーの ID を表す。
	userIDKey = "user_id"

	// iat と exp は登録済みクレーム名。それぞれの意味は https://tools.ietf.org/html/rfc7519#section-4.1 を参照
	iatKey = "iat"
	expKey = "exp"

	// lifetime は jwt の発行から失効までの期間を表す。
	lifetime = 30 * time.Minute
)

// userIDからjwtトークンを発行する
func New(userID string, now time.Time) (*authToken, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		userIDKey: userID,
		iatKey:    now.Unix(),
		expKey:    now.Add(lifetime).Unix(),
	})
	tokenStr, err := token.SignedString([]byte(secret))

	if err != nil {
		return nil, err
	}

	return &authToken{
		userID: userID,
		token:  tokenStr,
	}, nil
}

// contextにトークンをセットする
func SetToken(parents context.Context, t authToken) context.Context {
	return context.WithValue(parents, contextKey, &t)
}

// contextからトークンを取得する
func GetToken(ctx context.Context) (*authToken, error) {
	v := ctx.Value(contextKey)

	token, ok := v.(*authToken)
	if !ok {
		return nil, fmt.Errorf("token not found")
	}

	return token, nil
}

// トークンを検証しauthTokenを生成する
func GenerateAuthByToken(tokenString string) (authToken, error) {
	token, err := verifyToken(tokenString)
	if err != nil {
		return authToken{}, err
	}

	claims := token.Claims.(jwt.MapClaims)

	return authToken{
		userID: claims[userIDKey].(string),
		token:  token.Raw,
	}, nil

}

func (a authToken) GetUserID() string {
	return a.userID
}

func (a authToken) GetToken() string {
	return a.token
}

func verifyToken(tokenString string) (*jwt.Token, error) {
	// jwtの検証(改ざん検知、期限切れ)
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}

	return token, nil
}

app/middleware/auth.go

package middleware

import (
	"net/http"

	"github.com/amasok/sample-graphql/app/domain/authToken"
)

// TODO: configでoriginを切り分けられるようにする
func AuthForGraphql(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		auth := r.Header.Get("Authorization")

		if auth == "" {
			next.ServeHTTP(w, r)
			return
		}
		bearer := "Bearer "
		auth = auth[len(bearer):]

		token, err := authToken.GenerateAuthByToken(auth)
		if err != nil {
			next.ServeHTTP(w, r)
			return
		}

		// ここでトークンをcontextにセットする
		ctx := authToken.SetToken(r.Context(), token)
		r = r.WithContext(ctx)

		next.ServeHTTP(w, r)
	})
}

app/presentation/graphql/directive/auth.goの実装

上記、middlewareでセットしたcontextのトークンが正しくセットされているか確認する。

package directive

import (
	"context"
	"fmt"

	"github.com/99designs/gqlgen/graphql"
	"github.com/amasok/sample-graphql/app/domain/authToken"
	"github.com/vektah/gqlparser/v2/gqlerror"
)

func Auth(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
	tokenData, err := authToken.GetToken(ctx)
	if err != nil {
		return nil, &gqlerror.Error{
			Message: "Access Denied",
		}
	}
	// ログインユーザーIDを見てみる
	fmt.Printf("login user_id: %s", tokenData.GetUserID())
	return next(ctx)
}

cmd/graphql/main.goの実装

上記、middlewareとdirectiveの認証をmain.goに適用する。

@@ -9,6 +9,7 @@ import (
        "github.com/99designs/gqlgen/graphql/playground"
        "github.com/amasok/sample-graphql/app/middleware"
        "github.com/amasok/sample-graphql/app/presentation/graphql"
+       "github.com/amasok/sample-graphql/app/presentation/graphql/directive"
        "github.com/amasok/sample-graphql/app/presentation/graphql/generated"
        "github.com/gorilla/mux"
 )
@@ -23,7 +24,16 @@ func main() {

        r := mux.NewRouter()
        r.Use(middleware.CORSForGraphql)
-       srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graphql.Resolver{}}))
+       r.Use(middleware.AuthForGraphql) // ここでヘッダーにトークンがあれば内容をチェックしてcontextにセットする
+
+       config := generated.Config{
+               Resolvers: &graphql.Resolver{},
+               Directives: generated.DirectiveRoot{
+                       Auth: directive.Auth, // @authがついてるディレクティブで認証の承認を行う
+               },
+       }
+
+       srv := handler.NewDefaultServer(generated.NewExecutableSchema(config))

        r.Handle("/", playground.Handler("GraphQL playground", "/query"))
        r.Handle("/query", srv)

確認

ログインしてトークンを取得する

image in the content

@auth ディレクティブが設定されているCreateTodoで認証が通るか、通らなければエラーが変えるか確認する

↓ヘッダーにログイン時に返ってきたトークンをセットした場合

image in the content

↓トークンをセットしていない場合

image in the content

今回の変更PR

https://github.com/amasok/sample-graphql/pull/2

参考

https://gqlgen.com/recipes/authentication/

https://ichi.pro/jwt-o-shiyoshite-go-graphql-o-ninshosuru-289582363661