在哪里进行GraphQL的身份验证

在实现 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世界后进行身份验证的模式。由于无法在每次解析时都进行身份验证,因此需要处理以确保全局仅进行一次验证。

在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查询中不出现对应于身份验证令牌的内容。

使用GraphQL可以将认证过程与数据分离,这是一个重要的优点。
以下是它的缺点。

    • Http Headerに認証トークンを与えないといけないのでGraphiQLをそのままでは使えない

 

    • GraphQLレイヤより手前で認証処理を行わないといけないので、レイヤードアーキテクチャと相性が悪いかも

GraphQLはアダプタ層/プレゼンター層、認証はアプリケーション層/ユースケース層なので逆転してしまうはず
アプリケーションの設計によってここは変わるかも知れない

不确定感

被迫在应用程序架构的最外层进行认证。例如,如果想要在Clean Architecture的用例层执行认证,则不得不通过引入GraphQL来在适配器层执行认证,这将导致与架构不一致。在这种情况下,可以选择忽略此问题或者仅获取与认证令牌关联的账户ID,然后将其作为DTO填充到GraphQL的上下文(Ctx)中,并且在用例层中再次获取账户对象以获取一致性,但这显然是一种冗余的处理,因此需要在代码的清晰度和性能之间做出选择。

身份验证和架构

请求的用户,也就是与认证结果相关的信息,如何在GraphQL的模式中表示呢?使用Relay中的指令viewer似乎是很好的选择。在GitHub的v4 API中也有viewer,可以访问账户信息和该账户所拥有的仓库等内容。

如果是一个只能在进行认证后才能使用的服务,是否将所有的查询都放在viewer下面呢?
这可能很可能是否定的。即使在这样的服务中,只有与请求者账户相关联的信息才放在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的标签中设置某个值,并且可以对该Field进行查询时进行访问认证验证等的实现变得可能。

广告
将在 10 秒后关闭
bannerAds