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.
This commit is contained in:
Jan 2025-11-07 10:48:03 +01:00
parent 62e911caf5
commit a17d50b9a6
9 changed files with 208 additions and 6 deletions

View file

@ -177,6 +177,12 @@
<version>4.0.2</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>

View file

@ -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;
}
}

View file

@ -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),

View file

@ -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<GrantedAuthority> 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<GrantedAuthority> cached,
java.util.Collection<? extends GrantedAuthority> current) {
Set<String> cachedRoles = cached.stream()
.map(GrantedAuthority::getAuthority)
.filter(auth -> auth.startsWith("ROLE_"))
.collect(Collectors.toSet());
Set<String> 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<GrantedAuthority> newRoleAuthorities) {
Set<GrantedAuthority> 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);
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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<GrantedAuthority> getUserAuthorities(Integer userId) {
log.debug("Loading authorities from database for user {}", userId);
User user = userRepository.getById(userId);
Set<GrantedAuthority> 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");
}
}

View file

@ -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() {