在使用GraphQL Java Kickstart的Web系统中,将认证令牌存储在Cookie中

GraphQL在今年推出了1.0版本,Spring for GraphQL(并非名称为Srping GraphQL)和Netflix的DGS也在不知不觉中发布了。Java界的GraphQL框架也变得热闹起来。我个人主要使用Spring Boot进行应用程序开发,所以我认为将来Spring for GraphQL可能是首选,但目前我认为GraphQL Java Kickstart更加流行。

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

使用GraphQL Java Kickstart实现Web应用程序的认证时,最初是在一个资源充足的内部系统中使用Keycloak进行认证的。我们使用了Keycloak JS来供前端使用,并且使用Keycloak Spring Boot Starter在Spring Boot端简单地实现了认证功能。此外,由于可以通过Keycloak统一管理多个系统的账户,所以运维效率也很高。

接下来,我要参与一个小型系统的开发,但是如果只为一个系统使用Keycloak,对服务器的月费会有很大影响。因此,我考虑使用Spring Security来实现单独的Web应用程序身份验证。

    • 2022/9/16追記

 

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

重要的事项

我们设定了以下的认证要求。

    1. 将认证令牌存储在Cookie中。

在GraphQL的实现示例中,我只看到了将认证令牌存储在本地存储中的方法。然而,在我调查的范围内,对于本地存储的使用存在否定的观点,虽然Cookie也不是完美的,但使用HttpOnly属性和SameSite属性等,比本地存储更安全一些。(我还没有完全确定这一点)
虽然在GraphQL中也可以实现文件下载,但如果是通过按钮操作触发的话,把按钮简单地做成文件的链接并下载会更简单。在这种情况下,如果将认证令牌存储在Cookie中,认证也会自动解决。
由于不使用servlet会话,不需要生成类似JSESSIONID的Cookie,而是通过自己管理Cookie。

在GraphQL中进行登录。

当然,并不是说这是理所当然的,但使用Spring Security + GraphQL Java Kickstart实现这一点确实很困难。
GraphQL Java Kickstart在Servlet的标准异步处理中调用GraphQLResolver实现类。在这种情况下,Spring Security的各种过滤器处理完毕后,进行登录处理的线程将被执行,所以很难找到在身份验证结果中生成或删除Cookie的过滤器方法。

当然,在GraphQLResolver实现类中也可以进行Cookie的操作,但是生成SecurityContext从Cookie中生成的是过滤器,所以我想将从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実装クラスで使用するためです。

GraphQL解析器实现类的示例

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

负责的领域

如果在达到上述代码之前,经历了许多变化,这也可能存在不足之处或者致命缺陷。
如果您有任何发现,请指出,我将不胜感激。

广告
将在 10 秒后关闭
bannerAds