しおしお

IntelliJ IDEAのことなんかを書いてます

Spring Session & RedisでJacksonを使ったシリアライズを試してみる

Spring SessionでRedisにセッション情報を格納する際に、Jacksonを使ってシリアライズをできるみたいなので試してみました。*1

ライブラリのバージョンなど

build.gradleを参照ください。

Sessionの格納先をRedisにする

application.propertiesに以下を追加します。

spring.session.store-type=redis

Jackson2JsonRedisSerializerをBean定義する

RedisHttpSessionConfigurationを見ると、こんな実装がるのでBean名にspringSessionDefaultRedisSerializerを指定してJacksonのシリアライザをBean定義すれば良さそうですね。

	@Autowired(required = false)
	@Qualifier("springSessionDefaultRedisSerializer")
	public void setDefaultRedisSerializer(
			RedisSerializer<Object> defaultRedisSerializer) {
		this.defaultRedisSerializer = defaultRedisSerializer;
	}

実際のBean定義です。Jacksonでシリアライザを行うJackson2JsonRedisSerializerを返します。
@Qualifierアノテーションを使ってBean名にspringSessionDefaultRedisSerializerを指定します。
JacksonのObjectMapperには、セッション情報をシリアライズする際に必要となるためSecurityJackson2Modulesを使って、必要なModuleを登録してあげます。

    @Bean
    @Qualifier("springSessionDefaultRedisSerializer")
    fun redisSerializer(): RedisSerializer<Any> {
        return Jackson2JsonRedisSerializer(Any::class.java).apply {
            val objectMapper = ObjectMapper()
            objectMapper.registerModules(SecurityJackson2Modules.getModules(this.javaClass.classLoader))
            setObjectMapper(objectMapper)
        }
    }

ログインを行いRedisの中身を確認する

ログインを行ってセッションの中身を確認してみると、デフォルトで使われるUserクラスの情報がRedisに登録されていることが確認できます。

127.0.0.1:6379> hgetall spring:session:sessions:6ff0be42-853f-4184-a874-1e4881a822f7
creationTime
1547242900218
sessionAttr:SPRING_SECURITY_SAVED_REQUEST

sessionAttr:org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN

maxInactiveInterval
1800
lastAccessedTime
1547242908403
sessionAttr:SPRING_SECURITY_LAST_EXCEPTION

