petitviolet blog

    Scala - SangriaでUpdateCtxを使ってGraphQLの認証を実装する

    2018-04-27

    QiitaScalaGraphQLsangria

    Scala 用 GraphQL フレームワークのsangriaUpdateCtxを使って認証処理を実装する。 使い方に注意点がいくつかあるが、まずは普通に動かすための方法について。

    UpdateCtx を使って実装する

    認証処理に関するドキュメントはLearn SangriaAuthentication and Authorisationセクションにあり、「Resolve-Based Auth」として紹介されているようにUpdateCtxを使って実装してみる。

    その前に、まずは sangria を動かすための前提となるCtxおよび共通して使用する型を定義する。 色々実装が雑なのはサンプルのためなので目を瞑る。 コード全体像はGithubに push してある。

    // ユーザー
    case class User(id: Long, name: String, email: String, password: String) {
      def updateName(newName: String) = copy(name = newName)
    }
    trait UserDao {  // databaseへの接続.実装は省略
        def findAll: Seq[User]
        def login(email: String, password: String): Try[Token]
        def findByToken(token: Token): Option[User]
        def update(newUser: User): Unit
    }
    // 認証トークン
    case class Token(value: String)
    
    // Ctxとして使用
    case class GraphQLContext(userOpt: Option[User] = None) {
      val userDao: UserDao = ???
      // ログイン処理してtoken発行
      def loggedIn(user: User): GraphQLContext = copy(userOpt = Some(user))
      // tokenからuserを見付けて認証
      def authenticate(token: Token): GraphQLContext = copy(userOpt = userDao.findByToken(token))
    }
    

    GraphQLContext.userDao.loginを使って認証する Mutation を実装していく。

    まずは login してCtxUpdateCtxで更新する Field を実装する。

    // 入力
    val emailArg = Argument("email", StringType, "email of user")
    val passwordArg = Argument("password", StringType, "password of user")
    
    // 出力
    val authenticateType: ObjectType[Unit, Token] = derive.deriveObjectType[Unit, Token]()
    
    // loginしてUpdateCtxするField
    val authenticateField = fields[GraphQLContext, Unit](
      Field(
        "login",
        authenticateType,
        arguments = emailArg :: passwordArg :: Nil,
        resolve = { ctx =>
          ctx.withArgs(emailArg, passwordArg) { (email, password) =>
            // login処理を実行
            UpdateCtx(ctx.ctx.userDao.login(email, password)) { token: Token =>
              // loginが成功(Successなら)したらctxを更新
              ctx.ctx.authenticate(token)
            }
          }
        }
      )
    )
    

    続いてログイン中の User の情報を取得する、User を更新する Field を用意する。

    // 入力
    val nameArg = Argument("name", StringType, "name of user")
    // 出力
    val userType: ObjectType[Unit, User] = derive.deriveObjectType[Unit, User]()
    
    val userField = fields[GraphQLContext, Unit](
      Field(
        "get", // login中のuserを取得する
        OptionType(userType),
        arguments = Nil,
        resolve = ctx => ctx.ctx.userOpt
      ),
      Field(
        "update",  // nameを更新する
        userType,
        arguments = nameArg :: Nil,
        resolve = { ctx =>
          ctx.withArgs(nameArg) { name =>
            val user = ctx.ctx.userOpt.get // 雑にOption.get
            val newUser = user.updateName(name)
            UserDao.update(newUser)
            newUser
          }
        }
      )
    )
    

    これらの Field を Mutation として公開する。

    val mutation = ObjectType(
      "Mutation",
      fields[GraphQLContext, Unit](
        // loginするのとuserのget/updateをMutationとして公開
        authenticateField ++ userField : _*
      )
    )
    

    このmutationを使ってSchemaを作って GraphQL の Query を実行してみる

    mutation Login {
      login(email: "hoge@example.com", password: "password") {
        value
      }
      get {
        id
        name
      }
      update(name: "updated!") {
        id
        name
      }
    }
    

    結果は以下のように得ることが出来る。

    {
      "data": {
        "login": {
          "value": "token-1"
        },
        "get": {
          "id": 1,
          "name": "user-1"
        },
        "update": {
          "id": 1,
          "name": "updated"
        }
      }
    }
    

    無事にloginして、UpdateCtxによってCtxに与えられた User の情報をgetupdateで参照することが出来た。

    UpdateCtx の注意点

    便利なUpdateCtxだが注意点がいくつかある。

    クエリ内で UpdateCtx を記述する位置

    UpdateCtxがある Field を考慮して実行順序をいい感じにしてくれるようにはなっていない。 単純に書いた順番、先程の例だとlogin, get, updateと上から順に実行されている。
    なので、順番を入れ替えるとダメになる。

    mutation LoginAndUpdate {
      get {
        id
        name
      }
      login(email: "user-1@example.com", password: "password") {
        value
      }
      update(name: "updated") {
        id
        name
      }
    }
    

    結果はgetだけ login 中の user がCtxから取得できない。
    loginしてupdateは無事に成功する。

    {
      "data": {
        "get": null,
        "login": {
          "value": "1"
        },
        "update": {
          "id": 1,
          "name": "updated"
        }
      }
    }
    

    つまりUpdateCtxはそれ以降のCtxを更新するものなので、使用する場合はクライアント側で順番に注意して記述する必要がある。

    UpdateCtx は query では効かない

    上では mutation のサンプルを実装したが、query でも試してみる。 login 中の user 情報を返すだけの query を実装する。

    val userQuery = ObjectType(
      "Query",
      fields[GraphQLContext, Unit](
        Field(
          "get", // mutationのgetと同じ
          OptionType(userType),
          arguments = Nil,
          resolve = ctx => ctx.ctx.userOpt
        )
      )
    )
    

    これで Schema を作成し、実行してみる。
    投げるクエリは以下。 先程のmutation LoginAndUpdateからqueryに変化しているだけ。

    query MyQuery {
      login(email: "user-1@example.com", password: "password") {
        value
      }
      get {
        id
        name
      }
    }
    

    実行してみると以下の結果が得られる。

    {
      "data": {
        "login": {
          "value": "token-1"
        },
        "get": null
      }
    }
    

    つまり、query ではUpdateCtxが効いていない。 UpdateCtxは mutation でしか有効でないので、query でも認証したければUpdateCtx以外で行う必要がある。

    ネストしたパスでは UpdateCtx が効かない

    GraphQL だとトップレベルにクエリを並べることが多いとは思うが、入れ子にすることも出来る。 先程実装した mutation にprefixを付けてみる。

    val mutation =
      ObjectType(
        "Mutation",
        fields[GraphQLContext, Unit](
          Field("prefix",
            ObjectType("prefix",
              fields[GraphQLContext, Unit](authenticateFields ++ userMutation: _*)),
              resolve = _ => ()
            )
          )
        )
    

    これに対して先程と同じようにloginしてgetしてupdateするクエリを投げてみる。 prefixというパスの中に全部入る形となる。

    mutation LoginAndUpdate {
      prefix {
        login(email: "user-1@example.com", password: "password") {
          value
        }
    
        get {
          id
          name
        }
    
        update(name: "updated") {
          id
          name
        }
      }
    }
    

    結果はこんな感じ。

    {
      "data": null,
      "errors": [
        {
          "message": "Internal server error",
          "path": ["prefix", "update"],
          "locations": [
            {
              "line": 41,
              "column": 5
            }
          ]
        }
      ]
    }
    

    updateの内部でOption.getしているところでNoSuchElementExceptionが throw されてしまっている。

    prefix > loginだけにしてみると、期待通り実行されている。

    mutation Login {
      prefix {
        login(email: "user-1@example.com", password: "password") {
          value
        }
      }
    }
    

    結果は以下の通り。

    {
      "data": {
        "prefix": {
          "login": {
            "value": "token-1"
          }
        }
      }
    }
    

    UpdateCtxを使う Field はトップレベルに配置すること。 つまり、先ほどprefixに入れ込んだ実装を少し変えてauthenticateFieldsだけ外に出すと期待通り動くようになる。

    val mutation =
      ObjectType("Mutation",
        // ここでauthenticateFieldsはトップレベルに配置する
        authenticateFields ++ fields[GraphQLContext, Unit](
          Field("prefix", // それ以外はネストしていても問題ない
            ObjectType("prefix", fields[GraphQLContext, Unit](userMutation: _*)),
            resolve = _ => ())
        ))
    

    これに対してloginしてgetしてupdateするクエリを投げてみる。

    mutation LoginAndUpdate {
      login(email: "user-1@example.com", password: "password") {
        value
      }
      prefix {
        get {
          id
          name
        }
        update(name: "updated") {
          id
          name
        }
      }
    }
    

    結果は期待通り、無事にUpdateCtxが機能している。

    {
      "data": {
        "login": {
          "value": "token-1"
        },
        "prefix": {
          "get": {
            "id": 1,
            "name": "user-1"
          },
          "update": {
            "id": 1,
            "name": "updated"
          }
        }
      }
    }
    

    所感

    UpdateCtxの使い方には癖があるが、GraphQL のクエリだけで認証を完結させられるので便利。 しかし、Query で利用できないのは厳しい。 実際のプロダクト開発だと Http-Header に認証 Token を入れて GraphQL とは別のレイヤーで認証することになりそう。

    from: https://qiita.com/petitviolet/items/1fb6a8e52f02f4309f5b