View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   
7   package org.tailormap.api.configuration;
8   
9   import com.fasterxml.jackson.annotation.JsonTypeInfo;
10  import com.fasterxml.jackson.core.StreamReadFeature;
11  import com.fasterxml.jackson.databind.ObjectMapper;
12  import com.fasterxml.jackson.databind.SerializationFeature;
13  import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
14  import java.io.IOException;
15  import java.lang.invoke.MethodHandles;
16  import java.nio.charset.StandardCharsets;
17  import org.jspecify.annotations.NonNull;
18  import org.slf4j.Logger;
19  import org.slf4j.LoggerFactory;
20  import org.springframework.beans.factory.BeanClassLoaderAware;
21  import org.springframework.context.annotation.Bean;
22  import org.springframework.context.annotation.Configuration;
23  import org.springframework.core.convert.ConversionService;
24  import org.springframework.core.convert.support.GenericConversionService;
25  import org.springframework.security.jackson2.SecurityJackson2Modules;
26  import org.springframework.session.config.SessionRepositoryCustomizer;
27  import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
28  
29  @Configuration(proxyBeanMethods = false)
30  public class JdbcSessionConfiguration implements BeanClassLoaderAware {
31    private static final Logger logger =
32        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
33  
34    private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
35  INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
36  VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
37  """;
38  
39    private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
40  UPDATE %TABLE_NAME%_ATTRIBUTES
41  SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb
42  WHERE SESSION_PRIMARY_ID = ?
43  AND ATTRIBUTE_NAME = ?
44  """;
45  
46    private ClassLoader classLoader;
47  
48    @Bean
49    SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
50      return (sessionRepository) -> {
51        sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
52        sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
53      };
54    }
55  
56    @Bean("springSessionConversionService")
57    public ConversionService springSessionConversionService(ObjectMapper objectMapper) {
58  
59      BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
60          .allowIfSubType("org.tailormap.api.security.")
61          .allowIfSubType("org.springframework.security.")
62          .allowIfSubType("java.util.")
63          .allowIfSubType(java.lang.Number.class)
64          .allowIfSubType("java.time.")
65          .allowIfBaseType(Object.class)
66          .build();
67  
68      ObjectMapper copy = objectMapper
69          .copy()
70          .configure(
71              StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION.mappedFeature(),
72              (logger.isDebugEnabled() || logger.isTraceEnabled()))
73          .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()));
74  
75      // register mixins early so Jackson picks up the @JsonCreator constructor for TailormapUserDetails
76      // implementations
77      copy.addMixIn(
78          org.tailormap.api.security.TailormapUserDetailsImpl.class,
79          org.tailormap.api.security.TailormapUserDetailsImplMixin.class);
80  
81      copy.addMixIn(
82          org.tailormap.api.security.TailormapOidcUser.class,
83          org.tailormap.api.security.TailormapOidcUserMixin.class);
84  
85      copy.registerModules(SecurityJackson2Modules.getModules(this.classLoader))
86          .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
87  
88      final GenericConversionService converter = new GenericConversionService();
89      // Object -> byte[] (serialize to JSON bytes)
90      converter.addConverter(Object.class, byte[].class, source -> {
91        try {
92          logger.debug("Serializing Spring Session: {}", source);
93          return copy.writerFor(Object.class).writeValueAsBytes(source);
94        } catch (IOException e) {
95          logger.error("Error serializing Spring Session object: {}", source, e);
96          throw new RuntimeException("Unable to serialize Spring Session.", e);
97        }
98      });
99      // byte[] -> Object (deserialize from JSON bytes)
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 copy.readValue(source, Object.class);
107       } catch (IOException 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 }