lcc_tool/src/main/java/de/avatic/lcc/config/SecurityConfig.java

355 lines
16 KiB
Java

package de.avatic.lcc.config;
import de.avatic.lcc.model.db.users.User;
import de.avatic.lcc.repositories.users.GroupRepository;
import de.avatic.lcc.repositories.users.UserRepository;
import de.avatic.lcc.service.apps.JwtTokenService;
import io.jsonwebtoken.Claims;
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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
@Value("${lcc.allowed_cors}")
private String allowedCors;
@Value("${lcc.allowed_oauth_token_cors:*}") // Default: alle Origins
private String oauthTokenCors;
@Bean
@Profile("!dev & !test") // Only active when NOT in dev profile
public SecurityFilterChain prodSecurityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception {
http
.cors(cors -> cors.configurationSource(prodCorsConfigurationSource())) // Production CORS
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/oauth2/token").permitAll()
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/dev/**").denyAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/", true)
)
.oauth2ResourceServer(oauth2 -> oauth2
.bearerTokenResolver(bearerTokenResolver(jwtTokenService))
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")
)
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/oauth2/token")
.ignoringRequestMatchers("/login/oauth2/code/**")
.requireCsrfProtectionMatcher(request -> {
String requestUri = request.getRequestURI();
if (requestUri.startsWith("/oauth2/") || requestUri.startsWith("/login/oauth2/")) {
return false;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return false;
}
return !"GET".equalsIgnoreCase(request.getMethod());
})
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
)
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.addFilterBefore(
new SelfIssuedJwtFilter(jwtTokenService),
BearerTokenAuthenticationFilter.class
);
return http.build();
}
@Bean
@Profile("!dev & !test")
public CorsConfigurationSource prodCorsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration tokenConfiguration = new CorsConfiguration();
if ("*".equals(oauthTokenCors)) {
tokenConfiguration.setAllowedOriginPatterns(List.of("*"));
} else {
String[] tokenOrigins = oauthTokenCors.split(",");
for (int i = 0; i < tokenOrigins.length; i++) {
tokenOrigins[i] = tokenOrigins[i].trim();
}
if (tokenOrigins.length != 0) {
tokenConfiguration.setAllowedOrigins(Arrays.asList(tokenOrigins));
}
}
tokenConfiguration.setAllowedMethods(Arrays.asList("POST", "OPTIONS"));
tokenConfiguration.setAllowedHeaders(List.of("*"));
tokenConfiguration.setAllowCredentials(true);
tokenConfiguration.setMaxAge(3600L);
source.registerCorsConfiguration("/oauth2/token", tokenConfiguration);
CorsConfiguration configuration = new CorsConfiguration();
if ("*".equals(allowedCors)) {
configuration.setAllowedOriginPatterns(List.of("*"));
} else {
String[] origins = allowedCors.split(",");
for (int i = 0; i < origins.length; i++) {
origins[i] = origins[i].trim();
}
if (origins.length != 0) {
configuration.setAllowedOrigins(Arrays.asList(origins));
}
}
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Arrays.asList("X-Total-Count", "X-Page-Count", "X-Current-Page"));
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
@Profile("dev | test")
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http, UserRepository userRepository, JwtTokenService jwtTokenService) throws Exception {
return http
.cors(cors -> cors.configurationSource(devCorsConfigurationSource())) // Dev CORS
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/oauth2/token").permitAll()
.requestMatchers("/api/**").permitAll()
.requestMatchers("/api/dev/**").permitAll()
.anyRequest().permitAll())
.csrf(csrf -> csrf
.ignoringRequestMatchers("/oauth2/token") // CSRF für OAuth deaktivieren
.ignoringRequestMatchers("/api/**") // Optional: für alle API-Endpunkte
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
)
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.addFilterBefore(new DevUserEmulationFilter(userRepository), BasicAuthenticationFilter.class)
.addFilterBefore(
new SelfIssuedJwtFilter(jwtTokenService),
BearerTokenAuthenticationFilter.class
)
.build();
}
@Bean
@Profile("dev | test")
public CorsConfigurationSource devCorsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("http://localhost:*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Arrays.asList("X-Total-Count", "X-Page-Count", "X-Current-Page"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
// Für Entra ID Tokens
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("scp");
converter.setAuthorityPrefix("SCOPE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Profile("!dev & !test")
public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(UserRepository userRepository, GroupRepository groupRepository) {
final OidcUserService delegate = new OidcUserService();
return (userRequest) -> {
OidcUser oidcUser = delegate.loadUser(userRequest);
Integer userId = null;
// Debug: Print all claims
// System.out.println("=== ID Token Claims ===");
// oidcUser.getIdToken().getClaims().forEach((key, value) ->
// System.out.println(key + ": " + value)
// );
// System.out.println("======================");
Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());
User user = null;
String workdayId = oidcUser.getAttribute("workday_id");
// Try different ways to get email
String email = oidcUser.getEmail();
if (email == null) {
email = oidcUser.getAttribute("email");
}
if (email == null) {
email = oidcUser.getAttribute("upn");
}
if (email == null) {
email = oidcUser.getAttribute("preferred_username");
}
if (workdayId != null) {
user = userRepository.getByWorkdayId(workdayId);
if (user != null) {
user.getGroups().forEach(group -> mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + group.getName())));
userId = user.getId();
}
} else if (email != null) {
user = userRepository.getByEmail(email);
if (user != null) {
user.getGroups().forEach(group -> mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + group.getName().toUpperCase())));
userId = user.getId();
}
}
if (user == null) {
var isFirstUser = userRepository.count() == 0;
userRepository.update(LccOidcUser.createDatabaseUser(email, oidcUser.getGivenName(), oidcUser.getFamilyName(), workdayId, isFirstUser));
mappedAuthorities.add(new SimpleGrantedAuthority(isFirstUser ? "ROLE_SERVICE" : "ROLE_NONE"));
}
return new LccOidcUser(
mappedAuthorities,
oidcUser.getIdToken(),
oidcUser.getUserInfo(),
"preferred_username",
userId
);
};
}
@Bean
@Profile("!dev & !test")
public BearerTokenResolver bearerTokenResolver(JwtTokenService jwtTokenService) {
return request -> {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtTokenService.parseClaimsWithoutValidation(token);
String tokenType = claims.get("token_type", String.class);
if ("ext_app".equals(tokenType)) {
return null; // using the SelfIssuedJwtFilter
}
} catch (Exception e) {
// carry on ...
}
return token; // some other token
}
return null; // all other requests
};
}
public static final class LccCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestHandler delegate = new CsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return super.resolveCsrfTokenValue(request, csrfToken);
}
return this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
public static final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}
}