diff --git a/pom.xml b/pom.xml index 869baf6..9e8bc54 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,17 @@ org.springframework.boot spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.security spring-security-test @@ -192,6 +203,17 @@ jgrapht-core ${jgrapht-core.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + diff --git a/src/main/java/de/samply/converter/ConverterManager.java b/src/main/java/de/samply/converter/ConverterManager.java index f38e01d..d476c55 100644 --- a/src/main/java/de/samply/converter/ConverterManager.java +++ b/src/main/java/de/samply/converter/ConverterManager.java @@ -20,6 +20,8 @@ public ConverterManager( @Autowired ApplicationContext applicationContext, @Value(ExporterConst.CONVERTER_XML_APPLICATION_CONTEXT_PATH_SV) String converterXmlApplicationContextPath ) { + System.out.println("Context path: " + converterXmlApplicationContextPath); + System.out.println("Converter XML application context: " + applicationContext); List converters = new ArrayList<>(); converters.addAll(applicationContext.getBeansOfType(Converter.class).values()); converters.addAll(fetchConvertersFromApplicationContextInFile(converterXmlApplicationContextPath, diff --git a/src/main/java/de/samply/exporter/ExporterConst.java b/src/main/java/de/samply/exporter/ExporterConst.java index 6a95b70..3567215 100644 --- a/src/main/java/de/samply/exporter/ExporterConst.java +++ b/src/main/java/de/samply/exporter/ExporterConst.java @@ -2,10 +2,32 @@ public class ExporterConst { + // Spring Values (SV) + // public final static String HEAD_SV = "${"; + // public final static String BOTTOM_SV = "}"; + public final static boolean LOG_FHIR_VALIDATION_DEFAULT = false; // HTTP Headers - public final static String API_KEY_HEADER = "x-api-key"; + public final static String API_KEY_HEADER = "Authorization"; + public static final String API_KEY_PREFIX = "ApiKey"; + public final static String SECURITY_ENABLED = "SECURITY_ENABLED"; + public final static String JWKS_URI_PROPERTY = "spring.security.oauth2.client.provider.oidc.jwk-set-uri"; + + + // Keycloak paths + public final static String FETCH_USER_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/users?email={email}"; + public final static String FETCH_GROUP_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/groups?search={group}"; + public final static String CHANGE_USER_GROUP_KEYCLOAK_PATH = "/admin/realms/{realm}/users/{user-id}/groups/{group-id}"; + public final static String FETCH_TOKEN_KEYCLOAK_PATH = "/realms/{realm}/protocol/openid-connect/token"; + + // Keycloak parameters + public final static String CLIENT_ID_KEYCLOAK_PARAM = "client_id"; + public final static String CLIENT_SECRET_KEYCLOAK_PARAM = "client_secret"; + public final static String GRANT_TYPE_KEYCLOAK_PARAM = "grant_type"; + public final static String CLIENT_CREDENTIALS_KEYCLOAK_CONST = "client_credentials"; + public final static String ACCES_TOKEN_KEYCLOAK_CONST = "access_token"; + public final static String ID_KEYCLOAK_CONST = "id"; // Token variables public final static String TOKEN_HEAD = "${"; @@ -173,10 +195,14 @@ public class ExporterConst { public static final String TEMPLATE_GRAPH = "/template-graph"; public static final String RUNNING_QUERIES = "/running-queries"; public static final String API_DOCS = "/api-docs"; + public static final String ERROR = "/error"; - public static final String[] REST_PATHS_WITH_API_KEY = new String[]{CREATE_QUERY, FETCH_QUERIES, + public static final String[] REST_PATHS_WITH_AUTH = new String[]{CREATE_QUERY, FETCH_QUERIES, FETCH_QUERY_EXECUTIONS, FETCH_QUERY_EXECUTION_ERRORS, REQUEST, ACTIVE_INQUIRIES, ARCHIVED_INQUIRIES, - ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, STATUS, LOGS, RUNNING_QUERIES, UPDATE_QUERY, API_DOCS}; + ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, LOGS, RUNNING_QUERIES, UPDATE_QUERY}; + public static final String[] REST_PATHS_NO_AUTH=new String[]{INFO, API_DOCS, + STATUS, ERROR}; + public static final String[] REST_PATHS_BROWSER_AUTH=new String[]{RESPONSE, "/oauth2/**", "/login/**"}; // TODO: RESPONSE ??? Only with UUID enough? // REST Headers @@ -254,4 +280,15 @@ public class ExporterConst { public final static String QUERY_CONTEXT_EQUAL = "="; public final static String FHIR_SEARCH_PATH_ROOT = "ROOT"; + // Filter + public final static String SECURITY_ENABLED_SV = HEAD_SV + SECURITY_ENABLED + ":true" + BOTTOM_SV; + public final static String JWKS_URI_PROPERTY_SV = HEAD_SV + JWKS_URI_PROPERTY + BOTTOM_SV; + + // UUser and Roles + public final static String TEST_EMAIL = "test@project-manager.com"; + public final static String TEST_BRIDGEHEAD = "bridgehead-test"; + public final static String JWT_GROUPS_CLAIM = "JWT_GROUPS_CLAIM"; + public final static String JWT_GROUPS_CLAIM_SV = HEAD_SV + JWT_GROUPS_CLAIM + ":groups" + BOTTOM_SV; + + } diff --git a/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java b/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java index 0b124e1..e21318b 100644 --- a/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java +++ b/src/main/java/de/samply/security/ApiKeyAuthenticationManager.java @@ -15,34 +15,35 @@ @Component public class ApiKeyAuthenticationManager implements AuthenticationManager { - /* - * Security: This class provides API key support to REST for connecting different server - */ - - @Value(ExporterConst.EXPORTER_API_KEY_SV) - private String apiKey; - - /** - * Authenticates request based on an API key. - * - * @param authentication REST API Client authentication. - * @return Authentication with setAuthenticated set to true or false. - * @throws AuthenticationException Authentication exception. - */ - @Override - public Authentication authenticate(Authentication authentication) throws AuthenticationException { - - String apiKey = (String) authentication.getPrincipal(); - - if (!ObjectUtils.isEmpty(this.apiKey) && (ObjectUtils.isEmpty(apiKey) || !apiKey.equals( - this.apiKey))) { - throw new BadCredentialsException("Incorrect API Key"); - } else { - authentication.setAuthenticated(true); + /* + * Security: This class provides API key support to REST for connecting different server + */ + + @Value(ExporterConst.EXPORTER_API_KEY_SV) + private String apiKey; + + /** + * Authenticates request based on an API key. + * + * @param authentication REST API Client authentication. + * @return Authentication with setAuthenticated set to true or false. + * @throws AuthenticationException Authentication exception. + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String apiKey = (String) authentication.getPrincipal(); + // new Authorization header with prefix ApiKey + if (apiKey != null && apiKey.startsWith(ExporterConst.API_KEY_PREFIX)) { + apiKey = apiKey.substring(ExporterConst.API_KEY_PREFIX.length()).trim(); + } else { + throw new BadCredentialsException("header does not contain expected prefix"); + } + if (!ObjectUtils.isEmpty(this.apiKey) && !apiKey.equals( + this.apiKey)) { + throw new BadCredentialsException("Incorrect API Key"); + } else { + authentication.setAuthenticated(true); + } + return authentication; } - - return authentication; - - } - -} +} \ No newline at end of file diff --git a/src/main/java/de/samply/security/ApiKeyRequestMatcher.java b/src/main/java/de/samply/security/ApiKeyRequestMatcher.java new file mode 100644 index 0000000..5c4718a --- /dev/null +++ b/src/main/java/de/samply/security/ApiKeyRequestMatcher.java @@ -0,0 +1,18 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StringUtils; + +public class ApiKeyRequestMatcher implements RequestMatcher { + @Override + public boolean matches(HttpServletRequest request) { + String auth = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(auth) && auth.startsWith(ExporterConst.API_KEY_PREFIX)) { + return true; + } + return false; + } +} diff --git a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java index 9642a45..ed43000 100644 --- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java +++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java @@ -1,21 +1,25 @@ package de.samply.security; - import de.samply.exporter.ExporterConst; +import jakarta.servlet.http.HttpServletResponse; 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.core.annotation.Order; +import org.springframework.http.HttpHeaders; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.security.web.util.matcher.OrRequestMatcher; import java.util.Arrays; @@ -30,7 +34,6 @@ public class ApiKeySecurityConfiguration { private ApiKeyAuthenticationManager apiKeyAuthenticationManager; - /** * Add API key filter to Spring http security. * @@ -38,22 +41,30 @@ public class ApiKeySecurityConfiguration { * @return Security Filter Chain based on apiKey. * @throws Exception Exception. */ - @Bean - public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + @Bean(name = "apiKeyFilterChain") + public SecurityFilterChain apiFilterChain(HttpSecurity httpSecurity) throws Exception { + OrRequestMatcher pathMatcher = new OrRequestMatcher( + Arrays.stream(ExporterConst.REST_PATHS_WITH_AUTH) + .map(AntPathRequestMatcher::new) + .toArray(org.springframework.security.web.util.matcher.RequestMatcher[]::new) + ); + AndRequestMatcher apiKeyOnPaths = new AndRequestMatcher(new ApiKeyRequestMatcher(), pathMatcher); + httpSecurity + .securityMatcher(apiKeyOnPaths) .cors(Customizer.withDefaults()) - .securityMatcher(ExporterConst.REST_PATHS_WITH_API_KEY) .csrf(csrf -> csrf.disable()) .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilter(createApiKeyFilter()) - .authorizeHttpRequests(authorize -> { - authorize.requestMatchers(new AntPathRequestMatcher(ExporterConst.API_DOCS)).permitAll(); - Arrays.stream(ExporterConst.REST_PATHS_WITH_API_KEY).forEach(path -> authorize.requestMatchers(new AntPathRequestMatcher(path)).authenticated()); - authorize.anyRequest().authenticated(); - } - ); - + .addFilterBefore(createApiKeyFilter(), BearerTokenAuthenticationFilter.class) + .authorizeHttpRequests(authz -> authz + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(apiKeyAuthEntryPoint()) + .accessDeniedHandler(apiKeyAccessDeniedHandler()) + ) + .anonymous(anon -> anon.disable()); return httpSecurity.build(); } @@ -65,27 +76,47 @@ public void setApiKeyAuthenticationManager( @Bean public ApiKeyFilter createApiKeyFilter() { - ApiKeyFilter apiKeyFilter = new ApiKeyFilter(); apiKeyFilter.setAuthenticationManager(apiKeyAuthenticationManager); return apiKeyFilter; - } @Bean - CorsConfigurationSource corsConfigurationSource( - @Value(ExporterConst.CROSS_ORIGINS_SV) String[] crossOrigins) { - CorsConfiguration configuration = new CorsConfiguration(); - //configuration.setAllowedOrigins(fetchCrossOrigins(crossOrigins)); - configuration.setAllowedOrigins(Arrays.asList(crossOrigins)); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT")); - configuration.setAllowedHeaders( - Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin", - ExporterConst.API_KEY_HEADER)); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; + public AuthenticationEntryPoint apiKeyAuthEntryPoint() { + return (request, response, authException) -> { + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "ApiKey realm=\"Exporter\""); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + String body = """ + { + "error": "unauthorized", + "error_description": "API key missing or invalid", + "status": 401, + "path": "%s", + "timestamp": "%s" + } + """.formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString()); + + response.getWriter().write(body); + }; } - + @Bean + public AccessDeniedHandler apiKeyAccessDeniedHandler() { + return (request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + String body = """ + { + "error": "forbidden", + "error_description": "You do not have permission to access this resource", + "status": 403, + "path": "%s", + "timestamp": "%s" + } + """.formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString()); + + response.getWriter().write(body); + }; + } } diff --git a/src/main/java/de/samply/security/CommonSecurityBeans.java b/src/main/java/de/samply/security/CommonSecurityBeans.java new file mode 100644 index 0000000..dbc9365 --- /dev/null +++ b/src/main/java/de/samply/security/CommonSecurityBeans.java @@ -0,0 +1,96 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import jakarta.servlet.http.HttpServletResponse; +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.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; + + +@Configuration +public class CommonSecurityBeans { + private static final Logger log = LoggerFactory.getLogger(CommonSecurityBeans.class); + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + @Bean + public CorsConfigurationSource corsConfigurationSource( + @Value(ExporterConst.CROSS_ORIGINS_SV) String[] crossOrigins) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList(crossOrigins)); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS")); + configuration.setAllowedHeaders( + Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin")); + configuration.setAllowCredentials(true); // Optional: allow credentials + configuration.setExposedHeaders(Arrays.asList("Content-Disposition")); // Optional + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public AuthenticationEntryPoint jwtAuthEntryPoint() { + return (request, response, authException) -> { + Throwable cause = authException.getCause(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + + if (cause instanceof JwtException jwtEx && jwtEx.getMessage().contains("expired")) { + response.getWriter().write(""" + {"error":"token_expired","message":"Your access token has expired. Please refresh or log in again."} + """); + } else { + response.getWriter().write(""" + {"error":"unauthorized","message":"Invalid or missing access token."} + """); + } + }; + } + + /** + * Creates a {@link JwtDecoder} bean using the configured issuer URI. + * @return a configured {@link JwtDecoder} instance + */ + @Bean + public JwtDecoder jwtDecoder() { + JwtDecoder delegate = JwtDecoders.fromIssuerLocation(issuerUri); + + return token -> { + try { + Jwt jwt = delegate.decode(token); + log.debug("[JWT OK] sub=" + jwt.getSubject() + + " aud=" + jwt.getAudience() + + " iss=" + jwt.getIssuer() + + " exp=" + jwt.getExpiresAt()); + return jwt; + } catch (JwtException e) { + log.warn("[JWT FAIL] {}", e.getMessage()); + throw e; + } + catch (Exception e) { + throw new JwtException( "[JWT FAIL] jwt is not valid."); + } + }; + } + + /** + * + * @return + */ + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter();} +} diff --git a/src/main/java/de/samply/security/GroupAuthManager.java b/src/main/java/de/samply/security/GroupAuthManager.java new file mode 100644 index 0000000..73e7e32 --- /dev/null +++ b/src/main/java/de/samply/security/GroupAuthManager.java @@ -0,0 +1,79 @@ +package de.samply.security; + +import jakarta.annotation.PostConstruct; +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.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Configuration +public class GroupAuthManager { + private static final Logger log = LoggerFactory.getLogger(GroupAuthManager.class); + @Value("${OIDC_GROUPS:}") + private String allowedGroupsEnv; + + @PostConstruct + public void init() { + if (allowedGroupsEnv == null || allowedGroupsEnv.trim().isEmpty()) { + throw new IllegalStateException("Allowed groups are not configured properly."); + } + log.info("Allowed groups: " + allowedGroupsEnv); + } + + @Bean + public AuthorizationManager groupAuthorizationManager() { + List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + return (authentication, context) -> { + Authentication auth = authentication.get(); + List userGroups = null; + + if (auth instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + userGroups = jwt.getClaimAsStringList("groups"); + } + + else if (auth instanceof OAuth2AuthenticationToken oauth2Auth) { + Object principal = oauth2Auth.getPrincipal(); + + if (principal instanceof OidcUser oidcUser) { + userGroups = oidcUser.getClaimAsStringList("groups"); + } else if (principal instanceof OAuth2User oauth2User) { + Object claim = oauth2User.getAttributes().get("groups"); + if (claim instanceof List list) { + userGroups = list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + } + } + + boolean isAuthorized = userGroups != null && userGroups.stream().anyMatch(allowedGroups::contains); + + log.debug("Auth type : {}", auth.getClass().getSimpleName()); + log.debug("User groups : {}", userGroups); + log.debug("Allowed groups : {}", allowedGroups); + log.debug("Has allowed group: {}", isAuthorized); + + return new AuthorizationDecision(isAuthorized); + }; + } +} diff --git a/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java new file mode 100644 index 0000000..9bf5712 --- /dev/null +++ b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java @@ -0,0 +1,36 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +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.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@Order(0) +public class NoAuthSecurityConfiguration { + @Bean + SecurityFilterChain publicChain(HttpSecurity httpSecurity) throws Exception { + var publicPaths = new OrRequestMatcher( + Arrays.stream(ExporterConst.REST_PATHS_NO_AUTH) + .map(AntPathRequestMatcher::new) + .toArray(RequestMatcher[]::new) + ); + + return httpSecurity.securityMatcher(publicPaths) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/de/samply/security/OAuthSecurityConfiguration.java b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java new file mode 100644 index 0000000..c4cd851 --- /dev/null +++ b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java @@ -0,0 +1,143 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagers; +import org.springframework.security.config.Customizer; +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.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.savedrequest.SavedRequest; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + + +import java.io.IOException; +import java.util.Arrays; + +/** + * + */ +@Configuration +@EnableWebSecurity +@Order(2) +public class OAuthSecurityConfiguration { + private static final Logger log = LoggerFactory.getLogger(OAuthSecurityConfiguration.class); + @Value(ExporterConst.SECURITY_ENABLED_SV) + private boolean isSecurityEnabled; + + + /** + * Filter Chain for the Exporter + * If security is enabled, the application is configured as an OAuth2 resource server that: + *
    + *
  • Disables CSRF protection (suitable for stateless REST APIs).
  • + *
  • Uses JWT tokens for authentication via a custom decoder and authentication converter.
  • + *
  • Applies access control via a custom {@link AuthorizationManager}.
  • + *
+ * + * @param http the {@link HttpSecurity} to modify + * @return the configured {@link SecurityFilterChain} + * @throws Exception if an error occurs while configuring the security filter chain + */ + @Bean(name = "oauthFilterChain") + public SecurityFilterChain securityFilterChain( + HttpSecurity httpSecurity, + AuthenticationEntryPoint jwtAuthEntryPoint, + JwtDecoder jwtDecoder, + JwtAuthenticationConverter jwtAuthenticationConverter, + AuthorizationManager groupAuthorizationManager + ) throws Exception { + + RequestMatcher browserPaths = new OrRequestMatcher( + Arrays.stream(ExporterConst.REST_PATHS_BROWSER_AUTH) + .map(AntPathRequestMatcher::new) + .toArray(RequestMatcher[]::new) + ); + + AuthorizationManager authThenGroups = + AuthorizationManagers.allOf( + AuthenticatedAuthorizationManager.authenticated(), + groupAuthorizationManager + ); + + httpSecurity + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .requestCache(cache -> cache.requestCache(new HttpSessionRequestCache())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.decoder(jwtDecoder) + .jwtAuthenticationConverter(jwtAuthenticationConverter)) + .authenticationEntryPoint(jwtAuthEntryPoint) + ) + .oauth2Login(oauth2Login -> oauth2Login + .loginPage("/oauth2/authorization/oidc") + ) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/oauth2/**", "/login/**").permitAll() + .requestMatchers(ExporterConst.REST_PATHS_NO_AUTH).permitAll() + .anyRequest().access(authThenGroups) // <- use composed manager + ) + .exceptionHandling(eh -> eh + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/oidc"), + browserPaths + ) + .defaultAuthenticationEntryPointFor( + jwtAuthEntryPoint, + AnyRequestMatcher.INSTANCE + ) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ); + + return httpSecurity.build(); + } + + private AuthenticationSuccessHandler successHandler() { + return new SimpleUrlAuthenticationSuccessHandler() { + private RequestCache requestCache = new HttpSessionRequestCache(); + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + SavedRequest savedRequest = requestCache.getRequest(request, response); + setUseReferer(true); + if (savedRequest != null) { + String targetUrl = savedRequest.getRedirectUrl(); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } else { + super.onAuthenticationSuccess(request, response, authentication); + } + } + }; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 49d832c..278e336 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,9 @@ server.port=${APPLICATION_PORT:8092} logging.level.root=${LOG_LEVEL:INFO} -logging.level.org.springframework.security=${LOG_LEVEL:INFO} +logging.level.org.springframework.security=TRACE +logging.level.org.springframework.security.oauth2=TRACE +logging.level.org.springframework.web.client.RestTemplate=TRACE +logging.level.org.springframework.web=DEBUG logging.level.ca.uhn.fhir=${HAPI_FHIR_CLIENT_LOG_LEVEL:OFF} logging.level.org.hl7.fhir=${HAPI_FHIR_CLIENT_LOG_LEVEL:OFF} @@ -29,3 +32,20 @@ spring.jpa.show-sql=${HIBERNATE_LOG:false} springdoc.api-docs.path=/api-docs springdoc.api-docs.enabled=true springdoc.swagger-ui.path=/swagger-ui.html + +# security OIDC config +spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_URL}/application/o/${OIDC_SLUG}/ +spring.security.oauth2.client.provider.oidc.authorization-uri=${OIDC_URL}/application/o/authorize/ +spring.security.oauth2.client.provider.oidc.token-uri=${OIDC_URL}/application/o/token/ +spring.security.oauth2.client.provider.oidc.user-info-uri=${OIDC_URL}/application/o/userinfo/ +spring.security.oauth2.client.provider.oidc.jwk-set-uri=${OIDC_URL}/application/o/${OIDC_SLUG}/jwks/ +spring.security.oauth2.client.provider.oidc.user-name-attribute=email + +spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID} +spring.security.oauth2.client.registration.oidc.client-secret=${OIDC_CLIENT_SECRET:} +spring.security.oauth2.client.registration.oidc.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId} +spring.security.oauth2.client.registration.oidc.scope=email,profile,openid +server.servlet.session.cookie.same-site=lax +server.servlet.session.cookie.secure=false +server.servlet.session.cookie.http-only=true \ No newline at end of file