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でも使われているので、そちらの動向に追従したいと思います。
要件
もとめる認証は以下の要件としました。
-
認証トークンをCookieに格納する。
- GraphQLの実装サンプルで見かけるのは認証トークンをLocal Strageに格納するものばかりでした。しかし、調べた範囲ではLocal Strageの利用には否定な見解が多く、Cookieも万全ではないとはいえHttpOnly属性やSameSite属性などで、Local Strageよりは安全なようです。(←言い切る自信はまだない)
- GraphQLでファイルをダウンロードさせることもできますが、ボタン操作をトリガーに行うのであれば、ボタンを単にファイルへのリンクにしてダウンロードさせた方が実装もシンプルです。その場合、認証トークンをCookieに格納していれば、認証も自動的に解決します。
- Servletのセッションは使わないので、APサーバに(JSESSIONIDのような)Cookieを生成させるのではなく、自前でCookieを管理する。
-
ログインを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の有効期限を更新しないようにして、使用していなければログアウトするようにしています。
所管
上記のコードに辿り着くまでに、いろいろな変遷があり、これも不十分な部分もしくは致命的欠陥があるかもしれません。
もし、お気づきなことがありましたら、ご指摘いただけると幸いです。