在使用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でも使われているので、そちらの動向に追従したいと思います。
重要的事项
我们设定了以下的认证要求。
-
- 将认证令牌存储在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の有効期限を更新しないようにして、使用していなければログアウトするようにしています。
负责的领域
如果在达到上述代码之前,经历了许多变化,这也可能存在不足之处或者致命缺陷。
如果您有任何发现,请指出,我将不胜感激。