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 extends GrantedAuthority> 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() {