SecurityConfiguration.java
package jasper.config;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jasper.component.ConfigCache;
import jasper.security.jwt.JWTConfigurer;
import jasper.security.jwt.TokenProvider;
import jasper.security.jwt.TokenProviderImplDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
import org.springframework.web.context.annotation.ApplicationScope;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.zalando.problem.spring.web.advice.security.SecurityProblemSupport;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import static jasper.security.AuthoritiesConstants.ADMIN;
import static jasper.security.AuthoritiesConstants.ANONYMOUS;
import static jasper.security.AuthoritiesConstants.EDITOR;
import static jasper.security.AuthoritiesConstants.MOD;
import static jasper.security.AuthoritiesConstants.USER;
import static jasper.security.AuthoritiesConstants.VIEWER;
import static org.apache.commons.lang3.ArrayUtils.isEmpty;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl.fromHierarchy;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@Import(SecurityProblemSupport.class)
public class SecurityConfiguration {
private final Logger logger = LoggerFactory.getLogger(SecurityConfiguration.class);
@Autowired
Props props;
@Autowired
ConfigCache configs;
@Autowired
SecurityProblemSupport problemSupport;
@Autowired
TokenProvider tokenProvider;
@Autowired
TokenProviderImplDefault defaultTokenProvider;
@Value("${spring.profiles.active}")
String[] profiles;
@PostConstruct
void init() {
var unsafeSecret = profile("dev") && isNotBlank(props.getOverride().getSecurity().getSecret());
if (!props.isDebug()) props.setDebug(unsafeSecret);
if (props.isDebug()) {
logger.warn("==================================================");
logger.warn("==================================================");
logger.warn("DEBUG MODE");
logger.warn("==================================================");
logger.warn("==================================================");
}
logger.info("LOCAL ORIGIN: {}", props.getLocalOrigin());
logger.info("WORKER ORIGIN: {}", props.getWorkerOrigin());
logger.info("DEFAULT ROLE: {}", props.getDefaultRole());
logger.info("DEFAULT READ ACCESS: {}", isEmpty(props.getDefaultReadAccess()) ? "" : String.join(", ", props.getDefaultReadAccess()));
logger.info("DEFAULT WRITE ACCESS: {}", isEmpty(props.getDefaultWriteAccess()) ? "" : String.join(", ", props.getDefaultWriteAccess()));
logger.info("DEFAULT TAG READ ACCESS: {}", isEmpty(props.getDefaultTagReadAccess()) ? "" : String.join(", ", props.getDefaultTagReadAccess()));
logger.info("DEFAULT TAG WRITE ACCESS: {}", isEmpty(props.getDefaultTagWriteAccess()) ? "" : String.join(", ", props.getDefaultTagWriteAccess()));
logger.info("MAX ROLE: {}", props.getMaxRole());
logger.info("MIN ROLE: {}", props.getMinRole());
logger.info("MIN WRITE ROLE: {}", props.getMinWriteRole());
logger.info("MIN FETCH ROLE: {}", props.getMinFetchRole());
logger.info("MIN CONFIG ROLE: {}", props.getMinConfigRole());
logger.info("MIN READ BACKUPS ROLE: {}", props.getMinReadBackupsRole());
logger.info("AUTH HEADERS: {}", props.isAllowAuthHeaders() ? "ENABLED" : "-");
logger.info("USER HEADERS: {}", props.isAllowUserTagHeader() ? "ENABLED" : "-");
logger.info("ROLE HEADERS: {}", props.isAllowUserRoleHeader() ? "ENABLED" : "-");
}
private boolean profile(String profile) {
return Arrays.asList(profiles).contains(profile);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.exceptionHandling(e -> e
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
)
.headers(h -> h
.contentSecurityPolicy(csp -> csp
.policyDirectives(props.getSecurity().getContentSecurityPolicy()))
.referrerPolicy(r -> r.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
.permissionsPolicyHeader(p -> p.policy("camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()"))
)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.with(securityConfigurerAdapter(), Customizer.withDefaults())
.authorizeHttpRequests(r -> r
.requestMatchers("/api/**").permitAll()
.requestMatchers("/pub/api/**").permitAll()
.requestMatchers("/management/**").permitAll()
)
.csrf(c -> c
.csrfTokenRequestHandler(csrfRequestHandler())
.csrfTokenRepository(csrfTokenRepository())
.ignoringRequestMatchers("/pub/api/**") // Public API
)
; // @formatter:on
return http.build();
}
@Bean
public AuthenticationManager noopAuthenticationManager() {
return authentication -> {
throw new AuthenticationServiceException("AuthenticationManager is disabled");
};
}
@Bean
JWTConfigurer securityConfigurerAdapter() {
return new JWTConfigurer(props, tokenProvider, defaultTokenProvider, configs);
}
@Bean
CsrfTokenRepository csrfTokenRepository() {
var r = CookieCsrfTokenRepository.withHttpOnlyFalse();
r.setCookieCustomizer(c -> c
.secure(false) // Required when using SSL terminating gateway
.build());
return r;
}
@Bean
CsrfTokenRequestAttributeHandler csrfRequestHandler() {
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
// TODO: CSRF BREACH: https://docs.spring.io/spring-security/reference/5.8/migration/servlet/exploits.html
// Opt out of deferred csrf token loading
requestHandler.setCsrfRequestAttributeName(null);
return requestHandler;
}
@Bean
@ApplicationScope
public RoleHierarchy roleHierarchy() {
return fromHierarchy(String.join("\n", List.of(
ADMIN + " > " + MOD,
MOD + " > " + EDITOR,
EDITOR + " > " + USER,
USER + " > " + VIEWER,
VIEWER + " > " + ANONYMOUS
)));
}
@Bean
public DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler() {
var expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy());
return expressionHandler;
}
@Bean
public Filter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, IOException {
if (!shouldUseEtag(request, response)) {
ShallowEtagHeaderFilter.disableContentCaching(request);
filterChain.doFilter(request, response);
} else {
super.doFilterInternal(request, response, filterChain);
}
}
@Override
protected boolean isEligibleForEtag(
HttpServletRequest request,
HttpServletResponse response,
int responseStatusCode,
InputStream inputStream
) {
if (!shouldUseEtag(request,response)) return false;
return super.isEligibleForEtag(request, response, responseStatusCode, inputStream);
}
private boolean shouldUseEtag(HttpServletRequest request,
HttpServletResponse response){
return response.containsHeader(HttpHeaders.CONTENT_DISPOSITION);
}
};
}
}