GraphQLの認証をどこでやるか
2018-07-16
QiitaScalaGraphQLsangriaGraphQL な 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 に対するクエリであれば認証チェックをする、などの実装が可能になる。
from: https://qiita.com/petitviolet/items/62d7e0462f85bed3f39b