1
2
3
4
5
6
7 package org.tailormap.api.configuration;
8
9 import com.fasterxml.jackson.annotation.JsonTypeInfo;
10 import java.lang.invoke.MethodHandles;
11 import java.nio.charset.StandardCharsets;
12 import org.jspecify.annotations.NonNull;
13 import org.slf4j.Logger;
14 import org.slf4j.LoggerFactory;
15 import org.springframework.beans.factory.BeanClassLoaderAware;
16 import org.springframework.context.annotation.Bean;
17 import org.springframework.context.annotation.Configuration;
18 import org.springframework.core.convert.ConversionService;
19 import org.springframework.core.convert.support.GenericConversionService;
20 import org.springframework.security.jackson.SecurityJacksonModules;
21 import org.springframework.session.config.SessionRepositoryCustomizer;
22 import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
23 import tools.jackson.core.JacksonException;
24 import tools.jackson.core.StreamReadFeature;
25 import tools.jackson.databind.DefaultTyping;
26 import tools.jackson.databind.SerializationFeature;
27 import tools.jackson.databind.json.JsonMapper;
28 import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
29
30 @Configuration(proxyBeanMethods = false)
31 public class JdbcSessionConfiguration implements BeanClassLoaderAware {
32 private static final Logger logger =
33 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
34
35 private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
36 INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
37 VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
38 ON CONFLICT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME)
39 DO UPDATE SET ATTRIBUTE_BYTES = EXCLUDED.ATTRIBUTE_BYTES
40 """;
41
42 private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
43 UPDATE %TABLE_NAME%_ATTRIBUTES
44 SET ATTRIBUTE_BYTES = convert_from(?, 'UTF8')::jsonb
45 WHERE SESSION_PRIMARY_ID = ?
46 AND ATTRIBUTE_NAME = ?
47 """;
48
49 private ClassLoader classLoader;
50
51 @Bean
52 SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
53 return (sessionRepository) -> {
54 sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
55 sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
56 };
57 }
58
59 @Bean("springSessionConversionService")
60 public ConversionService springSessionConversionService() {
61
62 BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
63 .allowIfSubType("org.tailormap.api.security.")
64 .allowIfSubType("org.springframework.security.")
65 .allowIfSubType("java.util.")
66 .allowIfSubType(java.lang.Number.class)
67 .allowIfSubType("java.time.")
68 .allowIfBaseType(Object.class);
69
70 JsonMapper mapper = JsonMapper.builder()
71 .configure(
72 StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
73 (logger.isDebugEnabled() || logger.isTraceEnabled()))
74 .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
75
76
77
78 .addMixIn(
79 org.tailormap.api.security.TailormapUserDetailsImpl.class,
80 org.tailormap.api.security.TailormapUserDetailsImplMixin.class)
81 .addMixIn(
82 org.tailormap.api.security.TailormapOidcUser.class,
83 org.tailormap.api.security.TailormapOidcUserMixin.class)
84 .addModules(SecurityJacksonModules.getModules(this.classLoader, builder))
85 .activateDefaultTyping(builder.build(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
86 .build();
87
88 final GenericConversionService converter = new GenericConversionService();
89
90 converter.addConverter(Object.class, byte[].class, source -> {
91 try {
92 logger.debug("Serializing Spring Session: {}", source);
93 return mapper.writerFor(Object.class).writeValueAsBytes(source);
94 } catch (JacksonException e) {
95 logger.error("Error serializing Spring Session object: {}", source, e);
96 throw new RuntimeException("Unable to serialize Spring Session.", e);
97 }
98 });
99
100 converter.addConverter(byte[].class, Object.class, source -> {
101 try {
102 logger.debug(
103 "Deserializing Spring Session from bytes, length: {} ({})",
104 source.length,
105 new String(source, StandardCharsets.UTF_8));
106 return mapper.readValue(source, Object.class);
107 } catch (JacksonException e) {
108 String preview;
109 try {
110 String content = new String(source, StandardCharsets.UTF_8);
111 int maxLength = 256;
112 preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
113 } catch (Exception ex) {
114 preview = "<unavailable>";
115 }
116 logger.error(
117 "Error deserializing Spring Session from bytes, length: {}, preview: {}",
118 source.length,
119 preview,
120 e);
121 throw new RuntimeException("Unable to deserialize Spring Session.", e);
122 }
123 });
124
125 return converter;
126 }
127
128 @Override
129 public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
130 this.classLoader = classLoader;
131 }
132 }