LoginSignup
0
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-30

Spring for GraphQL(名称がSrping GraphQLではないんですね。今気づいた。)も今年バージョン1.0がリリースされ、知らぬ間にNetflixから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アプリケーション単独の認証を検討しました。

  • 2022/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を管理する。
  2. ログインをGraphQLで行う。

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

実装

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

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の設定

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実装クラスの例

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の有効期限を更新しないようにして、使用していなければログアウトするようにしています。

所管

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0