GraphQLなAPIを実装するにあたって、認証をどうするか。
- GraphQL内部で認証する
- GraphQLの外で認証する
- 認証とスキーマ
参考:
- A guide to authentication in GraphQL – Apollo GraphQL
- Learn SangriaのAuthentication and Authorisationセクション
ScalaでSangriaを使って実装を考える。
個人的な結論
認証はGraphQLの手前で実行する。
具体例としては、クライアント側は認証が必要な操作をする際にHTTP Headerに認証トークン的なものを入れて送る。
サーバ側はGraphQLの世界に入る前にHTTP Headerからトークンを取り出して認証を行い、ログイン中のアカウントを特定してCtx
のプロパティに入れてGraphQL世界に渡す。
スキーマとしては、アカウントそのものの情報を必要とする場合はviewer
ディレクティブからアクセスする。
それ以外はルートに配置する。
認可は別で考える必要があるがここでは扱わない。
GraphQL内部で認証する
GraphQLの世界に入ってから認証処理を行うパターン。
resolveする際に毎回認証するわけにはいかないので、全体で一回だけになるようにハンドリングする必要がある。
ScalaのGraphQLフレームワークであるsangriaにはUpdateCtx
というAPIがあり、認証した結果でCtx
を更新して後続の処理に繋げることが出来る。
これによって認証処理を一度だけにすることが可能。
UpdateCtx
を使った認証周りの実装はこんな感じ。
// DBへのアクセス
class UserDao() {
def findByToken(token: Token): Option[User] = ???
}
// Ctxとして使用する
case class GraphQLContext(userOpt: Option[User] = None) {
val userDao = new UserDao()
def loggedIn(user: User): GraphQLContext = copy(userOpt = Some(user))
}
val tokenArg = Argument("token", StringType, "token of logged in user")
ObjectType(
"Mutation",
fields[Ctx, Unit](
Field(
"authenticate",
OptionType(userType),
arguments = tokenArg :: Nil,
resolve = { ctx =>
ctx.withArgs(tokenArg) { token =>
// 認証を実行
UpdateCtx(ctx.ctx.userDao.findByToken(Token(token))) { userOpt =>
userOpt.fold(ctx.ctx) { user =>
// 成功していたらCtxを更新する
ctx.ctx.loggedIn(user)
}
}
}
}
),
??? // 他のMutation
)
)
これを使ったGraphQLのクエリは以下のようにかける
mutation {
authenticate(token: "your-token") {
id
}
// 認証後の操作
...
}
UpdateCtx
について詳しくはQiitaに以前書いた。
https://qiita.com/petitviolet/items/1fb6a8e52f02f4309f5b
UpdateCtx
はなかなか便利な仕組みだが、
- クライアント側でQueryを記述する順番に気をつける必要がある
-
UpdateCtx
が実行されるパスを上に持ってこないと後続に認証結果が引き継がれない
-
- Mutationのみで、Queryでは使用できない
- Queryはparallelに実行され、Mutationはserialなため
- Mutationでもネストしたディレクティブでは効果がない
- これは
UpdateCtx
の実装の問題かも?
- これは
という問題がある。
認証というのはQueryかMutationに関わらず共通して実行されてほしいことが多いので、
すべてのQuery/Mutationに対して認証トークンを渡して毎回認証をするような実装にせざるを得ない。
GraphQLの外で認証する
GraphQLの外、つまりGraphQLの世界に入る前に認証処理を実行してその結果をもってGraphQLの世界に入っていく 。
要するにCtx
作成時に認証を行うようなイメージ。
// Ctxとして使用する
class GraphQLContext private (val userOpt: Option[User] = None)
object GraphQLContext {
// Headerから取得したtokenを使ったファクトリ
def create(tokenOpt: Option[String]): GraphQLContext = {
tokenOpt.fold(apply()) { token =>
// 認証
new GraphQLContext(UserDao.findByToken(Token(token)))
}
}
}
これをAkka-HTTPと組み合わせて使うなら以下のような実装になる。
def route = (post & path("graphql")) {
entity(as[JsValue]) { JsObject(fields) =>
optionalHeaderValueByName("x-token") { tokenOpt =>
// HeaderからTokenを取り出してGraphQLContextを作成すると同時に認証する
val ctx = GraphQLContext.create(tokenOpt)
// ほぼ定型文
val operation = fields.get("operationName") collect { case JsString(op) => op }
val vars = fields.get("variables") match {
case Some(obj: JsObject) => obj
case _ => JsObject.empty
}
val Some(JsString(document)) = fields.get("query")
// GraphQLのクエリを実行
val res =
Executor.execute(schema, document, ctx, variables = vars, operationName = operation)
.map { OK -> _ }
.recover {
case err: QueryAnalysisError => BadRequest -> err.resolveError
case err: ErrorWithResolver => InternalServerError -> err.resolveError
}
complete(res)
}
}
}
GraphQLContext.create
したものをCtx
としてsangria.execution.Executor.execute
の引数に与えれば良い。
これによってGraphQLのクエリには認証tokenにあたるものは登場しない。
メリットとしてはGraphQLと認証処理を分離することが出来る、ということが大きい。
デメリットとしては以下がある。
- Http Headerに認証トークンを与えないといけないのでGraphiQLをそのままでは使えない
- GraphQLレイヤより手前で認証処理を行わないといけないので、レイヤードアーキテクチャと相性が悪いかも
- GraphQLはアダプタ層/プレゼンター層、認証はアプリケーション層/ユースケース層なので逆転してしまうはず
- アプリケーションの設計によってここは変わるかも知れない
懸念
アプリケーションアーキテクチャの一番外側に近い箇所での認証を強要されることになってしまう。
たとえば認証はCleanArchitectureにおけるユースケース層で行いたい場合などには、GraphQLを導入することによって認証をアダプタ層で行わざるをえなくなってしまうので、アーキテクチャとの整合性が取れなくなってしまう。
ここについては目をつぶるか、認証トークンに紐づくアカウントIDのみを取得した上でそれをDTOとしてGraphQLのCtx
に詰め、ユースケース層で改めてアカウントIDからアカウントのオブジェクトを取得する、という実装にすれば整合性は失われないが明らかに無駄な処理になるので、コードの綺麗さかパフォーマンスかを選択することになる。
認証とスキーマ
リクエストしてきたユーザ、つまり認証結果に紐づく情報をどうやってGraphQLのスキーマとして表現するか。
Learn Relayにも記述がある、viewer
というディレクティブを使うと良さそう。
Githubのv4 APIにもviewerがあり、アカウントの情報やそのアカウントが持つリポジトリの情報などにアクセスすることが出来る。
では認証しないと使えないサービスであった場合に、全てのクエリをviewer
配下に置くかどうか。
これは恐らくNoで、そういったサービスの場合でも、リクエスト元のアカウントに紐づく情報のみがviewer
以下で、認証さえしていれば共通してアクセスできるリソースに対してはルートに配置するのが良いかと。
認証を必須とするようなディレクティブについて、sangriaではMiddlewareという機構を提供しているため、そこで認証の必須チェックを行うことも出来る。
公式ドキュメントのサンプルをそのまま貼ってみる。
object SecurityEnforcer extends Middleware[SecureContext] with MiddlewareBeforeField[SecureContext] {
type QueryVal = Unit
type FieldVal = Unit
def beforeQuery(context: MiddlewareQueryContext[SecureContext, _, _]) = ()
def afterQuery(queryVal: QueryVal, context: MiddlewareQueryContext[SecureContext, _, _]) = ()
def beforeField(queryVal: QueryVal, mctx: MiddlewareQueryContext[SecureContext, _, _], ctx: Context[SecureContext, _]) = {
val permissions = ctx.field.tags.collect {case Permission(p) ⇒ p}
val requireAuth = ctx.field.tags contains Authorised
val securityCtx = ctx.ctx
if (requireAuth)
securityCtx.user
if (permissions.nonEmpty)
securityCtx.ensurePermissions(permissions)
continue
}
}
Field
のtag
に何か値を設定しておいて、そのFieldに対するクエリであれば認証チェックをする、などの実装が可能になる。