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 org.tailormap.api.security.TailormapOidcUser;
24 import org.tailormap.api.security.TailormapOidcUserMixin;
25 import org.tailormap.api.security.TailormapUserDetailsImpl;
26 import org.tailormap.api.security.TailormapUserDetailsImplMixin;
27 import tools.jackson.core.JacksonException;
28 import tools.jackson.core.StreamReadFeature;
29 import tools.jackson.databind.DefaultTyping;
30 import tools.jackson.databind.SerializationFeature;
31 import tools.jackson.databind.json.JsonMapper;
32 import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
33
34 @Configuration(proxyBeanMethods = false)
35 public class JdbcSessionConfiguration implements BeanClassLoaderAware {
36 private static final Logger logger =
37 LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
38
39 private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
40 INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
41 VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
42 ON CONFLICT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME)
43 DO UPDATE SET ATTRIBUTE_BYTES = EXCLUDED.ATTRIBUTE_BYTES
44 """;
45
46 private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
47 UPDATE %TABLE_NAME%_ATTRIBUTES
48 SET ATTRIBUTE_BYTES = convert_from(?, 'UTF8')::jsonb
49 WHERE SESSION_PRIMARY_ID = ?
50 AND ATTRIBUTE_NAME = ?
51 """;
52
53 private ClassLoader classLoader;
54
55 @Bean
56 SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
57 return (sessionRepository) -> {
58 sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
59 sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
60 };
61 }
62
63 @Bean("springSessionConversionService")
64 public ConversionService springSessionConversionService() {
65
66 BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
67 .allowIfSubType("org.tailormap.api.security.")
68 .allowIfSubType("org.springframework.security.")
69 .allowIfSubType("java.util.")
70 .allowIfSubType(Number.class)
71 .allowIfSubType("java.time.")
72 .allowIfBaseType(Object.class);
73
74 JsonMapper mapper = JsonMapper.builder()
75 .configure(
76 StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
77 (logger.isDebugEnabled() || logger.isTraceEnabled()))
78 .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
79
80
81
82 .addMixIn(TailormapUserDetailsImpl.class, TailormapUserDetailsImplMixin.class)
83 .addMixIn(TailormapOidcUser.class, 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.trace("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.trace(
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 }