GraphQL Java Kickstartを使用したWebシステムで認証トークンをCookieに格納する

[Spring for GraphQL](https://spring.io/projects/spring-graphql)(名称がSrping GraphQLではないんですね。今気づいた。)も今年バージョン1.0がリリースされ、知らぬ間にNetflixから[DGS](https://netflix.github.io/dgs/)もリリースされてたり、Java界隈のGraphQLフレームワークもにぎやか(?)になってきました。自分はSpring Bootを中心にアプリケーション開発をしているので、将来的にはSpring for GraphQLが本命かなと思うのですが、現時点ではGraphQL Java Kickstartに一日の長がある認識です。

* 特にGraphQL Java Kickstartだと、schemaファイルとJavaの実装で相違があると、起動時にエラーを吐いてくれるのが助かります。

そのGraphQL Java Kickstartを使用してWebアプリケーションの認証ですが、最初はサーバ環境が潤沢な社内システムだったので、Keycloakで認証させました。フロントエンド用にKeycloak JSがあり、Spring Boot側にもKeycloak Spring Boot Starterにより、簡単に実現することができました。また、複数システムのアカウントを、Keycloakで一括管理できるので運用効率もよいです。

次に、小規模システムの開発に従事することになったのですが、そうなると1つのシステムのためだけにKeycloakを使うのはサーバの月額費用にインパクトがあります。そこでSpring SecurityでWebアプリケーション単独の認証を検討しました。

* ２０２２/9/16追記
下記で使用しているSaveContextOnUpdateOrErrorResponseWrapperが Spring Security 5.7で@Deprecatedになってしまいました。ただ参考にしたHttpSessionSecurityContextRepositoryでも使われているので、そちらの動向に追従したいと思います。


# 要件
もとめる認証は以下の要件としました。

1. 認証トークンをCookieに格納する。
    * GraphQLの実装サンプルで見かけるのは認証トークンをLocal Strageに格納するものばかりでした。しかし、調べた範囲ではLocal Strageの利用には否定な見解が多く、Cookieも万全ではないとはいえHttpOnly属性やSameSite属性などで、Local Strageよりは安全なようです。(←言い切る自信はまだない)
    * GraphQLでファイルをダウンロードさせることもできますが、ボタン操作をトリガーに行うのであれば、ボタンを単にファイルへのリンクにしてダウンロードさせた方が実装もシンプルです。その場合、認証トークンをCookieに格納していれば、認証も自動的に解決します。
    * Servletのセッションは使わないので、APサーバに(JSESSIONIDのような)Cookieを生成させるのではなく、自前でCookieを管理する。

1. ログインをGraphQLで行う。
    * 当然の話しではあるのですが、これの実現がSpring Security + GraphQL Java Kickstartでは大変でした。
    * GraphQL Java Kickstartは、Servlet標準の非同期処理の中でGraphQLResolver実装クラスを呼び出します。そうなるとSpring Securityの各種フィルターの処理が終わった後に、ログイン処理が行われたスレッドが実行されるので、認証の結果からCookieの生成や破棄をフィルターで実現する方法がなかなか判りませんでした。
        * もちろんGraphQLResolver実装クラスの中でCookieをゴニョゴニョする方法もあるのでしょうけど、CookieからSecurityContextを生成するのはフィルターなので、SecurityContextからCookieを生成するのもフィルターにしたくて。
    * 試行錯誤の結果、SaveContextOnUpdateOrErrorResponseWrapperを利用することで実現できました。

# 実装

主な処理はSecurityContextPersistenceFilterから呼び出されるSecurityContextRepositoryにまとめました。

```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import org.springframework.context.ApplicationContext
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
import org.springframework.security.authentication.AuthenticationTrustResolver
import org.springframework.security.authentication.AuthenticationTrustResolverImpl
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.crypto.codec.Hex
import org.springframework.security.crypto.encrypt.Encryptors
import org.springframework.security.crypto.encrypt.TextEncryptor
import org.springframework.security.web.context.HttpRequestResponseHolder
import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper
import org.springframework.security.web.context.SecurityContextRepository
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import javax.servlet.http.Cookie
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@ConfigurationProperties(prefix = "app.token")
@ConstructorBinding
data class CookieSecurityContextProperties(
  val secret: String = "secret123",
  val salt: String = "salt123",
  val durationMinutes: Long = 30L,
  val refreshMinutes: Long = 5L
)

@Component
class CookieSecurityContextRepository(
  private val objectMapper: ObjectMapper,
  private val userDetailsService: UserDetailsService,
  private val properties: CookieSecurityContextProperties,
  applicationContext: ApplicationContext
) : SecurityContextRepository {

  data class CachePayload(val account: UserDetails, val expire: LocalDateTime) {
    fun toCookiePayload(): CookiePayload {
      return CookiePayload(account.username, expire)
    }
  }

  data class CookiePayload(val username: String, val expire: LocalDateTime)

  companion object {
    val NO_UPDATE_COOKIE = "${CookieSecurityContextRepository::class.java.simpleName}_NO_UPDATE_COOKIE"
    private const val SAME_SITE_STRICT = "Strict"
    private val log = LoggerFactory.getLogger(CookieSecurityContextRepository::class.java)
  }

  val cookieName = "${applicationContext.id}_SESSION"
  private val usernameToAccountCache = ConcurrentHashMap<String, CachePayload>()
  private val salt = Hex.encode(properties.salt.toByteArray()).concatToString()
  private val encryptor: TextEncryptor = Encryptors.delux(properties.secret, salt)
  private val trustResolver: AuthenticationTrustResolver = AuthenticationTrustResolverImpl()

  override fun loadContext(requestResponseHolder: HttpRequestResponseHolder): SecurityContext {
    val request = requestResponseHolder.request
    val response = requestResponseHolder.response

    val context = SecurityContextHolder.createEmptyContext()
    var usernameBeforeExecution: String? = null
    val cookie = obtainCookieFrom(request)
    if (cookie != null) {
      loadCookiePayload(cookie)
        ?.let { cookiePayload ->
          // キャッシュからアカウント情報を取得
          usernameToAccountCache[cookiePayload.username]
            ?: runCatching {
              // キャッシュになければDBからアカウント情報を取得
              userDetailsService.loadUserByUsername(cookiePayload.username)
            }.getOrNull()
              ?.let { CachePayload(it, cookiePayload.expire) }
              ?.also {
                // キャッシュにアカウント情報を取得
                usernameToAccountCache[cookiePayload.username] = it
              }
        }
        ?.also {
          usernameBeforeExecution = it.account.username
          context.authentication =
            UsernamePasswordAuthenticationToken(it.account, StringUtils.EMPTY, it.account.authorities)
        }
    }

    val wrappedResponse = SaveToCookieResponseWrapper(request, response, usernameBeforeExecution)
    requestResponseHolder.response = wrappedResponse
    return context
  }

  override fun saveContext(context: SecurityContext, request: HttpServletRequest, response: HttpServletResponse) {
    // 何もしない。
    // SecurityContextの保存処理はSaveToCookieResponseWrapperの方で行う。
  }

  override fun containsContext(request: HttpServletRequest): Boolean {
    return obtainCookieFrom(request) != null
  }

  private fun obtainCookieFrom(request: HttpServletRequest): Cookie? {
    return request.cookies
      ?.firstOrNull { it.name == cookieName }
  }

  private fun loadCookiePayload(encryptedCookie: Cookie): CookiePayload? {
    return try {
      encryptedCookie.value
        .ifEmpty { null }
        ?.let { encryptor.decrypt(it) }
        ?.let { objectMapper.readValue(it, CookiePayload::class.java) }
        ?.let {
          if (LocalDateTime.now() < it.expire) {
            it
          } else {
            log.debug("Cookieは期限切れです。")
            usernameToAccountCache.remove(it.username)
            null
          }
        }
    } catch (ex: Exception) {
      log.warn("Cookieからの取得に失敗しました。", ex)
      null
    }
  }

  inner class SaveToCookieResponseWrapper(
    private val request: HttpServletRequest,
    response: HttpServletResponse,
    private val usernameBeforeExecution: String?
  ) : SaveContextOnUpdateOrErrorResponseWrapper(response, false) {

    override fun getResponse(): HttpServletResponse {
      return super.getResponse() as HttpServletResponse
    }

    override fun saveContext(context: SecurityContext) {
      if (request.getAttribute(NO_UPDATE_COOKIE) == true) {
        return
      }

      // 認証情報なし
      val authentication = context.authentication
      val account = authentication?.principal as? UserDetails
      if (trustResolver.isAnonymous(authentication)
        || (account == null)
      ) {
        // Cookieは存在していた
        if (containsContext(request)) {
          // Cookieを削除
          val cookie = Cookie(cookieName, StringUtils.EMPTY)
          cookie.maxAge = 0
          response.addCookie(cookie)
        }
        // キャッシュを削除
        usernameBeforeExecution
          ?.also { usernameToAccountCache.remove(it) }
        return
      } else {
        val now = LocalDateTime.now()

        // 期限切れのキャッシュを削除する
        usernameToAccountCache.entries.removeIf { (_, cache) -> cache.expire < now }

        // 更新時期になってない
        var cachePayload = usernameToAccountCache[account.username]
        if ((cachePayload != null)
          && (now.plusMinutes(properties.durationMinutes - properties.refreshMinutes) < cachePayload.expire)) {
          // 何もしない
          return
        }

        cachePayload = CachePayload(account, now.plusMinutes(properties.durationMinutes))
        usernameToAccountCache[account.username] = cachePayload
        cachePayload.toCookiePayload()
          .let { objectMapper.writeValueAsString(it) }
          .let { encryptor.encrypt(it) }
          .also {
            val cookie = ResponseCookie.from(cookieName, it.orEmpty())
              .secure(true).httpOnly(true).sameSite(SAME_SITE_STRICT)
              .build()
            response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString())
          }
          ?: run {
            // キャッシュから削除
            usernameToAccountCache.remove(account.username)
          }
      }
    }
  }
}
```

* CookieSecurityContextRepositoryのloadContextで、CookieからSecurityContextを生成します。
    * Cookie中にはユーザ名を格納しているので、UserDetailsServiceを使ってアカウントの情報を取得しています。UserDetailsServiceではDBからアカウントの情報を取得することが多いので、処理コストを考えてキャッシュを用意しています。
    * HttpServletResponseオブジェクトとして、SaveToCookieResponseWrapperを挟み込みます。
* SaveToCookieResponseWrapperはSaveContextOnUpdateOrErrorResponseWrapperを継承していて、GraphQLの処理が完了するとsaveContextが呼び出され、Cookieの生成または破棄を行います。


## Spring Securityの設定

```kotlin
import org.springframework.context.annotation.Bean
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.context.SecurityContextRepository

@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration: WebSecurityConfigurerAdapter() {
  override fun configure(http: HttpSecurity) {
    http.setSharedObject(
      SecurityContextRepository::class.java,
      applicationContext.getBean(SecurityContextRepository::class.java)
    )

    http.csrf().disable()

    http.sessionManagement().disable()

    http.authorizeRequests().antMatchers("/api").permitAll()

    http.logout().disable()
  }

  @Bean
  override fun authenticationManagerBean(): AuthenticationManager {
    return super.authenticationManagerBean()
  }

  @Bean
  fun passwordEncoder(): PasswordEncoder {
    return BCryptPasswordEncoder()
  }
}
```

* CookieSecurityContextRepositoryをapplicationContextから取得して、SharedObjectとして登録すると、デフォルトであるHttpSessionSecurityContextRepositoryを置き換えることができます。
* 不要なフィルターを無効化しています。
* AuthenticationManagerを@Beanにしているのは、GraphQLResolver実装クラスで使用するためです。


## GraphQLResolver実装クラスの例

```kotlin
import graphql.kickstart.servlet.context.GraphQLServletContext
import graphql.kickstart.tools.GraphQLMutationResolver
import graphql.kickstart.tools.GraphQLQueryResolver
import graphql.schema.DataFetchingEnvironment
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Controller

@Controller
class AuthenticateController(private val authenticationManager: AuthenticationManager
) : GraphQLQueryResolver, GraphQLMutationResolver {

  fun login(mail: String, password: String): UserDetails? {
    val authentication = authenticationManager.authenticate(UsernamePasswordAuthenticationToken(username, password))
    val securityContext = SecurityContextHolder.createEmptyContext()
    securityContext.authentication = authentication
    SecurityContextHolder.setContext(securityContext)
    return (authentication.principal as? User)
  }

  fun logout(): Boolean {
    SecurityContextHolder.clearContext()
    return true
  }

  fun ping(environment: DataFetchingEnvironment): UserDetails? {
    val servletContext = environment.getContext() as GraphQLServletContext
    servletContext.request.setAttribute(CookieSecurityContextRepository.NO_UPDATE_COOKIE, true)
    authenticationService.ping(servletContext.httpServletRequest, servletContext.httpServletResponse)
    return SecurityContextHolder.getContext().authentication.principal as? User
  }
}
```

* スキーマは記載しませんが、loginとlogoutをmutationで、pingはqueryで呼び出します。
* loginでSecurityContextをSecurityContextHolderに格納することでSaveToCookieResponseWrapperがCookieを生成し、logoutでSecurityContextをクリアにすることでCookieが破棄されます。
* pingは、フロントエンドの初期状態と一定周期にCookieが有効(つまりログイン中)かを確認するために使用します。フラグを立てることでCookieの有効期限を更新しないようにして、使用していなければログアウトするようにしています。


# 所管
上記のコードに辿り着くまでに、いろいろな変遷があり、これも不十分な部分もしくは致命的欠陥があるかもしれません。
もし、お気づきなことがありましたら、ご指摘いただけると幸いです。