From 31957fb9c149c017ef51ac8c29700904a418ca9d Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Fri, 4 Apr 2025 09:04:28 +0200 Subject: [PATCH 1/9] api Key change to Authorization --- src/main/java/de/samply/exporter/ExporterConst.java | 2 +- .../java/de/samply/security/ApiKeySecurityConfiguration.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/samply/exporter/ExporterConst.java b/src/main/java/de/samply/exporter/ExporterConst.java index 6a95b70..593b35a 100644 --- a/src/main/java/de/samply/exporter/ExporterConst.java +++ b/src/main/java/de/samply/exporter/ExporterConst.java @@ -5,7 +5,7 @@ public class ExporterConst { 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"; // Token variables public final static String TOKEN_HEAD = "${"; diff --git a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java index 9642a45..c5b4503 100644 --- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java +++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java @@ -80,8 +80,7 @@ CorsConfigurationSource corsConfigurationSource( 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)); + Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; From c3e7665551bb2670443b86cb77485bd79c728590 Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Wed, 7 May 2025 14:36:33 +0200 Subject: [PATCH 2/9] first check APIKey and second check cokies with groups not tested --- pom.xml | 22 +++ .../de/samply/converter/ConverterManager.java | 2 + .../de/samply/exporter/ExporterConst.java | 33 ++++ .../security/ApiKeyAuthenticationManager.java | 61 +++--- .../security/ApiKeySecurityConfiguration.java | 26 --- .../samply/security/CommonSecurityBeans.java | 32 ++++ ...ectUserJwtGrantedAuthoritiesConverter.java | 34 ++++ .../security/SecurityConfiguration.java | 175 ++++++++++++++++++ src/main/resources/application.properties | 26 +++ 9 files changed, 355 insertions(+), 56 deletions(-) create mode 100644 src/main/java/de/samply/security/CommonSecurityBeans.java create mode 100644 src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java create mode 100644 src/main/java/de/samply/security/SecurityConfiguration.java diff --git a/pom.xml b/pom.xml index a4a978f..cc518f4 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 593b35a..d513c0c 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 = "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 = "${"; @@ -254,4 +276,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/ApiKeySecurityConfiguration.java b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java index c5b4503..45ac34d 100644 --- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java +++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java @@ -1,9 +1,7 @@ package de.samply.security; - import de.samply.exporter.ExporterConst; 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; @@ -13,10 +11,6 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; 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 java.util.Arrays; @@ -30,7 +24,6 @@ public class ApiKeySecurityConfiguration { private ApiKeyAuthenticationManager apiKeyAuthenticationManager; - /** * Add API key filter to Spring http security. * @@ -53,7 +46,6 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti authorize.anyRequest().authenticated(); } ); - return httpSecurity.build(); } @@ -65,26 +57,8 @@ 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")); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - } 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..d827a83 --- /dev/null +++ b/src/main/java/de/samply/security/CommonSecurityBeans.java @@ -0,0 +1,32 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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 { + + @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; + } + +} diff --git a/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java b/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java new file mode 100644 index 0000000..91c2201 --- /dev/null +++ b/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java @@ -0,0 +1,34 @@ +package de.samply.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@Component +public class ProjectUserJwtGrantedAuthoritiesConverter implements Converter> { + + @Value("${OIDC_GROUPS:}") + private String allowedGroupsEnv; + + /** + * + * @param jwt + * @return + */ + public List convert(Jwt jwt) { + List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + List userGroups = jwt.getClaimAsStringList("groups"); + return userGroups.stream() + .filter(allowedGroups::contains) + .map(group -> new SimpleGrantedAuthority("ROLE_" + group)) + .toList(); + } +} diff --git a/src/main/java/de/samply/security/SecurityConfiguration.java b/src/main/java/de/samply/security/SecurityConfiguration.java new file mode 100644 index 0000000..c3a8f75 --- /dev/null +++ b/src/main/java/de/samply/security/SecurityConfiguration.java @@ -0,0 +1,175 @@ +package de.samply.security; + +import de.samply.exporter.ExporterConst; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +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.core.Authentication; +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.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +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.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.util.StringUtils; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * + */ +@Configuration +@EnableWebSecurity +@Order(2) +public class SecurityConfiguration { + @Value("${OIDC_GROUPS:}") + private String allowedGroupsEnv; + + @Value(ExporterConst.SECURITY_ENABLED_SV) + private boolean isSecurityEnabled; + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String jwksUri; + + @PostConstruct + public void init() { + if (allowedGroupsEnv == null || allowedGroupsEnv.trim().isEmpty()) { + throw new IllegalStateException("Allowed groups are not configured properly."); + } + System.out.println("Allowed groups: " + allowedGroupsEnv); + } + + /** + * Creates a {@link JwtDecoder} bean using the configured issuer URI. + * @return a configured {@link JwtDecoder} instance + */ + @Bean + public JwtDecoder jwtDecoder() { + return JwtDecoders.fromIssuerLocation(jwksUri); + } + + /** + * 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 org.springframework.security.authorization.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 filterChain(HttpSecurity http) throws Exception { + if (!isSecurityEnabled) { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); + return http.build(); + } + http.csrf(AbstractHttpConfigurer::disable) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .decoder(jwtDecoder()) + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + ) + ) + .authorizeHttpRequests(authz -> authz.anyRequest().access(groupAuthorizationManager()) + ); + return http.build(); + } + + private AuthorizationManager groupAuthorizationManager() { + return (authentication, context) -> { + Authentication auth = authentication.get(); + if (auth instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + List userGroups = jwt.getClaimAsStringList("groups"); + if (userGroups == null || allowedGroupsEnv.trim().isEmpty()) { + return new AuthorizationDecision(false); + } + List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + boolean isAuthorized = userGroups.stream().anyMatch(allowedGroups::contains); + return new AuthorizationDecision(isAuthorized); + } + return new AuthorizationDecision(false); + }; + } + + /** + * + * @return + */ + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + return new JwtAuthenticationConverter(); + } + + /** + * @return + */ + @Bean + public BearerTokenResolver bearerTokenResolver() { + BearerTokenResolver resolver = request -> { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("jwt".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + // Fallback to use the Authorization-Header + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + }; + return resolver; + } + + 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); + } + } + }; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 49d832c..13671bf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,3 +29,29 @@ 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 + +#logging.level.org.springframework=DEBUG + +# security OIDC config +spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_URL}/realms/${OIDC_REALM} +spring.security.oauth2.client.provider.oidc.issuer-uri=${OIDC_URL}/realms/${OIDC_REALM} +spring.security.oauth2.client.provider.oidc.authorization-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/auth +spring.security.oauth2.client.provider.oidc.token-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/token +spring.security.oauth2.client.provider.oidc.user-info-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/userinfo +spring.security.oauth2.client.provider.oidc.jwk-set-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/certs +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 + + +#spring.security.oauth2.client.registration.authentik.client-id= +#spring.security.oauth2.client.registration.authentik.client-secret= +#spring.security.oauth2.client.registration.authentik.scope= +#spring.security.oauth2.client.registration.authentik.authorization-grant-type= + +#spring.security.oauth2.client.provider.authentik.token-uri= +#spring.security.oauth2.client.provider.authentik.jwk-set-uri= \ No newline at end of file From 8b3e5e2d94b90166aacd7d0c73498ebac56de1f1 Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Mon, 22 Sep 2025 16:25:34 +0200 Subject: [PATCH 3/9] prototype oauth2 --- .../security/SecurityConfiguration.java | 54 ++++++------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/src/main/java/de/samply/security/SecurityConfiguration.java b/src/main/java/de/samply/security/SecurityConfiguration.java index c3a8f75..0465d13 100644 --- a/src/main/java/de/samply/security/SecurityConfiguration.java +++ b/src/main/java/de/samply/security/SecurityConfiguration.java @@ -3,16 +3,15 @@ import de.samply.exporter.ExporterConst; import jakarta.annotation.PostConstruct; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationManager; +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; @@ -22,7 +21,6 @@ import org.springframework.security.oauth2.jwt.JwtDecoders; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -30,7 +28,6 @@ import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SavedRequest; -import org.springframework.util.StringUtils; import java.io.IOException; import java.util.Arrays; @@ -50,7 +47,7 @@ public class SecurityConfiguration { private boolean isSecurityEnabled; @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") - private String jwksUri; + private String issuerUri; @PostConstruct public void init() { @@ -66,7 +63,7 @@ public void init() { */ @Bean public JwtDecoder jwtDecoder() { - return JwtDecoders.fromIssuerLocation(jwksUri); + return JwtDecoders.fromIssuerLocation(issuerUri); } /** @@ -89,32 +86,34 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); return http.build(); } - http.csrf(AbstractHttpConfigurer::disable) + http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) .oauth2ResourceServer(oauth2 -> oauth2 + .bearerTokenResolver(bearerTokenResolver()) .jwt(jwt -> jwt .decoder(jwtDecoder()) .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ) - .authorizeHttpRequests(authz -> authz.anyRequest().access(groupAuthorizationManager()) + .authorizeHttpRequests(authz -> authz + .anyRequest() + .access(groupAuthorizationManager()) ); return http.build(); } private AuthorizationManager groupAuthorizationManager() { + List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); return (authentication, context) -> { Authentication auth = authentication.get(); if (auth instanceof JwtAuthenticationToken jwtAuth) { Jwt jwt = jwtAuth.getToken(); List userGroups = jwt.getClaimAsStringList("groups"); - if (userGroups == null || allowedGroupsEnv.trim().isEmpty()) { - return new AuthorizationDecision(false); - } - List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - boolean isAuthorized = userGroups.stream().anyMatch(allowedGroups::contains); + boolean isAuthorized = userGroups != null && userGroups.stream().anyMatch(allowedGroups::contains); return new AuthorizationDecision(isAuthorized); } return new AuthorizationDecision(false); @@ -130,29 +129,6 @@ public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter(); } - /** - * @return - */ - @Bean - public BearerTokenResolver bearerTokenResolver() { - BearerTokenResolver resolver = request -> { - if (request.getCookies() != null) { - for (Cookie cookie : request.getCookies()) { - if ("jwt".equals(cookie.getName())) { - return cookie.getValue(); - } - } - } - // Fallback to use the Authorization-Header - String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7); - } - return null; - }; - return resolver; - } - private AuthenticationSuccessHandler successHandler() { return new SimpleUrlAuthenticationSuccessHandler() { private RequestCache requestCache = new HttpSessionRequestCache(); From 5c1ca7d4474ab7c2ee88115e636e260c4393943f Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Mon, 6 Oct 2025 16:58:15 +0200 Subject: [PATCH 4/9] success oauth order 2 --- src/main/java/de/samply/security/SecurityConfiguration.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/de/samply/security/SecurityConfiguration.java b/src/main/java/de/samply/security/SecurityConfiguration.java index 0465d13..7f46415 100644 --- a/src/main/java/de/samply/security/SecurityConfiguration.java +++ b/src/main/java/de/samply/security/SecurityConfiguration.java @@ -28,7 +28,6 @@ import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SavedRequest; - import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -90,7 +89,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .oauth2ResourceServer(oauth2 -> oauth2 - .bearerTokenResolver(bearerTokenResolver()) .jwt(jwt -> jwt .decoder(jwtDecoder()) .jwtAuthenticationConverter(jwtAuthenticationConverter()) @@ -148,4 +146,4 @@ public void onAuthenticationSuccess( }; } -} +} \ No newline at end of file From 39cd4de4a32c489ca2bfc668c499e1796dfb36a0 Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Tue, 7 Oct 2025 08:18:00 +0200 Subject: [PATCH 5/9] config for authentik --- src/main/resources/application.properties | 25 +++++++---------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 13671bf..cf409de 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,28 +30,17 @@ springdoc.api-docs.path=/api-docs springdoc.api-docs.enabled=true springdoc.swagger-ui.path=/swagger-ui.html -#logging.level.org.springframework=DEBUG - # security OIDC config -spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_URL}/realms/${OIDC_REALM} -spring.security.oauth2.client.provider.oidc.issuer-uri=${OIDC_URL}/realms/${OIDC_REALM} -spring.security.oauth2.client.provider.oidc.authorization-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/auth -spring.security.oauth2.client.provider.oidc.token-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/token -spring.security.oauth2.client.provider.oidc.user-info-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/userinfo -spring.security.oauth2.client.provider.oidc.jwk-set-uri=${spring.security.oauth2.client.provider.oidc.issuer-uri}/protocol/openid-connect/certs +spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_URL}/application/o/${OIDC_REALM}/ +spring.security.oauth2.client.provider.oidc.issuer-uri=${OIDC_URL}/application/o/${OIDC_REALM}/ +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_REALM}/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 - - -#spring.security.oauth2.client.registration.authentik.client-id= -#spring.security.oauth2.client.registration.authentik.client-secret= -#spring.security.oauth2.client.registration.authentik.scope= -#spring.security.oauth2.client.registration.authentik.authorization-grant-type= - -#spring.security.oauth2.client.provider.authentik.token-uri= -#spring.security.oauth2.client.provider.authentik.jwk-set-uri= \ No newline at end of file +spring.security.oauth2.client.registration.oidc.scope=email,profile,openid \ No newline at end of file From 133626aceca5665d66e9d5ecf3daf523752cfa5d Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Thu, 6 Nov 2025 13:53:05 +0100 Subject: [PATCH 6/9] Error and endpiont config, info and status noauth --- .../de/samply/exporter/ExporterConst.java | 9 +- .../samply/security/ApiKeyRequestMatcher.java | 18 ++++ .../security/ApiKeySecurityConfiguration.java | 76 ++++++++++++++-- .../NoAuthSecurityConfiiguration.java | 36 ++++++++ ...n.java => OAuthSecurityConfiguration.java} | 91 +++++++++++++++---- ...ectUserJwtGrantedAuthoritiesConverter.java | 34 ------- 6 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 src/main/java/de/samply/security/ApiKeyRequestMatcher.java create mode 100644 src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java rename src/main/java/de/samply/security/{SecurityConfiguration.java => OAuthSecurityConfiguration.java} (59%) delete mode 100644 src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java diff --git a/src/main/java/de/samply/exporter/ExporterConst.java b/src/main/java/de/samply/exporter/ExporterConst.java index d513c0c..662231e 100644 --- a/src/main/java/de/samply/exporter/ExporterConst.java +++ b/src/main/java/de/samply/exporter/ExporterConst.java @@ -10,7 +10,7 @@ public class ExporterConst { // HTTP Headers public final static String API_KEY_HEADER = "Authorization"; - public static final String API_KEY_PREFIX = "ApiKey "; + 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"; @@ -195,10 +195,13 @@ 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}; // TODO: RESPONSE ??? Only with UUID enough? // REST Headers 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 45ac34d..976e4c4 100644 --- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java +++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java @@ -1,16 +1,26 @@ package de.samply.security; import de.samply.exporter.ExporterConst; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; 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.security.web.util.matcher.OrRequestMatcher; + import java.util.Arrays; @@ -31,21 +41,30 @@ public class ApiKeySecurityConfiguration { * @return Security Filter Chain based on apiKey. * @throws Exception Exception. */ - @Bean + @Bean(name = "apiKeyFilterChain") public SecurityFilterChain filterChain(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(); } @@ -61,4 +80,43 @@ public ApiKeyFilter createApiKeyFilter() { apiKeyFilter.setAuthenticationManager(apiKeyAuthenticationManager); return apiKeyFilter; } + + @Bean + 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/NoAuthSecurityConfiiguration.java b/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java new file mode 100644 index 0000000..2873fe5 --- /dev/null +++ b/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.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 NoAuthSecurityConfiiguration { + @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(); + } +} diff --git a/src/main/java/de/samply/security/SecurityConfiguration.java b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java similarity index 59% rename from src/main/java/de/samply/security/SecurityConfiguration.java rename to src/main/java/de/samply/security/OAuthSecurityConfiguration.java index 7f46415..34e5de9 100644 --- a/src/main/java/de/samply/security/SecurityConfiguration.java +++ b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java @@ -5,22 +5,27 @@ 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.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationManager; 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.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jwt.*; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +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; @@ -28,6 +33,8 @@ 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.AntPathRequestMatcher; + import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -38,7 +45,8 @@ @Configuration @EnableWebSecurity @Order(2) -public class SecurityConfiguration { +public class OAuthSecurityConfiguration { + private static final Logger log = LoggerFactory.getLogger(OAuthSecurityConfiguration.class); @Value("${OIDC_GROUPS:}") private String allowedGroupsEnv; @@ -53,7 +61,7 @@ public void init() { if (allowedGroupsEnv == null || allowedGroupsEnv.trim().isEmpty()) { throw new IllegalStateException("Allowed groups are not configured properly."); } - System.out.println("Allowed groups: " + allowedGroupsEnv); + log.info("Allowed groups: " + allowedGroupsEnv); } /** @@ -62,7 +70,24 @@ public void init() { */ @Bean public JwtDecoder jwtDecoder() { - return JwtDecoders.fromIssuerLocation(issuerUri); + 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."); + } + }; } /** @@ -71,7 +96,7 @@ public JwtDecoder jwtDecoder() { *
    *
  • 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 org.springframework.security.authorization.AuthorizationManager}.
  • + *
  • Applies access control via a custom {@link AuthorizationManager}.
  • *
* * @param http the {@link HttpSecurity} to modify @@ -79,26 +104,36 @@ public JwtDecoder jwtDecoder() { * @throws Exception if an error occurs while configuring the security filter chain */ @Bean(name = "oauthFilterChain") - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (!isSecurityEnabled) { - http.csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); - return http.build(); - } - http + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) + .requestCache(rc -> rc.disable()) + .sessionManagement(httpSecuritySessionManagementConfigurer -> + httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .decoder(jwtDecoder()) .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) + .authenticationEntryPoint(jwtAuthEntryPoint()) ) .authorizeHttpRequests(authz -> authz + .requestMatchers(ExporterConst.REST_PATHS_NO_AUTH).permitAll() .anyRequest() .access(groupAuthorizationManager()) + ) + .exceptionHandling(eh -> eh + .authenticationEntryPoint(jwtAuthEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) ); - return http.build(); + httpSecurity.addFilterBefore((req, res, chain) -> { + HttpServletRequest r = (HttpServletRequest) req; + String h = r.getHeader(HttpHeaders.AUTHORIZATION); + log.info("[CHECK HEADER in JWT Chain before BearerTokenAuthenticationFilter] " + h); + chain.doFilter(req, res); + }, BearerTokenAuthenticationFilter.class); + return httpSecurity.build(); } private AuthorizationManager groupAuthorizationManager() { @@ -107,11 +142,15 @@ private AuthorizationManager groupAuthorizationMana .filter(s -> !s.isEmpty()) .toList(); return (authentication, context) -> { + String test = authentication.get().getDetails().toString(); Authentication auth = authentication.get(); if (auth instanceof JwtAuthenticationToken jwtAuth) { Jwt jwt = jwtAuth.getToken(); List userGroups = jwt.getClaimAsStringList("groups"); + log.debug("JWT groups : {}", userGroups); + log.debug("Allowed groups : {}", allowedGroups); boolean isAuthorized = userGroups != null && userGroups.stream().anyMatch(allowedGroups::contains); + log.debug("JWT has allowed groups: {}", isAuthorized); return new AuthorizationDecision(isAuthorized); } return new AuthorizationDecision(false); @@ -123,9 +162,7 @@ private AuthorizationManager groupAuthorizationMana * @return */ @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { - return new JwtAuthenticationConverter(); - } + public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter();} private AuthenticationSuccessHandler successHandler() { return new SimpleUrlAuthenticationSuccessHandler() { @@ -146,4 +183,22 @@ public void onAuthenticationSuccess( }; } + @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."} + """); + } + }; + } } \ No newline at end of file diff --git a/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java b/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java deleted file mode 100644 index 91c2201..0000000 --- a/src/main/java/de/samply/security/ProjectUserJwtGrantedAuthoritiesConverter.java +++ /dev/null @@ -1,34 +0,0 @@ -package de.samply.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -@Component -public class ProjectUserJwtGrantedAuthoritiesConverter implements Converter> { - - @Value("${OIDC_GROUPS:}") - private String allowedGroupsEnv; - - /** - * - * @param jwt - * @return - */ - public List convert(Jwt jwt) { - List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - List userGroups = jwt.getClaimAsStringList("groups"); - return userGroups.stream() - .filter(allowedGroups::contains) - .map(group -> new SimpleGrantedAuthority("ROLE_" + group)) - .toList(); - } -} From 3830100b332e96d013eb0f2ad57efd464e0c18f2 Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Mon, 8 Dec 2025 18:57:24 +0100 Subject: [PATCH 7/9] browser acceppt response --- .../de/samply/exporter/ExporterConst.java | 1 + .../security/ApiKeySecurityConfiguration.java | 2 +- .../samply/security/CommonSecurityBeans.java | 66 +++++++- .../de/samply/security/GroupAuthManager.java | 79 +++++++++ .../NoAuthSecurityConfiiguration.java | 2 +- .../security/OAuthSecurityConfiguration.java | 155 ++++++------------ 6 files changed, 194 insertions(+), 111 deletions(-) create mode 100644 src/main/java/de/samply/security/GroupAuthManager.java diff --git a/src/main/java/de/samply/exporter/ExporterConst.java b/src/main/java/de/samply/exporter/ExporterConst.java index 662231e..3567215 100644 --- a/src/main/java/de/samply/exporter/ExporterConst.java +++ b/src/main/java/de/samply/exporter/ExporterConst.java @@ -202,6 +202,7 @@ public class ExporterConst { 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 diff --git a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java index 976e4c4..ed43000 100644 --- a/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java +++ b/src/main/java/de/samply/security/ApiKeySecurityConfiguration.java @@ -42,7 +42,7 @@ public class ApiKeySecurityConfiguration { * @throws Exception Exception. */ @Bean(name = "apiKeyFilterChain") - public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + public SecurityFilterChain apiFilterChain(HttpSecurity httpSecurity) throws Exception { OrRequestMatcher pathMatcher = new OrRequestMatcher( Arrays.stream(ExporterConst.REST_PATHS_WITH_AUTH) .map(AntPathRequestMatcher::new) diff --git a/src/main/java/de/samply/security/CommonSecurityBeans.java b/src/main/java/de/samply/security/CommonSecurityBeans.java index d827a83..dbc9365 100644 --- a/src/main/java/de/samply/security/CommonSecurityBeans.java +++ b/src/main/java/de/samply/security/CommonSecurityBeans.java @@ -1,17 +1,30 @@ 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( @@ -29,4 +42,55 @@ public CorsConfigurationSource corsConfigurationSource( 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/NoAuthSecurityConfiiguration.java b/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java index 2873fe5..d253007 100644 --- a/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java +++ b/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java @@ -33,4 +33,4 @@ SecurityFilterChain publicChain(HttpSecurity httpSecurity) throws Exception { .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 index 34e5de9..c4cd851 100644 --- a/src/main/java/de/samply/security/OAuthSecurityConfiguration.java +++ b/src/main/java/de/samply/security/OAuthSecurityConfiguration.java @@ -12,32 +12,36 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; -import org.springframework.security.authorization.AuthorizationDecision; +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.*; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 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; -import java.util.List; /** * @@ -47,48 +51,9 @@ @Order(2) public class OAuthSecurityConfiguration { private static final Logger log = LoggerFactory.getLogger(OAuthSecurityConfiguration.class); - @Value("${OIDC_GROUPS:}") - private String allowedGroupsEnv; - @Value(ExporterConst.SECURITY_ENABLED_SV) private boolean isSecurityEnabled; - @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") - private String issuerUri; - - @PostConstruct - public void init() { - if (allowedGroupsEnv == null || allowedGroupsEnv.trim().isEmpty()) { - throw new IllegalStateException("Allowed groups are not configured properly."); - } - log.info("Allowed groups: " + allowedGroupsEnv); - } - - /** - * 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."); - } - }; - } /** * Filter Chain for the Exporter @@ -104,66 +69,59 @@ public JwtDecoder jwtDecoder() { * @throws Exception if an error occurs while configuring the security filter chain */ @Bean(name = "oauthFilterChain") - public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + 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(rc -> rc.disable()) - .sessionManagement(httpSecuritySessionManagementConfigurer -> - httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .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()) + .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(groupAuthorizationManager()) + .anyRequest().access(authThenGroups) // <- use composed manager ) .exceptionHandling(eh -> eh - .authenticationEntryPoint(jwtAuthEntryPoint()) + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/oidc"), + browserPaths + ) + .defaultAuthenticationEntryPointFor( + jwtAuthEntryPoint, + AnyRequestMatcher.INSTANCE + ) .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) ); - httpSecurity.addFilterBefore((req, res, chain) -> { - HttpServletRequest r = (HttpServletRequest) req; - String h = r.getHeader(HttpHeaders.AUTHORIZATION); - log.info("[CHECK HEADER in JWT Chain before BearerTokenAuthenticationFilter] " + h); - chain.doFilter(req, res); - }, BearerTokenAuthenticationFilter.class); - return httpSecurity.build(); - } - private AuthorizationManager groupAuthorizationManager() { - List allowedGroups = Arrays.stream(allowedGroupsEnv.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - return (authentication, context) -> { - String test = authentication.get().getDetails().toString(); - Authentication auth = authentication.get(); - if (auth instanceof JwtAuthenticationToken jwtAuth) { - Jwt jwt = jwtAuth.getToken(); - List userGroups = jwt.getClaimAsStringList("groups"); - log.debug("JWT groups : {}", userGroups); - log.debug("Allowed groups : {}", allowedGroups); - boolean isAuthorized = userGroups != null && userGroups.stream().anyMatch(allowedGroups::contains); - log.debug("JWT has allowed groups: {}", isAuthorized); - return new AuthorizationDecision(isAuthorized); - } - return new AuthorizationDecision(false); - }; + return httpSecurity.build(); } - /** - * - * @return - */ - @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter();} - private AuthenticationSuccessHandler successHandler() { return new SimpleUrlAuthenticationSuccessHandler() { private RequestCache requestCache = new HttpSessionRequestCache(); @@ -182,23 +140,4 @@ public void onAuthenticationSuccess( } }; } - - @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."} - """); - } - }; - } } \ No newline at end of file From dbe8043f16db6198f4e22b742d52f24399522bf1 Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Wed, 10 Dec 2025 08:06:40 +0100 Subject: [PATCH 8/9] config oidc_realm renamed --- src/main/resources/application.properties | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cf409de..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} @@ -31,16 +34,18 @@ 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_REALM}/ -spring.security.oauth2.client.provider.oidc.issuer-uri=${OIDC_URL}/application/o/${OIDC_REALM}/ +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_REALM}/jwks/ +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 \ No newline at end of file +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 From 546ae1786dd182f7e284f3a03db17f5bf1c9672d Mon Sep 17 00:00:00 2001 From: Martin Jurk Date: Wed, 10 Dec 2025 08:48:49 +0100 Subject: [PATCH 9/9] miss type --- ...rityConfiiguration.java => NoAuthSecurityConfiguration.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/de/samply/security/{NoAuthSecurityConfiiguration.java => NoAuthSecurityConfiguration.java} (97%) diff --git a/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java similarity index 97% rename from src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java rename to src/main/java/de/samply/security/NoAuthSecurityConfiguration.java index d253007..9bf5712 100644 --- a/src/main/java/de/samply/security/NoAuthSecurityConfiiguration.java +++ b/src/main/java/de/samply/security/NoAuthSecurityConfiguration.java @@ -18,7 +18,7 @@ @Configuration @EnableWebSecurity @Order(0) -public class NoAuthSecurityConfiiguration { +public class NoAuthSecurityConfiguration { @Bean SecurityFilterChain publicChain(HttpSecurity httpSecurity) throws Exception { var publicPaths = new OrRequestMatcher(