sessionAttr:SPRING_SECURITY_CONTEXT
{"@class":"org.springframework.security.core.context.SecurityContextImpl","authentication":{"@class":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","authorities":["java.util.Collections$UnmodifiableRandomAccessList",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"details":{"@class":"org.springframework.security.web.authentication.WebAuthenticationDetails","remoteAddress":"0:0:0:0:0:0:0:1","sessionId":"272fd950-13c5-4d79-a3dc-54010be92ad7"},"authenticated":true,"principal":{"@class":"org.springframework.security.core.userdetails.User","password":null,"username":"test","authorities":["java.util.Collections$UnmodifiableSet",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"credentials":null}}

UserDetailsの実装クラスを変えてみる

デフォルトで使われるorg.springframework.security.core.userdetails.Userではなく、独自のUserDetails実装を使った場合の動きを確認してみます。

UserDetails実装はSampleUserとしてデフォルトの情報以外に、ログインしたユーザの名前を保持するようにしています。

data class SampleUser(private val username: String,
                      private val password: String,
                      private val authorities: List<GrantedAuthority>,
                      val loginUserName: String) : UserDetails {

    override fun getAuthorities(): List<GrantedAuthority> = authorities

    override fun isEnabled(): Boolean = true

    override fun getUsername(): String = username

    override fun isCredentialsNonExpired(): Boolean = true

    override fun getPassword(): String = password

    override fun isAccountNonExpired(): Boolean = true

    override fun isAccountNonLocked(): Boolean = true
}

独自Userでも動作するか確認してみます

残念ながらエラーになってしまいました。デシリアライズ処理ができなかったようです。

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: The class with siosio.springsessionredisexample.config.SampleUser and name of siosio.springsessionredisexample.config.SampleUser is not whitelisted. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details (through reference chain: org.springframework.security.core.context.SecurityContextImpl["authentication"]); nested exception is com.fasterxml.jackson.databind.JsonMappingException: The class with siosio.springsessionredisexample.config.SampleUser and name of siosio.springsessionredisexample.config.SampleUser is not whitelisted. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details (through reference chain: org.springframework.security.core.context.SecurityContextImpl["authentication"])
	at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.deserialize(Jackson2JsonRedisSerializer.java:75) ~[spring-data-redis-2.1.3.RELEASE.jar:2.1.3.RELEASE]
	at org.springframework.data.redis.core.AbstractOperations.deserializeHashValue(AbstractOperations.java:354) ~[spring-data-redis-2.1.3.RELEASE.jar:2.1.3.RELEASE]
	at org.springframework.data.redis.core.AbstractOperations.deserializeHashMap(AbstractOperations.java:298) ~[spring-data-redis-2.1.3.RELEASE.jar:2.1.3.RELEASE]
	at org.springframework.data.redis.core.DefaultHashOperations.entries(DefaultHashOperations.java:247) ~[spring-data-redis-2.1.3.RELEASE.jar:2.1.3.RELEASE]
	at org.springframework.data.redis.core.DefaultBoundHashOperations.entries(DefaultBoundHashOperations.java:183) ~[spring-data-redis-2.1.3.RELEASE.jar:2.1.3.RELEASE]
	at org.springframework.session.data.redis.RedisOperationsSessionRepository.getSession(RedisOperationsSessionRepository.java:466) ~[spring-session-data-redis-2.1.2.RELEASE.jar:2.1.2.RELEASE]
	at org.springframework.session.data.redis.RedisOperationsSessionRepository.findById(RedisOperationsSessionRepository.java:435) ~[spring-session-data-redis-2.1.2.RELEASE.jar:2.1.2.RELEASE]

なお、Redisの内容を確認してみると、想定通りSampleUserの情報が格納されているのでシリアライズは問題なさそうですね。

sessionAttr:SPRING_SECURITY_CONTEXT
{"@class":"org.springframework.security.core.context.SecurityContextImpl","authentication":{"@class":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","authorities":["java.util.Collections$UnmodifiableRandomAccessList",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"details":{"@class":"org.springframework.security.web.authentication.WebAuthenticationDetails","remoteAddress":"0:0:0:0:0:0:0:1","sessionId":"0f181292-9a16-4622-b820-83adcc8ae33c"},"authenticated":true,"principal":{"@class":"siosio.springsessionredisexample.config.SampleUser","username":"test","password":"$2a$10$AXwQIZKK8yaNk2tFN1JwaOCxcn7DYuCwK5d6wvY.tX.ZGQUWeA2sy","authorities":["java.util.Collections$SingletonList",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"loginUserName":"テストユーザ","enabled":true,"accounpired":true,"credentialsNonExpired":true,"accountNonLocked":true},"credentials":null}

シリアライズ処理がうまく動くようMixInを追加する

デフォルトのUserクラス用のMixIn(org.springframework.security.jackson2.UserMixin)を参考にしてデシリアライズ処理を行うクラスなどを追加します。
SampleUserMixinでは、デシリアライズを行うクラスの指定や、フィールドをシリアライズ対象にするなどの設定をしています。
シリアライズを行うSampleUserDeserializerは、SampleUserを生成して返します。

data class SampleUser(private val username: String,
                      private val password: String,
                      private val authorities: List<GrantedAuthority>,
                      val loginUserName: String) : UserDetails {

    override fun getAuthorities(): List<GrantedAuthority> = authorities

    override fun isEnabled(): Boolean = true

    override fun getUsername(): String = username

    override fun isCredentialsNonExpired(): Boolean = true

    override fun getPassword(): String = password

    override fun isAccountNonExpired(): Boolean = true

    override fun isAccountNonLocked(): Boolean = true
}

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
@JsonDeserialize(using = SampleUserDeserializer::class)
@JsonAutoDetect(
        fieldVisibility = JsonAutoDetect.Visibility.ANY,
        getterVisibility = JsonAutoDetect.Visibility.NONE,
        isGetterVisibility = JsonAutoDetect.Visibility.NONE,
        creatorVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSubTypes
internal abstract class SampleUserMixin

class SampleUserDeserializer : JsonDeserializer<SampleUser>() {
    override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): SampleUser {
        val mapper = jp.codec as ObjectMapper
        val jsonNode: JsonNode = mapper.readTree(jp)
        val authorities = mapper.convertValue<List<SimpleGrantedAuthority>>(jsonNode.get("authorities"))

        val password = jsonNode.readText("password")
        return SampleUser(
                jsonNode.readText("username"),
                password,
                authorities.toList(),
                jsonNode.readText("loginUserName")
        )
    }

    fun JsonNode.readText(field: String, defaultValue: String = ""): String {
        return when {
            has(field) -> get(field).asText(defaultValue)
            else -> defaultValue
        }
    }
}

最後に、JacksonのシリアライザのBean生成を行っているところで、作成したMixInをJacksonに登録します。

    @Bean
    @Qualifier("springSessionDefaultRedisSerializer")
    fun redisSerializer(): RedisSerializer<Any> {
        return Jackson2JsonRedisSerializer(Any::class.java).apply {
            val objectMapper = ObjectMapper()
            objectMapper.registerModules(SecurityJackson2Modules.getModules(this.javaClass.classLoader))
            objectMapper.addMixIn(SampleUser::class.java, SampleUserMixin::class.java)
            setObjectMapper(objectMapper)
        }
    }

MixInの確認

ログイン後に呼び出されるコントローラで下のようにUser情報を受けとり確認してみます。

    @GetMapping
    fun menu(@AuthenticationPrincipal user: SampleUser): String {
        println("user = ${user}")
        return "menu"
    }

ログインしてみると標準出力にユーザ情報が出力されるのでJacksonでのシリアライズ&デシリアライズが動いていることがわかります。
f:id:sioiri:20190112075143p:plain

最後に…

ソース全体はこちら→GitHub - siosio/spring-session-redis-example

*1:デフォルトでは、ObjectOutputStreamを使ったシリアライズが行われます。