From a17d50b9a66153c8582a93e76fd1c1764d46aa34 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 7 Nov 2025 10:48:03 +0100 Subject: [PATCH] Added caching for user authorities and refactored filters: - Introduced `UserAuthorityCacheService` with support for caching user authorities using Caffeine. - Added `CacheConfig` with Caffeine configuration for authority caching. - Implemented `AuthorityRefreshFilter` to dynamically refresh user roles in the security context. - Refactored filter package structure by moving existing filters to `config.filter`. - Updated `SecurityConfig` to integrate the new `AuthorityRefreshFilter`. - Modified `UserService` to invalidate user authority cache upon user updates. --- pom.xml | 6 + .../de/avatic/lcc/config/CacheConfig.java | 26 ++++ .../de/avatic/lcc/config/SecurityConfig.java | 6 +- .../config/filter/AuthorityRefreshFilter.java | 111 ++++++++++++++++++ .../{ => filter}/DevUserEmulationFilter.java | 3 +- .../{ => filter}/SelfIssuedJwtFilter.java | 2 +- .../lcc/controller/dev/DevController.java | 2 +- .../users/UserAuthorityCacheService.java | 51 ++++++++ .../avatic/lcc/service/users/UserService.java | 7 +- 9 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 src/main/java/de/avatic/lcc/config/CacheConfig.java create mode 100644 src/main/java/de/avatic/lcc/config/filter/AuthorityRefreshFilter.java rename src/main/java/de/avatic/lcc/config/{ => filter}/DevUserEmulationFilter.java (98%) rename src/main/java/de/avatic/lcc/config/{ => filter}/SelfIssuedJwtFilter.java (98%) create mode 100644 src/main/java/de/avatic/lcc/service/users/UserAuthorityCacheService.java diff --git a/pom.xml b/pom.xml index 9bd06f4..8f633db 100644 --- a/pom.xml +++ b/pom.xml @@ -177,6 +177,12 @@ 4.0.2 + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + diff --git a/src/main/java/de/avatic/lcc/config/CacheConfig.java b/src/main/java/de/avatic/lcc/config/CacheConfig.java new file mode 100644 index 0000000..e48667e --- /dev/null +++ b/src/main/java/de/avatic/lcc/config/CacheConfig.java @@ -0,0 +1,26 @@ +package de.avatic.lcc.config; + + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager("userAuthorities"); + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .maximumSize(1000) + .recordStats()); + return cacheManager; + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/lcc/config/SecurityConfig.java b/src/main/java/de/avatic/lcc/config/SecurityConfig.java index 0d2dc86..47b25dd 100644 --- a/src/main/java/de/avatic/lcc/config/SecurityConfig.java +++ b/src/main/java/de/avatic/lcc/config/SecurityConfig.java @@ -1,5 +1,8 @@ package de.avatic.lcc.config; +import de.avatic.lcc.config.filter.AuthorityRefreshFilter; +import de.avatic.lcc.config.filter.DevUserEmulationFilter; +import de.avatic.lcc.config.filter.SelfIssuedJwtFilter; import de.avatic.lcc.model.db.users.User; import de.avatic.lcc.repositories.users.GroupRepository; import de.avatic.lcc.repositories.users.UserRepository; @@ -77,7 +80,7 @@ public class SecurityConfig { @Bean @Profile("!dev & !test") // Only active when NOT in dev profile - public SecurityFilterChain prodSecurityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception { + public SecurityFilterChain prodSecurityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService, AuthorityRefreshFilter authorityRefreshFilter) throws Exception { http .cors(cors -> cors.configurationSource(prodCorsConfigurationSource())) // Production CORS .authorizeHttpRequests(auth -> auth @@ -123,6 +126,7 @@ public class SecurityConfig { .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new LccCsrfTokenRequestHandler()) ) + .addFilterAfter(authorityRefreshFilter, BearerTokenAuthenticationFilter.class) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) .addFilterBefore( new SelfIssuedJwtFilter(jwtTokenService), diff --git a/src/main/java/de/avatic/lcc/config/filter/AuthorityRefreshFilter.java b/src/main/java/de/avatic/lcc/config/filter/AuthorityRefreshFilter.java new file mode 100644 index 0000000..514bc58 --- /dev/null +++ b/src/main/java/de/avatic/lcc/config/filter/AuthorityRefreshFilter.java @@ -0,0 +1,111 @@ +package de.avatic.lcc.config.filter; + +import de.avatic.lcc.config.LccOidcUser; +import de.avatic.lcc.service.users.UserAuthorityCacheService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@Profile("!dev & !test") +public class AuthorityRefreshFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(AuthorityRefreshFilter.class); + private static final String AUTHORITIES_CHECKED_ATTR = "authorities_checked"; + + private final UserAuthorityCacheService userAuthorityService; + + public AuthorityRefreshFilter(UserAuthorityCacheService userAuthorityService) { + this.userAuthorityService = userAuthorityService; + } + + @Override + protected void doFilterInternal(@NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain) throws ServletException, IOException { + + if (request.getAttribute(AUTHORITIES_CHECKED_ATTR) == null) { + refreshAuthorities(); + request.setAttribute(AUTHORITIES_CHECKED_ATTR, true); + } + + filterChain.doFilter(request, response); + } + + private void refreshAuthorities() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth != null && auth.getPrincipal() instanceof LccOidcUser oidcUser) { + Integer userId = oidcUser.getSqlUserId(); + + if (userId != null) { + Set cachedAuthorities = userAuthorityService.getUserAuthorities(userId); + + if (!authoritiesEqual(cachedAuthorities, oidcUser.getAuthorities())) { + log.debug("Authorities changed for user {}, updating SecurityContext", userId); + updateSecurityContext(auth, oidcUser, cachedAuthorities); + } + } + } + } + + private boolean authoritiesEqual(Set cached, + java.util.Collection current) { + Set cachedRoles = cached.stream() + .map(GrantedAuthority::getAuthority) + .filter(auth -> auth.startsWith("ROLE_")) + .collect(Collectors.toSet()); + + Set currentRoles = current.stream() + .map(GrantedAuthority::getAuthority) + .filter(auth -> auth.startsWith("ROLE_")) + .collect(Collectors.toSet()); + + return cachedRoles.equals(currentRoles); + } + + private void updateSecurityContext(Authentication auth, LccOidcUser oidcUser, + Set newRoleAuthorities) { + Set updatedAuthorities = new HashSet<>(); + + oidcUser.getAuthorities().stream() + .filter(authority -> authority.getAuthority().startsWith("SCOPE_")) + .forEach(updatedAuthorities::add); + + updatedAuthorities.addAll(newRoleAuthorities); + + LccOidcUser updatedUser = new LccOidcUser( + updatedAuthorities, + oidcUser.getIdToken(), + oidcUser.getUserInfo(), + "preferred_username", + oidcUser.getSqlUserId() + ); + + if (auth instanceof OAuth2AuthenticationToken oauthToken) { + Authentication newAuth = new OAuth2AuthenticationToken( + updatedUser, + updatedAuthorities, + oauthToken.getAuthorizedClientRegistrationId() + ); + + SecurityContextHolder.getContext().setAuthentication(newAuth); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/lcc/config/DevUserEmulationFilter.java b/src/main/java/de/avatic/lcc/config/filter/DevUserEmulationFilter.java similarity index 98% rename from src/main/java/de/avatic/lcc/config/DevUserEmulationFilter.java rename to src/main/java/de/avatic/lcc/config/filter/DevUserEmulationFilter.java index 97f7771..c203e9c 100644 --- a/src/main/java/de/avatic/lcc/config/DevUserEmulationFilter.java +++ b/src/main/java/de/avatic/lcc/config/filter/DevUserEmulationFilter.java @@ -1,6 +1,7 @@ -package de.avatic.lcc.config; +package de.avatic.lcc.config.filter; +import de.avatic.lcc.config.LccOidcUser; import de.avatic.lcc.model.db.users.User; import de.avatic.lcc.repositories.users.UserRepository; import jakarta.servlet.FilterChain; diff --git a/src/main/java/de/avatic/lcc/config/SelfIssuedJwtFilter.java b/src/main/java/de/avatic/lcc/config/filter/SelfIssuedJwtFilter.java similarity index 98% rename from src/main/java/de/avatic/lcc/config/SelfIssuedJwtFilter.java rename to src/main/java/de/avatic/lcc/config/filter/SelfIssuedJwtFilter.java index 749d9b0..5ffbb77 100644 --- a/src/main/java/de/avatic/lcc/config/SelfIssuedJwtFilter.java +++ b/src/main/java/de/avatic/lcc/config/filter/SelfIssuedJwtFilter.java @@ -1,4 +1,4 @@ -package de.avatic.lcc.config; +package de.avatic.lcc.config.filter; import de.avatic.lcc.service.apps.JwtTokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/de/avatic/lcc/controller/dev/DevController.java b/src/main/java/de/avatic/lcc/controller/dev/DevController.java index fe699ea..5fdf4d2 100644 --- a/src/main/java/de/avatic/lcc/controller/dev/DevController.java +++ b/src/main/java/de/avatic/lcc/controller/dev/DevController.java @@ -1,6 +1,6 @@ package de.avatic.lcc.controller.dev; -import de.avatic.lcc.config.DevUserEmulationFilter; +import de.avatic.lcc.config.filter.DevUserEmulationFilter; import de.avatic.lcc.dto.error.CalculationJobDumpDTO; import de.avatic.lcc.dto.users.UserDTO; import de.avatic.lcc.repositories.error.DumpRepository; diff --git a/src/main/java/de/avatic/lcc/service/users/UserAuthorityCacheService.java b/src/main/java/de/avatic/lcc/service/users/UserAuthorityCacheService.java new file mode 100644 index 0000000..0ba7f1e --- /dev/null +++ b/src/main/java/de/avatic/lcc/service/users/UserAuthorityCacheService.java @@ -0,0 +1,51 @@ +package de.avatic.lcc.service.users; + + +import de.avatic.lcc.model.db.users.User; +import de.avatic.lcc.repositories.users.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Set; + +@Service +public class UserAuthorityCacheService { + + private static final Logger log = LoggerFactory.getLogger(UserAuthorityCacheService.class); + private final UserRepository userRepository; + + public UserAuthorityCacheService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Cacheable(value = "userAuthorities", key = "#userId") + public Set getUserAuthorities(Integer userId) { + log.debug("Loading authorities from database for user {}", userId); + User user = userRepository.getById(userId); + Set authorities = new HashSet<>(); + + if (user != null) { + user.getGroups().forEach(group -> + authorities.add(new SimpleGrantedAuthority("ROLE_" + group.getName().toUpperCase())) + ); + } + + return authorities; + } + + @CacheEvict(value = "userAuthorities", key = "#userId") + public void invalidateUserAuthorities(Integer userId) { + log.debug("Invalidating authority cache for user {}", userId); + } + + @CacheEvict(value = "userAuthorities", allEntries = true) + public void invalidateAllAuthorities() { + log.debug("Invalidating entire authority cache"); + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/lcc/service/users/UserService.java b/src/main/java/de/avatic/lcc/service/users/UserService.java index 002a04f..0941157 100644 --- a/src/main/java/de/avatic/lcc/service/users/UserService.java +++ b/src/main/java/de/avatic/lcc/service/users/UserService.java @@ -19,10 +19,12 @@ public class UserService { private final UserRepository userRepository; private final UserTransformer userTransformer; + private final UserAuthorityCacheService userAuthorityCacheService; - public UserService(UserRepository userRepository, UserTransformer userTransformer) { + public UserService(UserRepository userRepository, UserTransformer userTransformer, UserAuthorityCacheService userAuthorityCacheService) { this.userRepository = userRepository; this.userTransformer = userTransformer; + this.userAuthorityCacheService = userAuthorityCacheService; } /** @@ -42,7 +44,8 @@ public class UserService { * @param user the UserDTO containing updated user information */ public void updateUser(UserDTO user) { - userRepository.update(userTransformer.fromUserDTO(user)); + var userId = userRepository.update(userTransformer.fromUserDTO(user)); + userAuthorityCacheService.invalidateUserAuthorities(userId); } public boolean isSuper() {