From c8d1f448bcd1da206df32dd914e00687405c8064 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Wed, 5 Jul 2023 19:36:14 +0200 Subject: [PATCH 01/27] Ok Basic Auth test both with annotations/beans and from XML file. --- pom.xml | 60 +++++++++++-------- .../openeo/spring/BasicSecurityConfig.java | 60 +++++++++++++++++++ .../spring/BasicSecurityFromFileConfig.java | 12 ++++ .../openeo/spring/GlobalSecurityManager.java | 17 ++++++ .../org/openeo/spring/SecurityConfig.java | 39 ++++++------ .../spring/api/CredentialsApiController.java | 10 ++++ src/main/resources/webSecurityConfig.xml | 27 +++++++++ 7 files changed, 179 insertions(+), 46 deletions(-) create mode 100644 src/main/java/org/openeo/spring/BasicSecurityConfig.java create mode 100644 src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java create mode 100644 src/main/java/org/openeo/spring/GlobalSecurityManager.java create mode 100644 src/main/resources/webSecurityConfig.xml diff --git a/pom.xml b/pom.xml index 0ecd013..5b71166 100644 --- a/pom.xml +++ b/pom.xml @@ -1,20 +1,34 @@ + 4.0.0 org.openeo openeo-spring-driver jar openeo-spring-driver - 1.0.0 + 1.1.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.5.1 + + + 11 ${java.version} ${java.version} 3.0.0 1.4.6 - /usr/share/java/gdal.jar - 3.1.0 + + 3.4.0 5.2.0 2.17.0 2.8.0 @@ -22,15 +36,9 @@ 12.0.4 9.0.37 0.2.1 - 5.5.1 - 2.7.4 false - - org.springframework.boot - spring-boot-starter-parent - 2.5.1 - + src/main/java @@ -88,6 +96,7 @@ + org.springframework.boot @@ -115,6 +124,18 @@ org.springframework.boot spring-boot-starter-security + org.keycloak keycloak-spring-security-adapter @@ -125,21 +146,6 @@ keycloak-authz-client ${keycloak.version} - - org.springframework.security - spring-security-core - ${spring.security.version} - - - org.springframework.security - spring-security-web - ${spring.security.version} - - - org.springframework.security - spring-security-config - ${spring.security.version} - org.springdoc springdoc-openapi-ui @@ -222,7 +228,6 @@ org.springframework.boot spring-boot-starter-test - ${spring.test.version} test + @@ -242,6 +248,7 @@ + unidata-all @@ -254,4 +261,5 @@ http://oss.jfrog.org/artifactory/oss-snapshot-local/ + \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/BasicSecurityConfig.java new file mode 100644 index 0000000..ac80220 --- /dev/null +++ b/src/main/java/org/openeo/spring/BasicSecurityConfig.java @@ -0,0 +1,60 @@ +package org.openeo.spring; + +import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; + +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +//@Configuration +//@EnableWebSecurity +//@EnableGlobalMethodSecurity(securedEnabled = true) +public class BasicSecurityConfig { + + //@Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((requests) -> requests + .anyRequest().authenticated() + ) + .httpBasic(); + + return http.build(); + } + + //@Bean + // FIXME import auth-free endpoints from shared non-duplicated source + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().antMatchers(NOAUTH_API_RESOURCES); + } + + //@Bean + public UserDetailsService userDetailsService() { + UserDetails user = + User.withDefaultPasswordEncoder() + //.passwordEncoder((s1, s2) -> { return passwordEncoder(); }) ? + .username("user") + .password("changeme") + .authorities("ROLE_USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } + +// @Bean +// public PasswordEncoder passwordEncoder() { +// return new BCryptPasswordEncoder(); +// } + + // TODO ? + private Customizer> withDefaults() { + // Auto-generated method stub + return null; + } +} diff --git a/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java b/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java new file mode 100644 index 0000000..273eabf --- /dev/null +++ b/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java @@ -0,0 +1,12 @@ +package org.openeo.spring; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportResource; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(securedEnabled = true) +@ImportResource({ "classpath:webSecurityConfig.xml" }) +public class BasicSecurityFromFileConfig {} diff --git a/src/main/java/org/openeo/spring/GlobalSecurityManager.java b/src/main/java/org/openeo/spring/GlobalSecurityManager.java new file mode 100644 index 0000000..0f29f6b --- /dev/null +++ b/src/main/java/org/openeo/spring/GlobalSecurityManager.java @@ -0,0 +1,17 @@ +package org.openeo.spring; + +/** + * Global authentication and authorization manager. + * + * TODO docs + */ +public /*final*/ class GlobalSecurityManager { + + /** API resources that do not require authentication. */ + public static final String[] NOAUTH_API_RESOURCES = new String[] { + "/", + "/conformance", + "/file_formats", + "/.well-known/openeo"}; + +} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/SecurityConfig.java b/src/main/java/org/openeo/spring/SecurityConfig.java index 13bab62..5dc07a4 100644 --- a/src/main/java/org/openeo/spring/SecurityConfig.java +++ b/src/main/java/org/openeo/spring/SecurityConfig.java @@ -2,17 +2,10 @@ import java.io.InputStream; -import javax.servlet.Filter; -import javax.servlet.ServletException; - -import org.hibernate.Hibernate; import org.keycloak.adapters.KeycloakConfigResolver; import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.spi.HttpFacade; -//import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver; -import org.keycloak.adapters.springsecurity.KeycloakConfiguration; -import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents; import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate; @@ -24,32 +17,38 @@ import org.keycloak.adapters.springsecurity.management.HttpSessionManager; import org.openeo.spring.keycloak.OpenEOKeycloakAuthenticationProcessingFilter; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Scope; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.config.annotation.web.HttpSecurityBuilder; 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.core.authority.mapping.SimpleAuthorityMapper; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionRegistryImpl; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; -import org.springframework.security.web.header.HeaderWriterFilter; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; -@Configuration -@EnableWebSecurity(debug = false) -@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) -@KeycloakConfiguration +//@Configuration +//@EnableWebSecurity(debug = false) +//@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) +//@KeycloakConfiguration + +/* ERROR: org.springframework.beans.factory.BeanCreationException +-------------------------------------------------------------- + +org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class + ..cannot define bean with name 'springSecurityFilterChain', because: + "Found WebSecurityConfigurerAdapter as well as SecurityFilterChain. Please select just one." + +--> Keycloak configuration still provides a WebSecurityConfigurerAdapter: needs to be migrated? +"In Spring Security 5.7.0-M2 we deprecated the WebSecurityConfigurerAdapter" [1] + + + [1] https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter + */ public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter { public static final String EURAC_ROLE = "eurac"; diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index ffd7fcd..fa3b8c6 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -6,6 +6,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.openeo.spring.model.Error; +import org.openeo.spring.model.HTTPBasicAccessToken; import org.openeo.spring.model.OpenIDConnectProviders; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; @@ -65,4 +66,13 @@ public ResponseEntity authenticateOidc() { return new ResponseEntity(providers, HttpStatus.OK); } + @GetMapping(value = "/credentials/basic", produces = { "application/json" }) + @Override + public ResponseEntity authenticateBasic() { + /**TODO**/ + // see interface default method example + // token: https://github.com/Open-EO/openeo-wcps-driver/tree/master/src/main/java/eu/openeo/backend/auth/filter + // also: https://github.com/Open-EO/openeo-wcps-driver/blob/master/src/main/java/eu/openeo/api/impl/CredentialsApiServiceImpl.java + return null; + } } diff --git a/src/main/resources/webSecurityConfig.xml b/src/main/resources/webSecurityConfig.xml new file mode 100644 index 0000000..0cd8271 --- /dev/null +++ b/src/main/resources/webSecurityConfig.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + From e9e8de8f3f5790720a03f1c16c4acd839abc4e35 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 11 Jul 2023 15:15:49 +0200 Subject: [PATCH 02/27] Basic authentication with custom authentication provider as a component. --- pom.xml | 24 +- .../openeo/spring/BasicSecurityConfig.java | 144 ++++++++--- .../spring/BasicSecurityFromFileConfig.java | 12 - .../openeo/spring/GlobalSecurityConfig.java | 71 ++++++ .../openeo/spring/GlobalSecurityManager.java | 5 +- .../openeo/spring/KeycloakSecurityConfig.java | 109 ++++++++ .../spring/KeycloakSecurityConfigAdapter.java | 188 ++++++++++++++ .../org/openeo/spring/OpenAPI2SpringBoot.java | 2 + .../org/openeo/spring/SecurityConfig.java | 239 ------------------ .../java/org/openeo/spring/ac/IsUser.java | 15 ++ .../org/openeo/spring/ac/package-info.java | 7 + .../org/openeo/spring/api/CredentialsApi.java | 4 +- .../spring/api/CredentialsApiController.java | 61 ++++- .../openeo/spring/api/JobsApiController.java | 38 ++- .../spring/api/ResultApiController.java | 9 +- .../java/org/openeo/spring/api/TokenUtil.java | 60 ++++- .../CustomAuthenticationProvider.java | 30 +++ .../components/DynamicProviderManager.java | 48 ++++ .../OnOffDaoAuthenticationProvider.java | 53 ++++ .../keycloak/KeycloakLogoutHandler.java | 53 ++++ .../spring/keycloak/KeycloakRestTemplate.java | 7 + ...xedKeycloakAuthenticatedActionsFilter.java | 2 +- .../legacy/KeycloakConfiguration.java | 62 +++++ .../ModifyAuthTokenHeaderRequestWrapper.java | 2 +- ...eycloakAuthenticationProcessingFilter.java | 2 +- .../spring/keycloak/legacy/package-info.java | 9 + src/main/resources/META-INF/spring.factories | 1 + src/main/resources/webSecurityConfig.xml | 18 +- 28 files changed, 934 insertions(+), 341 deletions(-) delete mode 100644 src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java create mode 100644 src/main/java/org/openeo/spring/GlobalSecurityConfig.java create mode 100644 src/main/java/org/openeo/spring/KeycloakSecurityConfig.java create mode 100644 src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java delete mode 100644 src/main/java/org/openeo/spring/SecurityConfig.java create mode 100644 src/main/java/org/openeo/spring/ac/IsUser.java create mode 100644 src/main/java/org/openeo/spring/ac/package-info.java create mode 100644 src/main/java/org/openeo/spring/components/CustomAuthenticationProvider.java create mode 100644 src/main/java/org/openeo/spring/components/DynamicProviderManager.java create mode 100644 src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java create mode 100644 src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java create mode 100644 src/main/java/org/openeo/spring/keycloak/KeycloakRestTemplate.java rename src/main/java/org/openeo/spring/keycloak/{ => legacy}/FixedKeycloakAuthenticatedActionsFilter.java (98%) create mode 100644 src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java rename src/main/java/org/openeo/spring/keycloak/{ => legacy}/ModifyAuthTokenHeaderRequestWrapper.java (98%) rename src/main/java/org/openeo/spring/keycloak/{ => legacy}/OpenEOKeycloakAuthenticationProcessingFilter.java (97%) create mode 100644 src/main/java/org/openeo/spring/keycloak/legacy/package-info.java create mode 100644 src/main/resources/META-INF/spring.factories diff --git a/pom.xml b/pom.xml index 5b71166..caae1a8 100644 --- a/pom.xml +++ b/pom.xml @@ -12,11 +12,14 @@ org.springframework.boot spring-boot-starter-parent - 2.5.1 + 2.6.1 @@ -26,7 +29,7 @@ ${java.version} ${java.version} 3.0.0 - 1.4.6 + 1.5.13 3.4.0 5.2.0 @@ -136,6 +139,7 @@ org.springframework.security spring-security-config + org.keycloak keycloak-spring-security-adapter @@ -146,6 +150,20 @@ keycloak-authz-client ${keycloak.version} + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + org.springdoc springdoc-openapi-ui @@ -154,7 +172,7 @@ io.springfox springfox-boot-starter - 3.0.0 + ${springfox-version} com.fasterxml.jackson.datatype diff --git a/src/main/java/org/openeo/spring/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/BasicSecurityConfig.java index ac80220..0a5fd8c 100644 --- a/src/main/java/org/openeo/spring/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/BasicSecurityConfig.java @@ -1,60 +1,130 @@ package org.openeo.spring; +import static org.openeo.spring.GlobalSecurityManager.BASIC_AUTH_API_RESOURCE; import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; +import static org.openeo.spring.GlobalSecurityManager.OIDC_AUTH_API_RESOURCE; -import org.springframework.security.config.Customizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportResource; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; -import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -//@Configuration -//@EnableWebSecurity -//@EnableGlobalMethodSecurity(securedEnabled = true) +//@Profile(BasicSecurityFromFileConfig.PROFILE_ID) -> better use: +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") +@Configuration +//@ComponentScan("org.openeo.spring.components") +@ImportResource({ "classpath:webSecurityConfig.xml" }) public class BasicSecurityConfig { - //@Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + /** Used to define a {@link Profile}. */ + public static final String PROFILE_ID = "BASIC_AUTH_FILE"; + + /** + * Requires login input on the basic-auth endpoint. + */ + @Bean + public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { http - .authorizeHttpRequests((requests) -> requests + .antMatcher(BASIC_AUTH_API_RESOURCE) + .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() - ) - .httpBasic(); + ) + .httpBasic(); + // .rememberMe(Customizer.withDefaults()); return http.build(); } - - //@Bean - // FIXME import auth-free endpoints from shared non-duplicated source - public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> web.ignoring().antMatchers(NOAUTH_API_RESOURCES); + + /** + * Requires authenticated user on all resources. + * + * NOTE: resources to be ignored by the authorization service are + * configured in {@link #webSecurityCustomizer()}. + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ); + + return http.build(); } - //@Bean - public UserDetailsService userDetailsService() { - UserDetails user = - User.withDefaultPasswordEncoder() - //.passwordEncoder((s1, s2) -> { return passwordEncoder(); }) ? - .username("user") - .password("changeme") - .authorities("ROLE_USER") - .build(); - - return new InMemoryUserDetailsManager(user); + /** + * Sets the resources that do not required security rules. + */ + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web + .ignoring() + .antMatchers(NOAUTH_API_RESOURCES) + .antMatchers(OIDC_AUTH_API_RESOURCE); } + // to encode passwords, use Spring Boot CLI + // $ spring encodepassword "password" + // THIS IS CONFIGURED IN THE XML CONFIG FILE: // @Bean -// public PasswordEncoder passwordEncoder() { -// return new BCryptPasswordEncoder(); +// public UserDetailsService userDetailsService() { +// UserDetails user = User.builder() +// .username("user") +// .password("{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6") +// .roles("USER") +// .build(); +// UserDetails admin = User.builder() +// .username("admin") +// .password("{bcrypt}$2a$10$9BwjekB1vfhiuuHaFCGVOuL0WYUJzrCTz1sw3ZwA.KsU2s09H4uDS") +// .roles("USER", "ADMIN") +// .build(); +// +// return new InMemoryUserDetailsManager(user, admin); // } - // TODO ? - private Customizer> withDefaults() { - // Auto-generated method stub - return null; + /** + * Crypto-encoder for local storage of passwords. + * + * NOTE: this has nothing to do with the basic-auth encoding + * function used in HTTP headers (which shall be Base64 by + * + * Basic HTTP Authentication scheme standard). + */ + @Bean + public PasswordEncoder passwordEncoder() { + // it will detect the {id} of passwords (eg. {bcrypt}) and delegate + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } -} + + @Bean + @Primary + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + /** Overload for {@link #authenticationManager(AuthenticationConfiguration)}. */ +// public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { +// final AuthenticationConfiguration AC = http.getSharedObject(AuthenticationConfiguration.class); +// return authenticationManager(AC); +// } + + /* + * (force my custom Authentication Provider here) + * NO: automatically injected in the manager if it is a @Component + */ +// @Bean +// public AuthenticationManager authManager(HttpSecurity http) throws Exception { +// AuthenticationManagerBuilder authenticationManagerBuilder = +// http.getSharedObject(AuthenticationManagerBuilder.class); +// authenticationManagerBuilder.authenticationProvider(AP); +// return authenticationManagerBuilder.build(); +// } +} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java b/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java deleted file mode 100644 index 273eabf..0000000 --- a/src/main/java/org/openeo/spring/BasicSecurityFromFileConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.openeo.spring; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportResource; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; - -@Configuration -@EnableWebSecurity -@EnableGlobalMethodSecurity(securedEnabled = true) -@ImportResource({ "classpath:webSecurityConfig.xml" }) -public class BasicSecurityFromFileConfig {} diff --git a/src/main/java/org/openeo/spring/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/GlobalSecurityConfig.java new file mode 100644 index 0000000..523a949 --- /dev/null +++ b/src/main/java/org/openeo/spring/GlobalSecurityConfig.java @@ -0,0 +1,71 @@ +package org.openeo.spring; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.WebSecurityConfigurer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +/** + * In order to have multiple authentication mechanisms available + * simultaneously we need a single {@link WebSecurityConfigurer} + * where nested configurations are listed. + * + * This class also dynamically enables/disables authentication + * mechanisms based on the application properties. + * + * Environment post-processors classes need to be registered in the + * {@code META-INF/spring.factories} file. + * + * @see {@code spring.security.enable-basic} + * @see {@code spring.security.enable-keycloak} + */ +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(securedEnabled = true) +@Order(Ordered.LOWEST_PRECEDENCE) +public class GlobalSecurityConfig implements EnvironmentPostProcessor { + + @Value("${spring.security.enable-basic}") + boolean enableBasicAuth; + + @Value("${spring.security.enable-keycloak}") + boolean enableKeycloakAuth; + + /** + * Configured the application environment (e.g. activate profiles + * to dynamically control which configurations/components get actually + * processed by Spring Boot at runtime). + */ + @Override + public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) { + // FIXME @Value not processed yet / not called yet + enableBasicAuth = env.getProperty("spring.security.enable-basic", Boolean.class); + enableKeycloakAuth = env.getProperty("spring.security.enable-keycloak", Boolean.class); + + // just as an exercise: we use @ConditionalOnProperty anyway to control activation of beans + if (enableBasicAuth) { + env.addActiveProfile(BasicSecurityConfig.PROFILE_ID); + } + if (enableKeycloakAuth) { + env.addActiveProfile(KeycloakSecurityConfig.PROFILE_ID); + } + } + + /** + * Recommended authentication mechanism: OIDC/OAuth2 via Keycloak. + */ + @Order(1) + public static class RecommendedSecurityConfig extends KeycloakSecurityConfig {} + + /** + * Optional "basic" (user/password) authentication mechanism. + */ + @Order(2) + public static class OptionalSecurityConfig extends BasicSecurityConfig {} +} diff --git a/src/main/java/org/openeo/spring/GlobalSecurityManager.java b/src/main/java/org/openeo/spring/GlobalSecurityManager.java index 0f29f6b..36493df 100644 --- a/src/main/java/org/openeo/spring/GlobalSecurityManager.java +++ b/src/main/java/org/openeo/spring/GlobalSecurityManager.java @@ -12,6 +12,9 @@ "/", "/conformance", "/file_formats", - "/.well-known/openeo"}; + "/.well-known/**"}; + + public static final String BASIC_AUTH_API_RESOURCE = "/credentials/basic"; + public static final String OIDC_AUTH_API_RESOURCE = "/credentials/oidc"; } \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/KeycloakSecurityConfig.java new file mode 100644 index 0000000..2d6a4a6 --- /dev/null +++ b/src/main/java/org/openeo/spring/KeycloakSecurityConfig.java @@ -0,0 +1,109 @@ +package org.openeo.spring; + +import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; +import static org.openeo.spring.GlobalSecurityManager.OIDC_AUTH_API_RESOURCE; + +import org.openeo.spring.keycloak.KeycloakLogoutHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; + +//@Profile(KeycloakSecurityConfig.PROFILE_ID) -> better use: +@ConditionalOnProperty(prefix="spring.security", value="enable-keycloak") +@Configuration +public class KeycloakSecurityConfig { + + /** Used to define a {@link Profile}. */ + public static final String PROFILE_ID = "KEYCLOAK_AUTH"; + + @Autowired + private KeycloakLogoutHandler keycloakLogoutHandler; + +// KeycloakSecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) { +// this.keycloakLogoutHandler = keycloakLogoutHandler; +// } + + @Bean + protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { + return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); + } + + /** + * Requires login input on the basic-auth endpoint. + * @param http + * @return + * @throws Exception + */ + @Bean + public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { + http + .antMatcher(OIDC_AUTH_API_RESOURCE) + .oauth2Login() + .and() + .logout() + .addLogoutHandler(keycloakLogoutHandler) + .logoutSuccessUrl("/"); + + http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + + return http.build(); + } + + /** + * Requires authenticated user on all resources. + * + * NOTE: resources to be ignored by the authorization service are + * configured in {@link #webSecurityCustomizer()}. +// * + * @param http + * @return + * @throws Exception + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ); + + return http.build(); + } + + /** + * Sets the resources that do not required security rules. + */ + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web + .ignoring() + .antMatchers(NOAUTH_API_RESOURCES); +// .antMatchers(BASIC_AUTH_API_RESOURCE); + } + + /** + * Overrides default API filters set up by Spring Boot auto-configuration. + * Without this, OAuth2 login redirection specified in {@code application.properties} is processed. + * + * @see org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration#OAuth2ClientAutoConfiguration() + * @see Overriding Spring Boot 2.x Auto-configuration + */ + @ConditionalOnProperty(prefix="spring.security", value="enable-keycloak", havingValue="false") + @Configuration + static class SuppressAutoConfigLogin { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // NOOP, but avoid OAuth 2.0 Login auto-configuration + return http.build(); + } + } +} diff --git a/src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java b/src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java new file mode 100644 index 0000000..c2612df --- /dev/null +++ b/src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java @@ -0,0 +1,188 @@ +package org.openeo.spring; + +import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; +import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; +import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate; +import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; +import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticatedActionsFilter; +import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter; +import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter; +import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter; +import org.keycloak.adapters.springsecurity.management.HttpSessionManager; +import org.openeo.spring.keycloak.legacy.OpenEOKeycloakAuthenticationProcessingFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Scope; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; + +//@Configuration +//@EnableWebSecurity(debug = false) +//@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) +//@KeycloakConfiguration +//@Priority(value = 0) +// Keycloak adapters deprecated: https://github.com/keycloak/keycloak/discussions/10187 +// Migration: https://www.baeldung.com/spring-boot-keycloak +// Now with basic-auth component: "Found WebSecurityConfigurerAdapter as well as SecurityFilterChain. Please select just one." +@Deprecated +public class KeycloakSecurityConfigAdapter extends KeycloakWebSecurityConfigurerAdapter { + + @Value("${spring.security.enable-keycloak}") + private boolean enableKeycloakAuth; + + /** FIXME manage authorization with roles/authorities, etc **/ + public static final String EURAC_ROLE = "eurac"; + + private final KeycloakClientRequestFactory keycloakClientRequestFactory; + + public KeycloakSecurityConfigAdapter(KeycloakClientRequestFactory keycloakClientRequestFactory) { + this.keycloakClientRequestFactory = keycloakClientRequestFactory; + + //to use principal and authentication together with @async + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); + } + + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + public KeycloakRestTemplate keycloakRestTemplate() { + return new KeycloakRestTemplate(keycloakClientRequestFactory); + } + + /** + * registers the Keycloakauthenticationprovider in spring context + * and sets its mapping strategy for roles/authorities (mapping to spring seccurities' default ROLE_... for authorities ). + * @param auth SecurityBuilder to build authentications and add details like authproviders etc. + * @throws Exception + */ + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + KeycloakAuthenticationProvider keyCloakAuthProvider = keycloakAuthenticationProvider(); + keyCloakAuthProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); + auth.authenticationProvider(keyCloakAuthProvider); + } + + + /** + * define the session auth strategy so that no session is created + * @return concrete implementation of session authentication strategy + */ + @Bean + @Override + protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { + return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); + } + + + /** + * define the actual constraints of the app. + * @param http + * @throws Exception + */ + @Bean + @Primary + @Override + protected KeycloakAuthenticationProcessingFilter keycloakAuthenticationProcessingFilter() throws Exception { + KeycloakAuthenticationProcessingFilter filter = new OpenEOKeycloakAuthenticationProcessingFilter(authenticationManagerBean()); + filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy()); + return filter; + } + + + /** + * Avoid Bean redefinition + */ + // @Bean + // public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean( + // KeycloakAuthenticationProcessingFilter filter) { + // FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); + // registrationBean.setEnabled(false); + // return registrationBean; + // } + + + @Bean + public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean( + KeycloakPreAuthActionsFilter filter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); + registrationBean.setEnabled(false); + return registrationBean; + } + + + @Bean + public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean( + KeycloakAuthenticatedActionsFilter filter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); + registrationBean.setEnabled(false); + return registrationBean; + } + + @Bean + public FilterRegistrationBean keycloakSecurityContextRequestFilterBean( + KeycloakSecurityContextRequestFilter filter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); + registrationBean.setEnabled(false); + return registrationBean; + } + + @Bean + @Override + @ConditionalOnMissingBean(HttpSessionManager.class) + protected HttpSessionManager httpSessionManager() { + return new HttpSessionManager(); + } + + + + @Override + protected void configure(HttpSecurity http) throws Exception { + super.configure(http); + http. + csrf(). + disable(). + authorizeRequests(). + // antMatchers("/**"). + // permitAll(); + + // antMatchers("/collections").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/collections/{collection_id}").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/jobs/*").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/services/*").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/files/*").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/me").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/process_graphs/*").hasAnyRole(EURAC_ROLE, "public"). + // antMatchers("/result").hasAnyRole(EURAC_ROLE, "public"). + + //***we haven't decided about their authentication**// + + //antMatchers("//service_types").hasAnyRole(EURAC_ROLE, "public"). + //antMatchers("/file_formats").hasAnyRole(EURAC_ROLE, "public"). + //antMatchers("/udf_runtimes").hasAnyRole(EURAC_ROLE, "public"). + //antMatchers("/processes").hasAnyRole(EURAC_ROLE, "public"). + //antMatchers("/validation").hasAnyRole(EURAC_ROLE, "public"). + anyRequest(). + permitAll(); + http.headers().frameOptions().disable(); + + } + +} + + + + + + + + + diff --git a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java index a55b56f..716a20e 100644 --- a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java +++ b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java @@ -10,10 +10,12 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import com.fasterxml.jackson.databind.Module; +@EnableWebMvc @SpringBootApplication(exclude = {HibernateJpaAutoConfiguration.class}) @ComponentScan(basePackages = {"org.openeo.spring" , "org.openapitools.configuration", "org.openeo.wcps", "org.openeo.spring.api"}) public class OpenAPI2SpringBoot implements CommandLineRunner { diff --git a/src/main/java/org/openeo/spring/SecurityConfig.java b/src/main/java/org/openeo/spring/SecurityConfig.java deleted file mode 100644 index 5dc07a4..0000000 --- a/src/main/java/org/openeo/spring/SecurityConfig.java +++ /dev/null @@ -1,239 +0,0 @@ -package org.openeo.spring; - -import java.io.InputStream; - -import org.keycloak.adapters.KeycloakConfigResolver; -import org.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.KeycloakDeploymentBuilder; -import org.keycloak.adapters.spi.HttpFacade; -import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; -import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; -import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate; -import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; -import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticatedActionsFilter; -import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter; -import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter; -import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter; -import org.keycloak.adapters.springsecurity.management.HttpSessionManager; -import org.openeo.spring.keycloak.OpenEOKeycloakAuthenticationProcessingFilter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.context.annotation.Scope; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.session.SessionRegistryImpl; -import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; -import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; - -//@Configuration -//@EnableWebSecurity(debug = false) -//@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) -//@KeycloakConfiguration - -/* ERROR: org.springframework.beans.factory.BeanCreationException --------------------------------------------------------------- - -org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class - ..cannot define bean with name 'springSecurityFilterChain', because: - "Found WebSecurityConfigurerAdapter as well as SecurityFilterChain. Please select just one." - ---> Keycloak configuration still provides a WebSecurityConfigurerAdapter: needs to be migrated? -"In Spring Security 5.7.0-M2 we deprecated the WebSecurityConfigurerAdapter" [1] - - - [1] https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter - */ -public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter { - - public static final String EURAC_ROLE = "eurac"; - - private final KeycloakClientRequestFactory keycloakClientRequestFactory; - - - public SecurityConfig(KeycloakClientRequestFactory keycloakClientRequestFactory) { - this.keycloakClientRequestFactory = keycloakClientRequestFactory; - - //to use principal and authentication together with @async - SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); - } - - @Bean - @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) - public KeycloakRestTemplate keycloakRestTemplate() { - return new KeycloakRestTemplate(keycloakClientRequestFactory); - } - - - - /** - * registers the Keycloakauthenticationprovider in spring context - * and sets its mapping strategy for roles/authorities (mapping to spring seccurities' default ROLE_... for authorities ). - * @param auth SecurityBuilder to build authentications and add details like authproviders etc. - * @throws Exception - */ - @Autowired - public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { - KeycloakAuthenticationProvider keyCloakAuthProvider = keycloakAuthenticationProvider(); - keyCloakAuthProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); - auth.authenticationProvider(keyCloakAuthProvider); - } - - - -// /** -// * Sets keycloaks config resolver to use springs application.properties instead of keycloak.json (which is standard) -// * @return -// */ -// @Bean -// public KeycloakConfigResolver KeycloakConfigResolver() { -// return new KeycloakSpringBootConfigResolver(); -// } -// - @Bean - public KeycloakConfigResolver keycloakConfigResolver() { - return new KeycloakConfigResolver() { - - private KeycloakDeployment keycloakDeployment; - - @Override - public KeycloakDeployment resolve(HttpFacade.Request facade) { - if (keycloakDeployment != null) { - return keycloakDeployment; - } - - String path = "/keycloak.json"; - InputStream configInputStream = getClass().getResourceAsStream(path); - - if (configInputStream == null) { - throw new RuntimeException("Could not load Keycloak deployment info: " + path); - } else { - keycloakDeployment = KeycloakDeploymentBuilder.build(configInputStream); - } - - return keycloakDeployment; - } - }; - } - - /** - * define the session auth strategy so that no session is created - * @return concrete implementation of session authentication strategy - */ - @Bean - @Override - protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { - return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); - } - - - /** - * define the actual constraints of the app. - * @param http - * @throws Exception - */ - - - @Bean - @Primary - @Override - protected KeycloakAuthenticationProcessingFilter keycloakAuthenticationProcessingFilter() throws Exception { - KeycloakAuthenticationProcessingFilter filter = new OpenEOKeycloakAuthenticationProcessingFilter(authenticationManagerBean()); - filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy()); - return filter; - } - - - /** - * Avoid Bean redefinition - */ -// @Bean -// public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean( -// KeycloakAuthenticationProcessingFilter filter) { -// FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); -// registrationBean.setEnabled(false); -// return registrationBean; -// } - - - @Bean - public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean( - KeycloakPreAuthActionsFilter filter) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); - registrationBean.setEnabled(false); - return registrationBean; - } - - - @Bean - public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean( - KeycloakAuthenticatedActionsFilter filter) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); - registrationBean.setEnabled(false); - return registrationBean; - } - - @Bean - public FilterRegistrationBean keycloakSecurityContextRequestFilterBean( - KeycloakSecurityContextRequestFilter filter) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); - registrationBean.setEnabled(false); - return registrationBean; - } - - @Bean - @Override - @ConditionalOnMissingBean(HttpSessionManager.class) - protected HttpSessionManager httpSessionManager() { - return new HttpSessionManager(); - } - - - - @Override - protected void configure(HttpSecurity http) throws Exception { - super.configure(http); - http. - csrf(). - disable(). - authorizeRequests(). -// antMatchers("/**"). -// permitAll(); - -// antMatchers("/collections").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/collections/{collection_id}").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/jobs/*").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/services/*").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/files/*").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/me").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/process_graphs/*").hasAnyRole(EURAC_ROLE, "public"). -// antMatchers("/result").hasAnyRole(EURAC_ROLE, "public"). - - //***we haven't decided about their authentication**// - - //antMatchers("//service_types").hasAnyRole(EURAC_ROLE, "public"). - //antMatchers("/file_formats").hasAnyRole(EURAC_ROLE, "public"). - //antMatchers("/udf_runtimes").hasAnyRole(EURAC_ROLE, "public"). - //antMatchers("/processes").hasAnyRole(EURAC_ROLE, "public"). - //antMatchers("/validation").hasAnyRole(EURAC_ROLE, "public"). - anyRequest(). - permitAll(); - http.headers().frameOptions().disable(); - - } - -} - - - - - - - - - diff --git a/src/main/java/org/openeo/spring/ac/IsUser.java b/src/main/java/org/openeo/spring/ac/IsUser.java new file mode 100644 index 0000000..d2ea1ec --- /dev/null +++ b/src/main/java/org/openeo/spring/ac/IsUser.java @@ -0,0 +1,15 @@ +package org.openeo.spring.ac; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.access.prepost.PreAuthorize; + +/** @see Spring Method Security*/ + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("hasRole('ROLE_USER')") +public @interface IsUser {} diff --git a/src/main/java/org/openeo/spring/ac/package-info.java b/src/main/java/org/openeo/spring/ac/package-info.java new file mode 100644 index 0000000..fe7a8ad --- /dev/null +++ b/src/main/java/org/openeo/spring/ac/package-info.java @@ -0,0 +1,7 @@ +package org.openeo.spring.ac; + +/** + * This package contains the classes and interfaces that + * supports the "access control", thus the authorization + * mechanisms throughout the application. + */ \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/api/CredentialsApi.java b/src/main/java/org/openeo/spring/api/CredentialsApi.java index 6e4e363..bdb9994 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApi.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApi.java @@ -7,8 +7,6 @@ import java.util.Optional; -import org.openeo.spring.model.HTTPBasicAccessToken; -import org.openeo.spring.model.OpenIDConnectProviders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -45,7 +43,7 @@ default Optional getRequest() { @RequestMapping(value = "/credentials/basic", produces = { "application/json" }, method = RequestMethod.GET) - default ResponseEntity authenticateBasic() { + default ResponseEntity authenticateBasic() { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index fa3b8c6..1a42787 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -1,5 +1,7 @@ package org.openeo.spring.api; +import static org.junit.jupiter.api.Assertions.assertFalse; + import java.io.IOException; import java.util.Optional; @@ -31,11 +33,18 @@ public class CredentialsApiController implements CredentialsApi { @Value("${org.openeo.oidc.providers.list}") private Resource oidcProvidersFile; + @Value("${spring.security.enable-basic}") + boolean enableBasicAuth; + + @Value("${spring.security.enable-keycloak}") + boolean enableKeycloakAuth; + @org.springframework.beans.factory.annotation.Autowired public CredentialsApiController(NativeWebRequest request) { this.request = request; } + @Override public Optional getRequest() { return Optional.ofNullable(request); @@ -44,12 +53,23 @@ public Optional getRequest() { @GetMapping(value = "/credentials/oidc", produces = { "application/json" }) @Override public ResponseEntity authenticateOidc() { - ObjectMapper mapper = new ObjectMapper(); + ResponseEntity resp; - OpenIDConnectProviders providers; try { - log.debug(oidcProvidersFile.getFilename()); - providers = mapper.readValue(oidcProvidersFile.getInputStream(), OpenIDConnectProviders.class); + OpenIDConnectProviders providers = new OpenIDConnectProviders(); + if (enableKeycloakAuth) { + log.debug(oidcProvidersFile.getFilename()); + ObjectMapper mapper = new ObjectMapper(); + providers = mapper.readValue(oidcProvidersFile.getInputStream(), OpenIDConnectProviders.class); + assertFalse(providers.getProviders().isEmpty()); + resp = ResponseEntity.ok(providers); + } else { + log.debug("OIDC authentication is disabled."); + Error error = new Error(); + error.setCode("501"); + error.setMessage("OIDC authentication is not enabled."); + resp = ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(error); + } } catch (IOException e) { log.error("The list of oidc providers is currently not available! " + e.getMessage()); StringBuilder builder = new StringBuilder(); @@ -60,19 +80,44 @@ public ResponseEntity authenticateOidc() { Error error = new Error(); error.setCode("500"); error.setMessage("The list of oidc providers is currently not available!"); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + resp = ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } - return new ResponseEntity(providers, HttpStatus.OK); + return resp; } @GetMapping(value = "/credentials/basic", produces = { "application/json" }) @Override - public ResponseEntity authenticateBasic() { + // FIXME handle errors elsewhere and keep HTTPBasicAccessToken response type? + public ResponseEntity authenticateBasic() { + ResponseEntity resp; +// getRequest().ifPresent(request -> { +// for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { +// if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { +// // fetch access token +// String user = request.getRemoteUser(); +// String accessToken = String.format("{ \"access_token\" : \"{}\" }", user); // FIXME token +// ApiUtil.setExampleResponse(request, "application/json", accessToken); // FIXME +// break; +// } +// } +// }); + if (enableBasicAuth) { + String username = request.getUserPrincipal().getName(); + String token = TokenUtil.getBAAccessToken(request.getUserPrincipal()); + log.debug("Access token for user {}: {}", username, token); + resp = ResponseEntity.ok(new HTTPBasicAccessToken().accessToken(token)); + } else { + Error error = new Error(); + error.setCode("501"); + error.setMessage("Basic authentication mechanism not supported by the server."); + resp = ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(error); + } + return resp; + /**TODO**/ // see interface default method example // token: https://github.com/Open-EO/openeo-wcps-driver/tree/master/src/main/java/eu/openeo/backend/auth/filter // also: https://github.com/Open-EO/openeo-wcps-driver/blob/master/src/main/java/eu/openeo/api/impl/CredentialsApiServiceImpl.java - return null; } } diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index a8b178d..84d3710 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -1,5 +1,7 @@ package org.openeo.spring.api; +import static org.openeo.spring.KeycloakSecurityConfigAdapter.EURAC_ROLE; + import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; @@ -75,8 +77,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import static org.openeo.spring.SecurityConfig.EURAC_ROLE; - @javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-07-02T08:45:00.334+02:00[Europe/Rome]") @RestController @RequestMapping("${openapi.openEO.base-path:}") @@ -178,7 +178,8 @@ public Optional getRequest() { * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Create a new batch job", operationId = "createJob", description = "Creates a new batch processing task (job) from one or more (chained) processes at the back-end. Processing the data doesn't start yet. The job status gets initialized as `created` by default.", tags = { + @Override + @Operation(summary = "Create a new batch job", operationId = "createJob", description = "Creates a new batch processing task (job) from one or more (chained) processes at the back-end. Processing the data doesn't start yet. The job status gets initialized as `created` by default.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "The batch job has been created successfully."), @@ -191,7 +192,7 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) AccessToken token = null; if(principal != null) { - token = TokenUtil.getAccessToken(principal); + token = TokenUtil.getKCAccessToken(principal); job.setOwnerPrincipal(token.getPreferredUsername()); ThreadContext.put("userid", token.getPreferredUsername()); } @@ -368,7 +369,8 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Logs for a batch job", operationId = "debugJob", description = "Shows log entries for the batch job, usually for debugging purposes. Back-ends can log any information that may be relevant for a user. Users can log information during data processing using respective processes such as `debug`. If requested consecutively while a job is running, it is RECOMMENDED that clients use the offset parameter to get only the entries they have not received yet. While pagination itself is OPTIONAL, the `offset` parameter is REQUIRED to be implemented by back-ends.", tags = { + @Override + @Operation(summary = "Logs for a batch job", operationId = "debugJob", description = "Shows log entries for the batch job, usually for debugging purposes. Back-ends can log any information that may be relevant for a user. Users can log information during data processing using respective processes such as `debug`. If requested consecutively while a job is running, it is RECOMMENDED that clients use the offset parameter to get only the entries they have not received yet. While pagination itself is OPTIONAL, the `offset` parameter is REQUIRED to be implemented by back-ends.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Lists the requested log entries."), @ApiResponse(responseCode = "400", description = "The request can't be fulfilled due to an error on client-side, i.e. the request is invalid. The client should not repeat the request without modifications. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). This request MUST respond with HTTP status codes 401 if authorization is required or 403 if the authorization failed or access is forbidden in general to the authenticated user. HTTP status code 404 should be used if the value of a path parameter is invalid. See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)"), @@ -494,7 +496,8 @@ public ResponseEntity debugJob( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Delete a batch job", operationId = "deleteJob", description = "Deletes all data related to this job. Computations are stopped and computed results are deleted. This job won't generate additional costs for processing.", tags = { + @Override + @Operation(summary = "Delete a batch job", operationId = "deleteJob", description = "Deletes all data related to this job. Computations are stopped and computed results are deleted. This job won't generate additional costs for processing.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "The job has been successfully deleted."), @ApiResponse(responseCode = "400", description = "The request can't be fulfilled due to an error on client-side, i.e. the request is invalid. The client should not repeat the request without modifications. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). This request MUST respond with HTTP status codes 401 if authorization is required or 403 if the authorization failed or access is forbidden in general to the authenticated user. HTTP status code 404 should be used if the value of a path parameter is invalid. See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)"), @@ -561,7 +564,8 @@ public ResponseEntity deleteJob( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Full metadata for a batch job", operationId = "describeJob", description = "Returns all information about a submitted batch job.", tags = { + @Override + @Operation(summary = "Full metadata for a batch job", operationId = "describeJob", description = "Returns all information about a submitted batch job.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Full job information."), @ApiResponse(responseCode = "400", description = "The request can't be fulfilled due to an error on client-side, i.e. the request is invalid. The client should not repeat the request without modifications. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). This request MUST respond with HTTP status codes 401 if authorization is required or 403 if the authorization failed or access is forbidden in general to the authenticated user. HTTP status code 404 should be used if the value of a path parameter is invalid. See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)"), @@ -619,7 +623,8 @@ public ResponseEntity describeJob( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Get an estimate for a batch job", operationId = "estimateJob", description = "Clients can ask for an estimate for a batch job. Back-ends can decide to either calculate the duration, the costs, the size or a combination of them. This MUST be the upper limit of the incurring costs. Clients can be charged less than specified, but never more. Back-end providers MAY specify an expiry time for the estimate. Starting to process data afterwards MAY be charged at a higher cost. Costs MAY NOT include downloading costs. This can be indicated with the `downloads_included` flag.", tags = { + @Override + @Operation(summary = "Get an estimate for a batch job", operationId = "estimateJob", description = "Clients can ask for an estimate for a batch job. Back-ends can decide to either calculate the duration, the costs, the size or a combination of them. This MUST be the upper limit of the incurring costs. Clients can be charged less than specified, but never more. Back-end providers MAY specify an expiry time for the estimate. Starting to process data afterwards MAY be charged at a higher cost. Costs MAY NOT include downloading costs. This can be indicated with the `downloads_included` flag.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "The estimated costs with regard to money, processing time and storage capacity. At least one of `costs`, `duration` or `size` MUST be provided."), @@ -688,7 +693,8 @@ public ResponseEntity estimateJob( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "List all batch jobs", operationId = "listJobs", description = "Requests to this endpoint will list all batch jobs submitted by a user. It is **strongly RECOMMENDED** to keep the response size small by omitting all optional non-scalar values from objects in `jobs` (i.e. the `process` property). To get the full metadata for a job clients MUST request `GET /jobs/{job_id}`.", tags = { + @Override + @Operation(summary = "List all batch jobs", operationId = "listJobs", description = "Requests to this endpoint will list all batch jobs submitted by a user. It is **strongly RECOMMENDED** to keep the response size small by omitting all optional non-scalar values from objects in `jobs` (i.e. the `process` property). To get the full metadata for a job clients MUST request `GET /jobs/{job_id}`.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Array of job descriptions"), @ApiResponse(responseCode = "400", description = "The request can't be fulfilled due to an error on client-side, i.e. the request is invalid. The client should not repeat the request without modifications. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). This request MUST respond with HTTP status codes 401 if authorization is required or 403 if the authorization failed or access is forbidden in general to the authenticated user. HTTP status code 404 should be used if the value of a path parameter is invalid. See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)"), @@ -697,7 +703,7 @@ public ResponseEntity estimateJob( public ResponseEntity listJobs( @Min(1) @Parameter(description = "This parameter enables pagination for the endpoint and specifies the maximum number of elements that arrays in the top-level object (e.g. jobs or log entries) are allowed to contain. The only exception is the `links` array, which MUST NOT be paginated as otherwise the pagination links may be missing ins responses. If the parameter is not provided or empty, all elements are returned. Pagination is OPTIONAL and back-ends and clients may not support it. Therefore it MUST be implemented in a way that clients not supporting pagination get all resources regardless. Back-ends not supporting pagination will return all resources. If the response is paginated, the links array MUST be used to propagate the links for pagination with pre-defined `rel` types. See the links array schema for supported `rel` types. *Note:* Implementations can use all kind of pagination techniques, depending on what is supported best by their infrastructure. So it doesn't care whether it is page-based, offset-based or uses tokens for pagination. The clients will use whatever is specified in the links with the corresponding `rel` types.") @Valid @RequestParam(value = "limit", required = false) Integer limit, Principal principal) { - AccessToken token = TokenUtil.getAccessToken(principal); + AccessToken token = TokenUtil.getKCAccessToken(principal); String username = token.getPreferredUsername(); BatchJobs batchJobs = new BatchJobs(); @@ -768,7 +774,8 @@ public ResponseEntity listJobs( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Download results for a completed batch job", operationId = "listResults", description = "After finishing processing, this request will provide signed URLs to the processed files of the batch job with some additional metadata. The response is a [STAC Item (version 0.9.0)](https://github.com/radiantearth/stac-spec/tree/v0.9.0/item-spec) if it has spatial and temporal references included. URL signing is a way to protect files from unauthorized access with a key in the URL instead of HTTP header based authorization. The URL signing key is similar to a password and its inclusion in the URL allows to download files using simple GET requests supported by a wide range of programs, e.g. web browsers or download managers. Back-ends are responsible to generate the URL signing keys and to manage their appropriate expiration. The back-end MAY indicate an expiration time by setting the `expires` property. If processing has not finished yet requests to this endpoint MUST be rejected with openEO error `JobNotFinished`.", tags = { + @Override + @Operation(summary = "Download results for a completed batch job", operationId = "listResults", description = "After finishing processing, this request will provide signed URLs to the processed files of the batch job with some additional metadata. The response is a [STAC Item (version 0.9.0)](https://github.com/radiantearth/stac-spec/tree/v0.9.0/item-spec) if it has spatial and temporal references included. URL signing is a way to protect files from unauthorized access with a key in the URL instead of HTTP header based authorization. The URL signing key is similar to a password and its inclusion in the URL allows to download files using simple GET requests supported by a wide range of programs, e.g. web browsers or download managers. Back-ends are responsible to generate the URL signing keys and to manage their appropriate expiration. The back-end MAY indicate an expiration time by setting the `expires` property. If processing has not finished yet requests to this endpoint MUST be rejected with openEO error `JobNotFinished`.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Valid download links have been returned. The download links doesn't necessarily need to be located under the API base url."), @@ -880,7 +887,8 @@ public ResponseEntity downloadResult( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Start processing a batch job", operationId = "startJob", description = "Adds a batch job to the processing queue to compute the results. The result will be stored in the format specified in the process graph. To specify the format use a process such as `save_result`. This endpoint has no effect if the job status is already `queued` or `running`. In particular, it doesn't restart a running job. Processing MUST be canceled before to restart it. The job status is set to `queued`, if processing doesn't start instantly. * Once the processing starts the status is set to `running`. * Once the data is available to download the status is set to `finished`. * Whenever an error occurs during processing, the status must be set to `error`.", tags = { + @Override + @Operation(summary = "Start processing a batch job", operationId = "startJob", description = "Adds a batch job to the processing queue to compute the results. The result will be stored in the format specified in the process graph. To specify the format use a process such as `save_result`. This endpoint has no effect if the job status is already `queued` or `running`. In particular, it doesn't restart a running job. Processing MUST be canceled before to restart it. The job status is set to `queued`, if processing doesn't start instantly. * Once the processing starts the status is set to `running`. * Once the data is available to download the status is set to `finished`. * Whenever an error occurs during processing, the status must be set to `error`.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "202", description = "The creation of the resource has been queued successfully."), @@ -967,7 +975,8 @@ public ResponseEntity startJob( * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Cancel processing a batch job", operationId = "stopJob", description = "Cancels all related computations for this job at the back-end. It will stop generating additional costs for processing. A subset of processed results may be available for downloading depending on the state of the job as it was canceled. Finished results MUST NOT be deleted until the job is deleted or job processing is started again. This endpoint only has an effect if the job status is `queued` or `running`. The job status is set to `canceled` if the status was `running` beforehand and partial or preliminary results are available to be downloaded. Otherwise the status is set to `created`. ", tags = { + @Override + @Operation(summary = "Cancel processing a batch job", operationId = "stopJob", description = "Cancels all related computations for this job at the back-end. It will stop generating additional costs for processing. A subset of processed results may be available for downloading depending on the state of the job as it was canceled. Finished results MUST NOT be deleted until the job is deleted or job processing is started again. This endpoint only has an effect if the job status is `queued` or `running`. The job status is set to `canceled` if the status was `running` beforehand and partial or preliminary results are available to be downloaded. Otherwise the status is set to `created`. ", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Processing the job has been successfully canceled."), @@ -1041,7 +1050,8 @@ else if(job.getStatus()==JobStates.QUEUED) { * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Modify a batch job", operationId = "updateJob", description = "Modifies an existing job at the back-end but maintains the identifier. Changes can be grouped in a single request. Jobs can only be modified when the job is not queued or running. Otherwise requests to this endpoint MUST be rejected with openEO error `JobLocked`.", tags = { + @Override + @Operation(summary = "Modify a batch job", operationId = "updateJob", description = "Modifies an existing job at the back-end but maintains the identifier. Changes can be grouped in a single request. Jobs can only be modified when the job is not queued or running. Otherwise requests to this endpoint MUST be rejected with openEO error `JobLocked`.", tags = { "Data Processing", "Batch Jobs", }) @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Changes to the job applied successfully."), diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index b3542d6..1fdc4e2 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -1,5 +1,7 @@ package org.openeo.spring.api; +import static org.openeo.spring.KeycloakSecurityConfigAdapter.EURAC_ROLE; + import java.io.BufferedReader; import java.io.File; import java.io.IOException; @@ -63,8 +65,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import static org.openeo.spring.SecurityConfig.EURAC_ROLE; - @javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-07-02T08:45:00.334+02:00[Europe/Rome]") @Component @RestController @@ -116,7 +116,8 @@ public Optional getRequest() { return Optional.ofNullable(request); } - @Operation(summary = "Process and download data synchronously", operationId = "computeResult", description = "A user-defined process will be executed directly and the result will be downloaded in the format specified in the process graph. This endpoint can be used to generate small previews or test user-defined processes before starting a batch job. Timeouts on either client- or server-side are to be expected for complex computations. Back-ends MAY send the openEO error `ProcessGraphComplexity` immediately if the computation is expected to time out. Otherwise requests MAY time-out after a certain amount of time by sending openEO error `RequestTimeout`. A header named `OpenEO-Costs` MAY be sent with all responses, which MUST include the costs for processing and downloading the data. Additionally, a link to a log file MAY be sent in the header.", tags = { + @Override + @Operation(summary = "Process and download data synchronously", operationId = "computeResult", description = "A user-defined process will be executed directly and the result will be downloaded in the format specified in the process graph. This endpoint can be used to generate small previews or test user-defined processes before starting a batch job. Timeouts on either client- or server-side are to be expected for complex computations. Back-ends MAY send the openEO error `ProcessGraphComplexity` immediately if the computation is expected to time out. Otherwise requests MAY time-out after a certain amount of time by sending openEO error `RequestTimeout`. A header named `OpenEO-Costs` MAY be sent with all responses, which MUST include the costs for processing and downloading the data. Additionally, a link to a log file MAY be sent in the header.", tags = { "Data Processing", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Result data in the requested output format"), @@ -129,7 +130,7 @@ public ResponseEntity computeResult(@Parameter(description = "", required = t AccessToken token = null; if(principal != null) { - token = TokenUtil.getAccessToken(principal); + token = TokenUtil.getKCAccessToken(principal); } Set roles = new HashSet<>(); diff --git a/src/main/java/org/openeo/spring/api/TokenUtil.java b/src/main/java/org/openeo/spring/api/TokenUtil.java index 10c017f..4409549 100644 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ b/src/main/java/org/openeo/spring/api/TokenUtil.java @@ -2,13 +2,67 @@ import java.security.Principal; +import org.keycloak.adapters.OidcKeycloakAccount; import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.keycloak.representations.AccessToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +/** + * Utilities for fetching user sessions' access tokens. + */ +// TODOs unique method: getAccessToken(Principal p) public class TokenUtil { + + private TokenUtil() {}; - public static AccessToken getAccessToken(Principal principal) { - KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal; - return keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken(); + /** + * FEtches the Keycloak (KC) access token of a given user. + * @param principal the user asking for the token + * @return the access token; {@code null} when not found (or with {@code null} input) + */ + public static AccessToken getKCAccessToken(Principal principal) { + if (null == principal) { + return null; + } + if (!(principal instanceof KeycloakAuthenticationToken)) { + return null; + } + + KeycloakAuthenticationToken kcPrincipal = (KeycloakAuthenticationToken) principal; + OidcKeycloakAccount account = kcPrincipal.getAccount(); + AccessToken token = account.getKeycloakSecurityContext().getToken(); + + return token; } + + /** + * Fetches the Basic-Authentication (BA) access token of a given user. + * @param principal the user asking for the token + * @return the access token; {@code null} when not found (or with {@code null} input) + */ + public static String getBAAccessToken(Principal principal) { + if (null == principal) { + return null; + } + + String token = null; + Authentication authentication = SecurityContextHolder.getContext().getAuthentication();//? which one? + // 1 authentication.getPrincipal() -> instanceof User + // 2 get username() then: + String givenUser = principal.getName(); + String loggedUser = .toString(); + User loggedUser = + + if (givenUser.equals(loggedUser)) { + token = authentication.getName(); + } else { + // TODO + // throw internal error exception? given user != logged user + // or just return null? + } + + return token; + } } diff --git a/src/main/java/org/openeo/spring/components/CustomAuthenticationProvider.java b/src/main/java/org/openeo/spring/components/CustomAuthenticationProvider.java new file mode 100644 index 0000000..22379f7 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/CustomAuthenticationProvider.java @@ -0,0 +1,30 @@ +package org.openeo.spring.components; + +import java.util.ArrayList; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +/** + * @see https://xinghua24.github.io/SpringSecurity/Spring-Security-Custom-AuthenticationProvider/ + */ +//@Component +public class CustomAuthenticationProvider implements AuthenticationProvider { + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String name = authentication.getName(); + String password = authentication.getCredentials().toString(); + if(name.equals("user") && password.equals("password")) { + return new UsernamePasswordAuthenticationToken(name, password, new ArrayList<>()); + } + throw new UsernameNotFoundException(name+ " not found."); + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } +} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/components/DynamicProviderManager.java b/src/main/java/org/openeo/spring/components/DynamicProviderManager.java new file mode 100644 index 0000000..109da22 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/DynamicProviderManager.java @@ -0,0 +1,48 @@ +package org.openeo.spring.components; + +import java.util.Collections; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; + +/** + * Authentication manager that dynamically filters the available + * authentication providers based on the application configuration + * (properties). + */ +//@Component +public class DynamicProviderManager extends ProviderManager { + + private boolean enabled; + + public DynamicProviderManager(AuthenticationProvider... providers) { + super(providers); + } + +// public DynamicProviderManager(AuthenticationManager parent) { +// super(Collections.emptyList(), parent); +// setEnabled(true); +// } + + public void setEnabled(boolean b) { + this.enabled = b; + } + + public boolean getEnabled() { + return enabled; + } + + // enable @value properties injection + @Bean + public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + @Override + public List getProviders() { + return getEnabled() ? super.getProviders() : Collections.emptyList(); + } +} diff --git a/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java b/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java new file mode 100644 index 0000000..dd59276 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java @@ -0,0 +1,53 @@ +package org.openeo.spring.components; + +import org.openeo.spring.BasicSecurityConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; + +/** + * DAO authentication provider that can be enabled/disabled at runtime. + * + * Deprecated in favor of annotation-based runtime conditional activations. + * @see ConditionalOnProperty + */ +//@Component +@Deprecated +public class OnOffDaoAuthenticationProvider extends DaoAuthenticationProvider { + + + /** Switch to enable/disable this authentication provider at runtime. */ + private boolean enabled; + + public OnOffDaoAuthenticationProvider(BasicSecurityConfig config) { + super(); +// this.setUserDetailsService(config.userDetailsService()); + this.setPasswordEncoder(config.passwordEncoder()); + + // from: org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer +// UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class); + +// provider.setUserDetailsPasswordService(passwordManager); + +// setEnabled(config.getEnabled()); // better use @ConditionalOnProperty annotation on the config + } + + /** + * Tells whether this provider is enabled (or disabled). + */ + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean b) { + this.enabled = b; + } + + @Override + public boolean supports(Class authentication) { + boolean out = false; + if (isEnabled() || true) { // FIXME testing whether manipulating filterchains is enough to disable + out = super.supports(authentication); + } + return out; + } +} diff --git a/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java new file mode 100644 index 0000000..5d4fc33 --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java @@ -0,0 +1,53 @@ +package org.openeo.spring.keycloak; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +// see: https://www.baeldung.com/spring-boot-keycloak +public class KeycloakLogoutHandler implements LogoutHandler { + + private static final Logger logger = LogManager.getLogger(KeycloakLogoutHandler.class); + + @Autowired + private RestTemplate restTemplate; + + // TODO circular dependency issue (use KeycloakRestTemplate as workaround) +// @Bean +// public RestTemplate restTemplate(RestTemplateBuilder builder) { +// return builder.build(); +// } + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication auth) { + logoutFromKeycloak((OidcUser) auth.getPrincipal()); + } + + private void logoutFromKeycloak(OidcUser user) { + String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout"; + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(endSessionEndpoint) + .queryParam("id_token_hint", user.getIdToken().getTokenValue()); + + ResponseEntity logoutResponse = restTemplate.getForEntity( + builder.toUriString(), String.class); + if (logoutResponse.getStatusCode().is2xxSuccessful()) { + logger.info("Successfulley logged out from Keycloak"); + } else { + logger.error("Could not propagate logout to Keycloak"); + } + } + +} diff --git a/src/main/java/org/openeo/spring/keycloak/KeycloakRestTemplate.java b/src/main/java/org/openeo/spring/keycloak/KeycloakRestTemplate.java new file mode 100644 index 0000000..b9ae66a --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/KeycloakRestTemplate.java @@ -0,0 +1,7 @@ +package org.openeo.spring.keycloak; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class KeycloakRestTemplate extends RestTemplate {} diff --git a/src/main/java/org/openeo/spring/keycloak/FixedKeycloakAuthenticatedActionsFilter.java b/src/main/java/org/openeo/spring/keycloak/legacy/FixedKeycloakAuthenticatedActionsFilter.java similarity index 98% rename from src/main/java/org/openeo/spring/keycloak/FixedKeycloakAuthenticatedActionsFilter.java rename to src/main/java/org/openeo/spring/keycloak/legacy/FixedKeycloakAuthenticatedActionsFilter.java index c9fa4ca..beab745 100644 --- a/src/main/java/org/openeo/spring/keycloak/FixedKeycloakAuthenticatedActionsFilter.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/FixedKeycloakAuthenticatedActionsFilter.java @@ -1,4 +1,4 @@ -package org.openeo.spring.keycloak; +package org.openeo.spring.keycloak.legacy; import java.io.IOException; diff --git a/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java new file mode 100644 index 0000000..221b24e --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java @@ -0,0 +1,62 @@ +package org.openeo.spring.keycloak.legacy; + +import java.io.InputStream; + +import org.keycloak.adapters.KeycloakConfigResolver; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.spi.HttpFacade; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Provides the configuation resolver to the Keycloak auth module. + */ +// Separate file to avoid circular deps with Spring Boot 2.6.x +// https://stackoverflow.com/questions/70207564/spring-boot-2-6-regression-how-can-i-fix-keycloak-circular-dependency-in-adapte +@ConditionalOnExpression(value = "false") +@Configuration +@Deprecated +public class KeycloakConfiguration { + + @Value("${spring.security.keycloak.conf-file}") + private String keycloakConfFile; + + // /** + // * Sets keycloaks config resolver to use springs application.properties instead of keycloak.json (which is standard) + // * @return + // */ + // @Bean + // public KeycloakConfigResolver KeycloakConfigResolver() { + // return new KeycloakSpringBootConfigResolver(); + // } + // + + @Bean + public KeycloakConfigResolver keycloakConfigResolver() { + return new KeycloakConfigResolver() { + + private KeycloakDeployment keycloakDeployment; + + @Override + public KeycloakDeployment resolve(HttpFacade.Request facade) { + if (keycloakDeployment != null) { + return keycloakDeployment; + } + + String path = "/" + keycloakConfFile; + InputStream configInputStream = getClass().getResourceAsStream(path); + + if (configInputStream == null) { + throw new RuntimeException("Could not load Keycloak deployment info: " + path); + } else { + keycloakDeployment = KeycloakDeploymentBuilder.build(configInputStream); + } + + return keycloakDeployment; + } + }; + } +} diff --git a/src/main/java/org/openeo/spring/keycloak/ModifyAuthTokenHeaderRequestWrapper.java b/src/main/java/org/openeo/spring/keycloak/legacy/ModifyAuthTokenHeaderRequestWrapper.java similarity index 98% rename from src/main/java/org/openeo/spring/keycloak/ModifyAuthTokenHeaderRequestWrapper.java rename to src/main/java/org/openeo/spring/keycloak/legacy/ModifyAuthTokenHeaderRequestWrapper.java index 88a0f48..675f4fa 100644 --- a/src/main/java/org/openeo/spring/keycloak/ModifyAuthTokenHeaderRequestWrapper.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/ModifyAuthTokenHeaderRequestWrapper.java @@ -1,4 +1,4 @@ -package org.openeo.spring.keycloak; +package org.openeo.spring.keycloak.legacy; import java.util.ArrayList; import java.util.Collections; diff --git a/src/main/java/org/openeo/spring/keycloak/OpenEOKeycloakAuthenticationProcessingFilter.java b/src/main/java/org/openeo/spring/keycloak/legacy/OpenEOKeycloakAuthenticationProcessingFilter.java similarity index 97% rename from src/main/java/org/openeo/spring/keycloak/OpenEOKeycloakAuthenticationProcessingFilter.java rename to src/main/java/org/openeo/spring/keycloak/legacy/OpenEOKeycloakAuthenticationProcessingFilter.java index c1f864c..dc63a35 100644 --- a/src/main/java/org/openeo/spring/keycloak/OpenEOKeycloakAuthenticationProcessingFilter.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/OpenEOKeycloakAuthenticationProcessingFilter.java @@ -1,4 +1,4 @@ -package org.openeo.spring.keycloak; +package org.openeo.spring.keycloak.legacy; import java.io.IOException; diff --git a/src/main/java/org/openeo/spring/keycloak/legacy/package-info.java b/src/main/java/org/openeo/spring/keycloak/legacy/package-info.java new file mode 100644 index 0000000..dc7220a --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/legacy/package-info.java @@ -0,0 +1,9 @@ +package org.openeo.spring.keycloak.legacy; + +/** + * This package contains Keycloak-based configuration objects + * used by the legacy authentication provider {@link org.openeo.spring.KeycloakSecurityConfigAdapter} + * based on the deprecated Spring {@link org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter}. + * + * @see https://github.com/keycloak/keycloak/discussions/10187 + */ \ No newline at end of file diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..6ef8193 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=org.openeo.spring.GlobalSecurityConfig diff --git a/src/main/resources/webSecurityConfig.xml b/src/main/resources/webSecurityConfig.xml index 0cd8271..2fb094b 100644 --- a/src/main/resources/webSecurityConfig.xml +++ b/src/main/resources/webSecurityConfig.xml @@ -4,21 +4,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/security https://www.springframework.org/schema/security/spring-security.xsd"> - - - - - - - - - - - - - + + + - + From 7b1b4ecaeea43e1b151f3c688b5a2029b8f20181 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Fri, 14 Jul 2023 16:50:31 +0200 Subject: [PATCH 03/27] No JSESSIONID cookies in BA authentication. --- .../openeo/spring/BasicSecurityConfig.java | 20 +++++++++++++++++-- ...urityConfig.xml => spring-ba-security.xml} | 0 2 files changed, 18 insertions(+), 2 deletions(-) rename src/main/resources/{webSecurityConfig.xml => spring-ba-security.xml} (100%) diff --git a/src/main/java/org/openeo/spring/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/BasicSecurityConfig.java index 0a5fd8c..e0e5205 100644 --- a/src/main/java/org/openeo/spring/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/BasicSecurityConfig.java @@ -14,31 +14,47 @@ import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.SecurityContextRepository; //@Profile(BasicSecurityFromFileConfig.PROFILE_ID) -> better use: @ConditionalOnProperty(prefix="spring.security", value="enable-basic") @Configuration //@ComponentScan("org.openeo.spring.components") -@ImportResource({ "classpath:webSecurityConfig.xml" }) +@ImportResource({ "classpath:spring-ba-security.xml" }) public class BasicSecurityConfig { /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "BASIC_AUTH_FILE"; + /** Label for the "realm" set in {@code WWW-Authenticate} response header. */ + public static final String REALM_LABEL = "openEO"; + + /** Override default session repository. */ + public static SecurityContextRepository REPO; + /** * Requires login input on the basic-auth endpoint. */ @Bean public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { +// REPO = new HttpSessionSecurityContextRepository(); + http .antMatcher(BASIC_AUTH_API_RESOURCE) .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) - .httpBasic(); +// .securityContext((context) -> context +// .securityContextRepository(REPO)) + .httpBasic() + .realmName(REALM_LABEL) // [Authenticate: Basic realm="REALM"] + .and() // disable session management (JSESSIONID cookies -> security risks) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // .rememberMe(Customizer.withDefaults()); return http.build(); diff --git a/src/main/resources/webSecurityConfig.xml b/src/main/resources/spring-ba-security.xml similarity index 100% rename from src/main/resources/webSecurityConfig.xml rename to src/main/resources/spring-ba-security.xml From 5cf9dfdbb01c383d463bbb8faf67a4d71c03c83c Mon Sep 17 00:00:00 2001 From: pcampalani Date: Fri, 14 Jul 2023 18:39:34 +0200 Subject: [PATCH 04/27] Return JWT bearer token in BA login response header and body. --- pom.xml | 79 +++++++------- .../openeo/spring/BasicSecurityConfig.java | 73 ++++++++++--- .../openeo/spring/GlobalSecurityConfig.java | 4 +- .../spring/api/CredentialsApiController.java | 20 +--- .../java/org/openeo/spring/api/TokenUtil.java | 36 ++++--- .../openeo/spring/token/ITokenService.java | 27 +++++ .../spring/token/JWTAuthenticationFilter.java | 101 ++++++++++++++++++ .../spring/token/JWTAuthorizationFilter.java | 81 ++++++++++++++ .../openeo/spring/token/JWTTokenService.java | 87 +++++++++++++++ .../org/openeo/spring/token/package-info.java | 3 + 10 files changed, 424 insertions(+), 87 deletions(-) create mode 100644 src/main/java/org/openeo/spring/token/ITokenService.java create mode 100644 src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java create mode 100644 src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java create mode 100644 src/main/java/org/openeo/spring/token/JWTTokenService.java create mode 100644 src/main/java/org/openeo/spring/token/package-info.java diff --git a/pom.xml b/pom.xml index caae1a8..32fae8a 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ 12.0.4 9.0.37 0.2.1 + 0.11.5 false @@ -86,21 +87,11 @@ ${project.build.directory} - + org.springframework.boot spring-boot-starter-web @@ -127,18 +118,18 @@ org.springframework.boot spring-boot-starter-security - + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + org.keycloak @@ -150,7 +141,7 @@ keycloak-authz-client ${keycloak.version} - + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + org.springdoc springdoc-openapi-ui @@ -174,6 +185,8 @@ springfox-boot-starter ${springfox-version} + + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -214,12 +227,6 @@ json ${json.version} - - org.springframework.boot - spring-boot-devtools - runtime - true - org.hibernate hibernate-core @@ -243,16 +250,6 @@ log4j2-ecs-layout 1.2.0 - - org.springframework.boot - spring-boot-starter-test - test - - diff --git a/src/main/java/org/openeo/spring/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/BasicSecurityConfig.java index e0e5205..7ee4eba 100644 --- a/src/main/java/org/openeo/spring/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/BasicSecurityConfig.java @@ -4,8 +4,16 @@ import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; import static org.openeo.spring.GlobalSecurityManager.OIDC_AUTH_API_RESOURCE; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.openeo.spring.token.JWTAuthenticationFilter; +import org.openeo.spring.token.JWTAuthorizationFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportResource; import org.springframework.context.annotation.Primary; @@ -15,18 +23,28 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.context.HttpRequestResponseHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; -//@Profile(BasicSecurityFromFileConfig.PROFILE_ID) -> better use: -@ConditionalOnProperty(prefix="spring.security", value="enable-basic") @Configuration -//@ComponentScan("org.openeo.spring.components") +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") @ImportResource({ "classpath:spring-ba-security.xml" }) +@ComponentScan("org.openeo.spring.token") +//@Profile(BasicSecurityFromFileConfig.PROFILE_ID) -> better use @ConditionalOnProperty public class BasicSecurityConfig { + @Autowired + JWTAuthenticationFilter jwtAuthenticationFilter; + + @Autowired + JWTAuthorizationFilter jwtAuthorizationFilter; + /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "BASIC_AUTH_FILE"; @@ -36,26 +54,27 @@ public class BasicSecurityConfig { /** Override default session repository. */ public static SecurityContextRepository REPO; + private @Autowired AutowireCapableBeanFactory beanFactory; + /** * Requires login input on the basic-auth endpoint. */ @Bean - public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { -// REPO = new HttpSessionSecurityContextRepository(); - + public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { http .antMatcher(BASIC_AUTH_API_RESOURCE) .authorizeHttpRequests(authorize -> authorize - .anyRequest().authenticated() - ) -// .securityContext((context) -> context -// .securityContextRepository(REPO)) + .anyRequest().authenticated()) .httpBasic() .realmName(REALM_LABEL) // [Authenticate: Basic realm="REALM"] - .and() // disable session management (JSESSIONID cookies -> security risks) + .and() + // disable session management (JSESSIONID cookies -> security risks) .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS); - // .rememberMe(Customizer.withDefaults()); + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + // add Bearer/JWT tokens management + .and() + .addFilterAfter(jwtAuthenticationFilter, BasicAuthenticationFilter.class); + // .rememberMe(Customizer.withDefaults()); TODO return http.build(); } @@ -71,7 +90,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() - ); + ) + // disable session management (JSESSIONID cookies -> security risks) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class); return http.build(); } @@ -143,4 +167,25 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a // authenticationManagerBuilder.authenticationProvider(AP); // return authenticationManagerBuilder.build(); // } + + /** + * Custom security context repository, to manually store session information. + */ + static class InternalSecurityRepo extends HttpSessionSecurityContextRepository { + + @Override + public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { + return super.loadContext(requestResponseHolder); + } + + @Override + public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) { + super.saveContext(context, request, response); + } + + @Override + public boolean containsContext(HttpServletRequest request) { + return super.containsContext(request); + } + } } \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/GlobalSecurityConfig.java index 523a949..dac8e3e 100644 --- a/src/main/java/org/openeo/spring/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/GlobalSecurityConfig.java @@ -27,7 +27,9 @@ */ @Configuration @EnableWebSecurity -@EnableGlobalMethodSecurity(securedEnabled = true) +@EnableGlobalMethodSecurity( + securedEnabled = true, + prePostEnabled = true) // -> @PreAuthorize annotations on controller methods @Order(Ordered.LOWEST_PRECEDENCE) public class GlobalSecurityConfig implements EnvironmentPostProcessor { diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index 1a42787..cac4909 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -10,6 +10,7 @@ import org.openeo.spring.model.Error; import org.openeo.spring.model.HTTPBasicAccessToken; import org.openeo.spring.model.OpenIDConnectProviders; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; @@ -39,11 +40,10 @@ public class CredentialsApiController implements CredentialsApi { @Value("${spring.security.enable-keycloak}") boolean enableKeycloakAuth; - @org.springframework.beans.factory.annotation.Autowired + @Autowired public CredentialsApiController(NativeWebRequest request) { this.request = request; } - @Override public Optional getRequest() { @@ -91,20 +91,10 @@ public ResponseEntity authenticateOidc() { // FIXME handle errors elsewhere and keep HTTPBasicAccessToken response type? public ResponseEntity authenticateBasic() { ResponseEntity resp; -// getRequest().ifPresent(request -> { -// for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { -// if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { -// // fetch access token -// String user = request.getRemoteUser(); -// String accessToken = String.format("{ \"access_token\" : \"{}\" }", user); // FIXME token -// ApiUtil.setExampleResponse(request, "application/json", accessToken); // FIXME -// break; -// } -// } -// }); + if (enableBasicAuth) { - String username = request.getUserPrincipal().getName(); - String token = TokenUtil.getBAAccessToken(request.getUserPrincipal()); + String username = request.getUserPrincipal().getName(); + String token = TokenUtil.getCurrentBAAccessToken(request.getUserPrincipal()); log.debug("Access token for user {}: {}", username, token); resp = ResponseEntity.ok(new HTTPBasicAccessToken().accessToken(token)); } else { diff --git a/src/main/java/org/openeo/spring/api/TokenUtil.java b/src/main/java/org/openeo/spring/api/TokenUtil.java index 4409549..079dba8 100644 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ b/src/main/java/org/openeo/spring/api/TokenUtil.java @@ -7,7 +7,7 @@ import org.keycloak.representations.AccessToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.User; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; /** * Utilities for fetching user sessions' access tokens. @@ -18,7 +18,7 @@ public class TokenUtil { private TokenUtil() {}; /** - * FEtches the Keycloak (KC) access token of a given user. + * Fetches the Keycloak (KC) access token of a given user. * @param principal the user asking for the token * @return the access token; {@code null} when not found (or with {@code null} input) */ @@ -42,27 +42,31 @@ public static AccessToken getKCAccessToken(Principal principal) { * @param principal the user asking for the token * @return the access token; {@code null} when not found (or with {@code null} input) */ - public static String getBAAccessToken(Principal principal) { + public static String getCurrentBAAccessToken(Principal principal) { if (null == principal) { return null; } + String givenUsername = principal.getName(); String token = null; - Authentication authentication = SecurityContextHolder.getContext().getAuthentication();//? which one? - // 1 authentication.getPrincipal() -> instanceof User - // 2 get username() then: - String givenUser = principal.getName(); - String loggedUser = .toString(); - User loggedUser = - - if (givenUser.equals(loggedUser)) { - token = authentication.getName(); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication();//? which one? + + if (auth instanceof BearerTokenAuthenticationToken) { + BearerTokenAuthenticationToken tokenAuth = (BearerTokenAuthenticationToken) auth; + String loggedUsername = tokenAuth.getName(); + + if (givenUsername.equals(loggedUsername)) { + token = tokenAuth.getToken(); + } else { + // TODO + // throw internal error exception? given user != logged user + // or just return null? + } } else { - // TODO - // throw internal error exception? given user != logged user - // or just return null? + // TODO as above } - + return token; } } diff --git a/src/main/java/org/openeo/spring/token/ITokenService.java b/src/main/java/org/openeo/spring/token/ITokenService.java new file mode 100644 index 0000000..c4a3f91 --- /dev/null +++ b/src/main/java/org/openeo/spring/token/ITokenService.java @@ -0,0 +1,27 @@ +package org.openeo.spring.token; + +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Interface for a (bearer) token service. + * + * @see https://datatracker.ietf.org/doc/html/rfc6750 + */ +public interface ITokenService { + + /** + * Generates a token hash. + * + * @param user the logged user. + */ + String generateToken(UserDetails user); + + /** + * Parses a token. + * + * @param token the received token. + */ + UserDetails parseToken(String token); + + //String extractToken(HttpRequest or HttpResponse) "Authorization: Bearer basic//$TOKEN" +} diff --git a/src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java b/src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java new file mode 100644 index 0000000..1b84bb8 --- /dev/null +++ b/src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java @@ -0,0 +1,101 @@ +package org.openeo.spring.token; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter to be added in the HTTP basic security chain, + * in order to manage the Bearer token generation and parsing. + * + * @see BearerTokenAuthenticationFilter + */ +@Component +public class JWTAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + ITokenService tokenService; + + /** HTTP Bearer scheme id. */ + private static String BEARER_HEADER_PRE = "Bearer "; + + /** Prefix of the HTTP authentication Basic authentication token header. */ + private static String BA_HEADER_PRE = "Basic "; + + private static final Logger LOGGER = LogManager.getLogger(JWTAuthenticationFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { + + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeaderIsValid(authorizationHeader)) { + Authentication authResult = SecurityContextHolder.getContext().getAuthentication(); + if (null != authResult) { + onSuccessfulAuthentication(request, response, authResult); + } else { + LOGGER.debug("Unauthenticated user: NOOP."); + } + } else { + LOGGER.debug("No \"Authorization\" header found."); + } + + // do not break the chain! + filterChain.doFilter(request, response); + } + + /** + * What to be done when a new successful authentication process has been completed. + */ + protected void onSuccessfulAuthentication(HttpServletRequest request, + HttpServletResponse response, Authentication authResult) { + Object authPrincipal = authResult.getPrincipal(); + + if (authPrincipal instanceof UserDetails) { + UserDetails user = (UserDetails) authPrincipal; + String token = tokenService.generateToken(user); + + // store the token in the session for later usage in the chain + BearerTokenAuthenticationToken tokenAuth = new BearerTokenAuthenticationToken(token); + tokenAuth.setDetails(authPrincipal); + tokenAuth.setAuthenticated(true); // ! + SecurityContextHolder.getContext().setAuthentication(tokenAuth); + + // add the token in the response header + response.addHeader(HttpHeaders.AUTHORIZATION, + String.format("%s%s", BEARER_HEADER_PRE, token)); + + LOGGER.debug("JWT token added to the response's header: {}...", token); + + } else { + throw new NotImplementedException(String.format( + "Authentication object not handled: %s", authPrincipal.getClass())); + } + } + /** + * The request header should be a Basic authentication request + * Further requests from client shall have the "Bearer" prefix instead. + */ + private boolean authorizationHeaderIsValid(String authorizationHeader) { + return null != authorizationHeader + && authorizationHeader.startsWith(BA_HEADER_PRE); + } +} diff --git a/src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java new file mode 100644 index 0000000..5ab8742 --- /dev/null +++ b/src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java @@ -0,0 +1,81 @@ +package org.openeo.spring.token; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filters that handles authorizations for incoming HTTP requests + * based on a received JWT bearer token. + * + * Basic HTTP authentication is assumed (basic// prefix is expected on the token). + */ +@Component +public class JWTAuthorizationFilter extends OncePerRequestFilter { + + @Autowired + ITokenService tokenService; + + /** HTTP Bearer scheme id. */ + private static String BEARER_HEADER_PRE = "Bearer "; + + /** Prefix of the HTTP authentication Bearer token header. */ + private static String TOKEN_PREFIX = "basic//"; + + private static final Logger LOGGER = LogManager.getLogger(JWTAuthorizationFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeaderIsInvalid(authorizationHeader)) { + LOGGER.debug("No \"Bearer\" token found in the request."); + + } else { + UsernamePasswordAuthenticationToken token = parseToken(authorizationHeader); + SecurityContextHolder.getContext().setAuthentication(token); + } + + // do not break the chain! + filterChain.doFilter(request, response); + } + + /** Tells whether the given HTTP "Authorization" header follows the Bearer scheme. */ + private boolean authorizationHeaderIsInvalid(String authorizationHeader) { + return authorizationHeader == null + || !authorizationHeader.startsWith(BEARER_HEADER_PRE); + } + + /** Deciphers a JWT bearer token attached to a given request header. */ + private UsernamePasswordAuthenticationToken parseToken(String authorizationHeader) { + String prefixedToken = authorizationHeader.replace(BEARER_HEADER_PRE, ""); + String jwtToken = prefixedToken.replaceAll(TOKEN_PREFIX, ""); + UserDetails userPrincipal = tokenService.parseToken(jwtToken); + + // TODO + List authorities = new ArrayList<>(); +// if (userPrincipal.isAdmin()) { +// authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // FIXME String +// } + + return new UsernamePasswordAuthenticationToken(userPrincipal, null, authorities); + } +} diff --git a/src/main/java/org/openeo/spring/token/JWTTokenService.java b/src/main/java/org/openeo/spring/token/JWTTokenService.java new file mode 100644 index 0000000..ed19b51 --- /dev/null +++ b/src/main/java/org/openeo/spring/token/JWTTokenService.java @@ -0,0 +1,87 @@ +package org.openeo.spring.token; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +/** + * JWT token management service. + */ +//FIXME use org.springframework.security.core.token.TokenService ? +@Component +public class JWTTokenService implements ITokenService { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.issuer}") + private String jwtIssuer; + + @Value("${jwt.type}") + private String jwtType; + + @Value("${jwt.audience}") + private String jwtAudience; + + @Value("${jwt.exp-minutes}") + private int jwtExpMinutes; + + /** Algorithm used to encode the token. */ + private static final SignatureAlgorithm SA = SignatureAlgorithm.HS512; + + @Override + public String generateToken(UserDetails user) { + + Instant expirationTime = Instant.now().plus(jwtExpMinutes, ChronoUnit.MINUTES); + Date expirationDate = Date.from(expirationTime); + + SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes()); + + String compactTokenString = Jwts.builder() +// .claim(ID_CLAIM, user.getId()) +// .claim(IS_ADMIN_CLAIM, user.isAdmin()) + .setExpiration(expirationDate) + .setSubject(user.getUsername()) + .signWith(key, SA) + .setIssuer(jwtIssuer) + .setAudience(jwtAudience) + .setHeaderParam("typ", jwtType) + .compact(); + + return compactTokenString; + } + + @Override + public UserDetails parseToken(String token) { + byte[] secretBytes = jwtSecret.getBytes(); + + Jws jwsClaims = Jwts.parserBuilder() + .setSigningKey(secretBytes) + .build() + .parseClaimsJws(token); + + String username = jwsClaims.getBody().getSubject(); +// Integer userId = jwsClaims.getBody().get(ID_CLAIM, Integer.class); +// boolean isAdmin = jwsClaims.getBody().get(IS_ADMIN_CLAIM, Boolean.class); + + return User.builder().username(username).build(); + } + + // JWT labels + private static final String ID_CLAIM = "id"; + private static final String USER_CLAIM = "sub"; + private static final String IS_ADMIN_CLAIM = "admin"; +} diff --git a/src/main/java/org/openeo/spring/token/package-info.java b/src/main/java/org/openeo/spring/token/package-info.java new file mode 100644 index 0000000..d116604 --- /dev/null +++ b/src/main/java/org/openeo/spring/token/package-info.java @@ -0,0 +1,3 @@ +package org.openeo.spring.token; + +/** JWT/Bearer token management. */ \ No newline at end of file From 83726192560d7cca149cbae44419ea09c4f364e8 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Thu, 20 Jul 2023 17:33:27 +0200 Subject: [PATCH 05/27] Integration tests for Basic Authentication. --- pom.xml | 7 +- .../openeo/spring/GlobalSecurityManager.java | 20 --- .../spring/api/CollectionsApiController.java | 8 +- .../spring/api/CredentialsApiController.java | 29 +++- .../openeo/spring/bearer/ITokenService.java | 41 +++++ .../JWTAuthenticationFilter.java | 2 +- .../JWTAuthorizationFilter.java | 58 ++++++-- .../{token => bearer}/JWTTokenService.java | 54 +++++-- .../{token => bearer}/package-info.java | 2 +- .../components/ExceptionTranslator.java | 83 +++++++++++ .../FilterChainExceptionHandler.java | 50 +++++++ .../OnOffDaoAuthenticationProvider.java | 2 +- .../spring/{api => }/loaders/CRSUtils.java | 2 +- .../CollectionHandlerThreadFactory.java | 2 +- .../{api => }/loaders/ICollectionParser.java | 2 +- .../{api => }/loaders/ICollectionsLoader.java | 2 +- .../{api => }/loaders/JSONMarshaller.java | 2 +- .../loaders/ODCCollectionsLoader.java | 2 +- .../loaders/STACFileCollectionsLoader.java | 2 +- .../loaders/WCSCollectionsLoader.java | 12 +- .../{api => }/loaders/package-info.java | 2 +- .../{ => security}/BasicSecurityConfig.java | 22 +-- .../{ => security}/GlobalSecurityConfig.java | 15 +- .../KeycloakSecurityConfig.java | 6 +- .../openeo/spring/security/package-info.java | 5 + .../openeo/spring/token/ITokenService.java | 27 ---- src/main/resources/META-INF/spring.factories | 2 +- .../{api => }/loaders/ICollectionTester.java | 2 +- .../spring/{api => }/loaders/Resource.java | 2 +- .../loaders/TestWCSCollectionsLoader.java | 23 +-- .../security/TestBasicAuthentication.java | 140 ++++++++++++++++++ 31 files changed, 493 insertions(+), 135 deletions(-) delete mode 100644 src/main/java/org/openeo/spring/GlobalSecurityManager.java create mode 100644 src/main/java/org/openeo/spring/bearer/ITokenService.java rename src/main/java/org/openeo/spring/{token => bearer}/JWTAuthenticationFilter.java (99%) rename src/main/java/org/openeo/spring/{token => bearer}/JWTAuthorizationFilter.java (54%) rename src/main/java/org/openeo/spring/{token => bearer}/JWTTokenService.java (51%) rename src/main/java/org/openeo/spring/{token => bearer}/package-info.java (51%) create mode 100644 src/main/java/org/openeo/spring/components/ExceptionTranslator.java create mode 100644 src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java rename src/main/java/org/openeo/spring/{api => }/loaders/CRSUtils.java (99%) rename src/main/java/org/openeo/spring/{api => }/loaders/CollectionHandlerThreadFactory.java (96%) rename src/main/java/org/openeo/spring/{api => }/loaders/ICollectionParser.java (85%) rename src/main/java/org/openeo/spring/{api => }/loaders/ICollectionsLoader.java (90%) rename src/main/java/org/openeo/spring/{api => }/loaders/JSONMarshaller.java (99%) rename src/main/java/org/openeo/spring/{api => }/loaders/ODCCollectionsLoader.java (98%) rename src/main/java/org/openeo/spring/{api => }/loaders/STACFileCollectionsLoader.java (98%) rename src/main/java/org/openeo/spring/{api => }/loaders/WCSCollectionsLoader.java (99%) rename src/main/java/org/openeo/spring/{api => }/loaders/package-info.java (81%) rename src/main/java/org/openeo/spring/{ => security}/BasicSecurityConfig.java (90%) rename src/main/java/org/openeo/spring/{ => security}/GlobalSecurityConfig.java (85%) rename src/main/java/org/openeo/spring/{ => security}/KeycloakSecurityConfig.java (95%) create mode 100644 src/main/java/org/openeo/spring/security/package-info.java delete mode 100644 src/main/java/org/openeo/spring/token/ITokenService.java rename src/test/java/org/openeo/spring/{api => }/loaders/ICollectionTester.java (96%) rename src/test/java/org/openeo/spring/{api => }/loaders/Resource.java (97%) rename src/test/java/org/openeo/spring/{api => }/loaders/TestWCSCollectionsLoader.java (95%) create mode 100644 src/test/java/org/openeo/spring/security/TestBasicAuthentication.java diff --git a/pom.xml b/pom.xml index 32fae8a..74a3259 100644 --- a/pom.xml +++ b/pom.xml @@ -128,7 +128,12 @@ org.springframework.boot spring-boot-starter-test test - + + + org.springframework.security + spring-security-test + test + diff --git a/src/main/java/org/openeo/spring/GlobalSecurityManager.java b/src/main/java/org/openeo/spring/GlobalSecurityManager.java deleted file mode 100644 index 36493df..0000000 --- a/src/main/java/org/openeo/spring/GlobalSecurityManager.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.openeo.spring; - -/** - * Global authentication and authorization manager. - * - * TODO docs - */ -public /*final*/ class GlobalSecurityManager { - - /** API resources that do not require authentication. */ - public static final String[] NOAUTH_API_RESOURCES = new String[] { - "/", - "/conformance", - "/file_formats", - "/.well-known/**"}; - - public static final String BASIC_AUTH_API_RESOURCE = "/credentials/basic"; - public static final String OIDC_AUTH_API_RESOURCE = "/credentials/oidc"; - -} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/api/CollectionsApiController.java b/src/main/java/org/openeo/spring/api/CollectionsApiController.java index 519f22c..91cc0e0 100644 --- a/src/main/java/org/openeo/spring/api/CollectionsApiController.java +++ b/src/main/java/org/openeo/spring/api/CollectionsApiController.java @@ -20,12 +20,12 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.openeo.spring.api.loaders.ICollectionsLoader; -import org.openeo.spring.api.loaders.ODCCollectionsLoader; -import org.openeo.spring.api.loaders.STACFileCollectionsLoader; -import org.openeo.spring.api.loaders.WCSCollectionsLoader; import org.openeo.spring.components.CollectionMap; import org.openeo.spring.components.CollectionsMap; +import org.openeo.spring.loaders.ICollectionsLoader; +import org.openeo.spring.loaders.ODCCollectionsLoader; +import org.openeo.spring.loaders.STACFileCollectionsLoader; +import org.openeo.spring.loaders.WCSCollectionsLoader; import org.openeo.spring.model.Collection; import org.openeo.spring.model.Collections; import org.openeo.spring.model.EngineTypes; diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index cac4909..201f69c 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import java.io.IOException; +import java.security.Principal; import java.util.Optional; import org.apache.logging.log4j.LogManager; @@ -93,20 +94,36 @@ public ResponseEntity authenticateOidc() { ResponseEntity resp; if (enableBasicAuth) { - String username = request.getUserPrincipal().getName(); - String token = TokenUtil.getCurrentBAAccessToken(request.getUserPrincipal()); - log.debug("Access token for user {}: {}", username, token); - resp = ResponseEntity.ok(new HTTPBasicAccessToken().accessToken(token)); + Principal principal = request.getUserPrincipal(); + + if (null != principal) { + String username = principal.getName(); + String token = TokenUtil.getCurrentBAAccessToken(request.getUserPrincipal()); + log.debug("Access token for user {}: {}", username, token); + resp = ResponseEntity + .ok(new HTTPBasicAccessToken() + .accessToken(token)); + + } else { + Error error = new Error(); + error.setCode("401"); + error.setMessage("Basic Authentication header required."); + resp = ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(error); + } } else { Error error = new Error(); error.setCode("501"); error.setMessage("Basic authentication mechanism not supported by the server."); - resp = ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(error); + resp = ResponseEntity + .status(HttpStatus.NOT_IMPLEMENTED) + .body(error); } + return resp; /**TODO**/ - // see interface default method example // token: https://github.com/Open-EO/openeo-wcps-driver/tree/master/src/main/java/eu/openeo/backend/auth/filter // also: https://github.com/Open-EO/openeo-wcps-driver/blob/master/src/main/java/eu/openeo/api/impl/CredentialsApiServiceImpl.java } diff --git a/src/main/java/org/openeo/spring/bearer/ITokenService.java b/src/main/java/org/openeo/spring/bearer/ITokenService.java new file mode 100644 index 0000000..0d654f9 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/ITokenService.java @@ -0,0 +1,41 @@ +package org.openeo.spring.bearer; + +import java.time.temporal.TemporalUnit; + +import org.springframework.security.core.userdetails.UserDetails; + +import io.jsonwebtoken.ClaimJwtException; + +/** + * Interface for a (bearer) token service. + * + * @see https://datatracker.ietf.org/doc/html/rfc6750 + */ +public interface ITokenService { + + /** + * Generates a token hash with default expiration time. + * + * @param user the logged user. + * @return the hashed token + */ + String generateToken(UserDetails user); + + /** + * Generates a token with the given arbitrary expiration time. + * + * @param user the logged user + * @param expUnits how many units of time (*{@code uom}) from now to set the expiration + * @param uom the unit of measure of {@code expUnits} + * @return the hashed token + */ + String generateToken(UserDetails user, int expUnits, TemporalUnit uom); + + /** + * Parses a token and its claims. + * + * @param token the received token. + * @throws ClaimJwtException + */ + UserDetails parseToken(String token) throws ClaimJwtException; +} diff --git a/src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java similarity index 99% rename from src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java rename to src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java index 1b84bb8..b3ad762 100644 --- a/src/main/java/org/openeo/spring/token/JWTAuthenticationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.openeo.spring.token; +package org.openeo.spring.bearer; import java.io.IOException; diff --git a/src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java similarity index 54% rename from src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java rename to src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index 5ab8742..4f0b758 100644 --- a/src/main/java/org/openeo/spring/token/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -1,4 +1,4 @@ -package org.openeo.spring.token; +package org.openeo.spring.bearer; import java.io.IOException; import java.util.ArrayList; @@ -20,6 +20,9 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import io.jsonwebtoken.ClaimJwtException; +import io.jsonwebtoken.JwtException; + /** * Filters that handles authorizations for incoming HTTP requests * based on a received JWT bearer token. @@ -46,36 +49,59 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (authorizationHeaderIsInvalid(authorizationHeader)) { - LOGGER.debug("No \"Bearer\" token found in the request."); - + if (null != authorizationHeader) { + if (authorizationHeaderIsBearer(authorizationHeader)) { + if (authBearerHeaderIsInvalid(authorizationHeader)) { + throw new JwtException(String.format( + "Invalid authorization header. Expected: %s%sTOKEN", + BEARER_HEADER_PRE, TOKEN_PREFIX)); + } else { + UsernamePasswordAuthenticationToken auth = parseToken(authorizationHeader); + if (null != auth) { + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + LOGGER.error("Invalid token received: authentication unsuccessful."); + } + } + } } else { - UsernamePasswordAuthenticationToken token = parseToken(authorizationHeader); - SecurityContextHolder.getContext().setAuthentication(token); + LOGGER.debug("No \"Bearer\" token found in the request."); } // do not break the chain! filterChain.doFilter(request, response); } + /** Tells whether the given HTTP "Authorization" header is a Bearer token. */ + private boolean authorizationHeaderIsBearer(String authorizationHeader) { + return authorizationHeader != null && + authorizationHeader.startsWith(BEARER_HEADER_PRE); + } + /** Tells whether the given HTTP "Authorization" header follows the Bearer scheme. */ - private boolean authorizationHeaderIsInvalid(String authorizationHeader) { - return authorizationHeader == null - || !authorizationHeader.startsWith(BEARER_HEADER_PRE); + private boolean authBearerHeaderIsInvalid(String authorizationHeader) { + return authorizationHeader == null || ( + authorizationHeader.startsWith(BEARER_HEADER_PRE) && + !authorizationHeader.startsWith(BEARER_HEADER_PRE + TOKEN_PREFIX)); } /** Deciphers a JWT bearer token attached to a given request header. */ - private UsernamePasswordAuthenticationToken parseToken(String authorizationHeader) { + private UsernamePasswordAuthenticationToken parseToken(String authorizationHeader) + throws ClaimJwtException { String prefixedToken = authorizationHeader.replace(BEARER_HEADER_PRE, ""); String jwtToken = prefixedToken.replaceAll(TOKEN_PREFIX, ""); + UserDetails userPrincipal = tokenService.parseToken(jwtToken); + UsernamePasswordAuthenticationToken auth = null; - // TODO - List authorities = new ArrayList<>(); -// if (userPrincipal.isAdmin()) { -// authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // FIXME String -// } + if (null != userPrincipal) { + List authorities = new ArrayList<>(); + // if (userPrincipal.isAdmin()) { + // authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // FIXME String + // } + auth = new UsernamePasswordAuthenticationToken(userPrincipal, null, authorities); + } - return new UsernamePasswordAuthenticationToken(userPrincipal, null, authorities); + return auth; } } diff --git a/src/main/java/org/openeo/spring/token/JWTTokenService.java b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java similarity index 51% rename from src/main/java/org/openeo/spring/token/JWTTokenService.java rename to src/main/java/org/openeo/spring/bearer/JWTTokenService.java index ed19b51..a2984b0 100644 --- a/src/main/java/org/openeo/spring/token/JWTTokenService.java +++ b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java @@ -1,18 +1,23 @@ -package org.openeo.spring.token; +package org.openeo.spring.bearer; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.Date; import javax.crypto.SecretKey; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; @@ -39,13 +44,23 @@ public class JWTTokenService implements ITokenService { @Value("${jwt.exp-minutes}") private int jwtExpMinutes; + @Autowired + private UserDetailsService udService; + /** Algorithm used to encode the token. */ private static final SignatureAlgorithm SA = SignatureAlgorithm.HS512; + + private static final Logger LOGGER = LogManager.getLogger(JWTTokenService.class); @Override public String generateToken(UserDetails user) { + return generateToken(user, jwtExpMinutes, ChronoUnit.MINUTES); + } + + @Override + public String generateToken(UserDetails user, int expUnits, TemporalUnit uom) { - Instant expirationTime = Instant.now().plus(jwtExpMinutes, ChronoUnit.MINUTES); + Instant expirationTime = Instant.now().plus(expUnits, uom); Date expirationDate = Date.from(expirationTime); SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes()); @@ -65,19 +80,34 @@ public String generateToken(UserDetails user) { } @Override - public UserDetails parseToken(String token) { + public UserDetails parseToken(String token) throws JwtException { + byte[] secretBytes = jwtSecret.getBytes(); + UserDetails user = null; - Jws jwsClaims = Jwts.parserBuilder() - .setSigningKey(secretBytes) - .build() - .parseClaimsJws(token); + try { + Jws jwsClaims = Jwts.parserBuilder() + .setSigningKey(secretBytes) + .requireIssuer(jwtIssuer) + .requireAudience(jwtAudience) + .setAllowedClockSkewSeconds(0) + .build() + .parseClaimsJws(token); - String username = jwsClaims.getBody().getSubject(); -// Integer userId = jwsClaims.getBody().get(ID_CLAIM, Integer.class); -// boolean isAdmin = jwsClaims.getBody().get(IS_ADMIN_CLAIM, Boolean.class); + String username = jwsClaims.getBody().getSubject(); + // Integer userId = jwsClaims.getBody().get(ID_CLAIM, Integer.class); + // boolean isAdmin = jwsClaims.getBody().get(IS_ADMIN_CLAIM, Boolean.class); + if (null != username) { + user = udService.loadUserByUsername(username); + } + } catch (JwtException ex) { // TODO handle via registered runtime exceptions handler: +// ExpiredJwtException | UnsupportedJwtException | +// MalformedJwtException | SignatureException | IllegalArgumentException ex) { + LOGGER.error("Illegal or expired token received.", ex); + throw ex; + } - return User.builder().username(username).build(); + return user; } // JWT labels diff --git a/src/main/java/org/openeo/spring/token/package-info.java b/src/main/java/org/openeo/spring/bearer/package-info.java similarity index 51% rename from src/main/java/org/openeo/spring/token/package-info.java rename to src/main/java/org/openeo/spring/bearer/package-info.java index d116604..afdce87 100644 --- a/src/main/java/org/openeo/spring/token/package-info.java +++ b/src/main/java/org/openeo/spring/bearer/package-info.java @@ -1,3 +1,3 @@ -package org.openeo.spring.token; +package org.openeo.spring.bearer; /** JWT/Bearer token management. */ \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/components/ExceptionTranslator.java b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java new file mode 100644 index 0000000..a675648 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java @@ -0,0 +1,83 @@ +package org.openeo.spring.components; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openeo.spring.model.Error; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import io.jsonwebtoken.JwtException; + +/** + * Collection of exception handlers for the API. + * + * Registered handlers are used by the {@link HandlerExceptionResolver} to resolve + * the exceptions, given its type. + * + * @see FilterChainExceptionHandler + */ +@RestControllerAdvice +public class ExceptionTranslator { + + private static final Logger LOGGER = LogManager.getLogger(ExceptionTranslator.class); + + /** + * Handling of an error in the validation of an incoming Bearer token: + * expired token, invalid format, maliciously crafted tokens etc. + */ + @ExceptionHandler(JwtException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity processBearerTokenValidationException(JwtException e) { + LOGGER.error("JWT token exception caught: ", e); + + Error error = new Error(); + error.setCode("403"); + error.setMessage(e.getMessage()); + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(error); + } + + /** + * @deprecated All {@link AuthenticationException} thrown are handled by the + * {@link BasicAuthenticationFilter}. + */ + @Deprecated + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity processAuthenticationException(AuthenticationException e) { + LOGGER.error("Authentication exception caught: ", e); + + Error error = new Error(); + error.setCode("403"); + error.setMessage(e.getMessage()); + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(error); + } + + /** + * Last resort handler of all not-yet managed runtime error. + */ + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseEntity processAllException(RuntimeException e) { + LOGGER.error("Runtime exception caught: ", e); + + Error error = new Error(); + error.setCode("500"); + error.setMessage(e.getMessage()); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(error); + } +} diff --git a/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java new file mode 100644 index 0000000..485ac90 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java @@ -0,0 +1,50 @@ +package org.openeo.spring.components; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * The overarching exception handler in the HTTP chain of filters. + * + * This filter should be placed before other custom security, + * authentication or authorization filters so that the new logic + * is safely placed inside a trz-catch block. + */ +@Component +public class FilterChainExceptionHandler extends OncePerRequestFilter { + + private final Logger LOGGER = LogManager.getLogger(FilterChainExceptionHandler.class); + + @Autowired + @Qualifier("handlerExceptionResolver") + private HandlerExceptionResolver resolver; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (BadCredentialsException bex) { + LOGGER.error("TESTING", bex); + resolver.resolveException(request, response, null, bex); + + } catch (Exception e) { + LOGGER.error("Spring Security Filter Chain Exception:", e); + resolver.resolveException(request, response, null, e); // --> to ExceptionTranslators + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java b/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java index dd59276..3f4adb4 100644 --- a/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java +++ b/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java @@ -1,6 +1,6 @@ package org.openeo.spring.components; -import org.openeo.spring.BasicSecurityConfig; +import org.openeo.spring.security.BasicSecurityConfig; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; diff --git a/src/main/java/org/openeo/spring/api/loaders/CRSUtils.java b/src/main/java/org/openeo/spring/loaders/CRSUtils.java similarity index 99% rename from src/main/java/org/openeo/spring/api/loaders/CRSUtils.java rename to src/main/java/org/openeo/spring/loaders/CRSUtils.java index 0d721d1..0707775 100644 --- a/src/main/java/org/openeo/spring/api/loaders/CRSUtils.java +++ b/src/main/java/org/openeo/spring/loaders/CRSUtils.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.util.Arrays; import java.util.EnumSet; diff --git a/src/main/java/org/openeo/spring/api/loaders/CollectionHandlerThreadFactory.java b/src/main/java/org/openeo/spring/loaders/CollectionHandlerThreadFactory.java similarity index 96% rename from src/main/java/org/openeo/spring/api/loaders/CollectionHandlerThreadFactory.java rename to src/main/java/org/openeo/spring/loaders/CollectionHandlerThreadFactory.java index 66b1470..ea881ca 100644 --- a/src/main/java/org/openeo/spring/api/loaders/CollectionHandlerThreadFactory.java +++ b/src/main/java/org/openeo/spring/loaders/CollectionHandlerThreadFactory.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/openeo/spring/api/loaders/ICollectionParser.java b/src/main/java/org/openeo/spring/loaders/ICollectionParser.java similarity index 85% rename from src/main/java/org/openeo/spring/api/loaders/ICollectionParser.java rename to src/main/java/org/openeo/spring/loaders/ICollectionParser.java index a8aa0ea..d3b71b2 100644 --- a/src/main/java/org/openeo/spring/api/loaders/ICollectionParser.java +++ b/src/main/java/org/openeo/spring/loaders/ICollectionParser.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.util.concurrent.Callable; diff --git a/src/main/java/org/openeo/spring/api/loaders/ICollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/ICollectionsLoader.java similarity index 90% rename from src/main/java/org/openeo/spring/api/loaders/ICollectionsLoader.java rename to src/main/java/org/openeo/spring/loaders/ICollectionsLoader.java index a8d6967..03442f0 100644 --- a/src/main/java/org/openeo/spring/api/loaders/ICollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/ICollectionsLoader.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.util.concurrent.Callable; diff --git a/src/main/java/org/openeo/spring/api/loaders/JSONMarshaller.java b/src/main/java/org/openeo/spring/loaders/JSONMarshaller.java similarity index 99% rename from src/main/java/org/openeo/spring/api/loaders/JSONMarshaller.java rename to src/main/java/org/openeo/spring/loaders/JSONMarshaller.java index d47fc38..3daae12 100644 --- a/src/main/java/org/openeo/spring/api/loaders/JSONMarshaller.java +++ b/src/main/java/org/openeo/spring/loaders/JSONMarshaller.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.io.BufferedReader; import java.io.File; diff --git a/src/main/java/org/openeo/spring/api/loaders/ODCCollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/ODCCollectionsLoader.java similarity index 98% rename from src/main/java/org/openeo/spring/api/loaders/ODCCollectionsLoader.java rename to src/main/java/org/openeo/spring/loaders/ODCCollectionsLoader.java index ac8fbac..7643e6a 100644 --- a/src/main/java/org/openeo/spring/api/loaders/ODCCollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/ODCCollectionsLoader.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.io.File; import java.io.IOException; diff --git a/src/main/java/org/openeo/spring/api/loaders/STACFileCollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/STACFileCollectionsLoader.java similarity index 98% rename from src/main/java/org/openeo/spring/api/loaders/STACFileCollectionsLoader.java rename to src/main/java/org/openeo/spring/loaders/STACFileCollectionsLoader.java index cdde209..0f06611 100644 --- a/src/main/java/org/openeo/spring/api/loaders/STACFileCollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/STACFileCollectionsLoader.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.io.File; import java.io.IOException; diff --git a/src/main/java/org/openeo/spring/api/loaders/WCSCollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java similarity index 99% rename from src/main/java/org/openeo/spring/api/loaders/WCSCollectionsLoader.java rename to src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java index 7087e6e..93c1983 100644 --- a/src/main/java/org/openeo/spring/api/loaders/WCSCollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java @@ -1,7 +1,7 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; -import static org.openeo.spring.api.loaders.CRSUtils.EPSG_WGS84; -import static org.openeo.spring.api.loaders.CRSUtils.TEMPORAL_AXIS_LABELS; +import static org.openeo.spring.loaders.CRSUtils.EPSG_WGS84; +import static org.openeo.spring.loaders.CRSUtils.TEMPORAL_AXIS_LABELS; import java.io.File; import java.io.IOException; @@ -43,9 +43,9 @@ import org.openeo.spring.api.CollectionsApiController; import org.openeo.spring.api.DefaultApiController; import org.openeo.spring.api.LinkRelType; -import org.openeo.spring.api.loaders.CRSUtils.AxisMappingStrategy; -import org.openeo.spring.api.loaders.CRSUtils.CSAxisOrientation; -import org.openeo.spring.api.loaders.CRSUtils.CsType; +import org.openeo.spring.loaders.CRSUtils.AxisMappingStrategy; +import org.openeo.spring.loaders.CRSUtils.CSAxisOrientation; +import org.openeo.spring.loaders.CRSUtils.CsType; import org.openeo.spring.model.Asset; import org.openeo.spring.model.BandSummary; import org.openeo.spring.model.Collection; diff --git a/src/main/java/org/openeo/spring/api/loaders/package-info.java b/src/main/java/org/openeo/spring/loaders/package-info.java similarity index 81% rename from src/main/java/org/openeo/spring/api/loaders/package-info.java rename to src/main/java/org/openeo/spring/loaders/package-info.java index db9e15f..92fad65 100644 --- a/src/main/java/org/openeo/spring/api/loaders/package-info.java +++ b/src/main/java/org/openeo/spring/loaders/package-info.java @@ -4,4 +4,4 @@ * different sources, being it a remote service * or a local catalogue file. */ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; diff --git a/src/main/java/org/openeo/spring/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java similarity index 90% rename from src/main/java/org/openeo/spring/BasicSecurityConfig.java rename to src/main/java/org/openeo/spring/security/BasicSecurityConfig.java index 7ee4eba..be4d271 100644 --- a/src/main/java/org/openeo/spring/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -1,16 +1,16 @@ -package org.openeo.spring; +package org.openeo.spring.security; -import static org.openeo.spring.GlobalSecurityManager.BASIC_AUTH_API_RESOURCE; -import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; -import static org.openeo.spring.GlobalSecurityManager.OIDC_AUTH_API_RESOURCE; +import static org.openeo.spring.security.GlobalSecurityConfig.BASIC_AUTH_API_RESOURCE; +import static org.openeo.spring.security.GlobalSecurityConfig.NOAUTH_API_RESOURCES; +import static org.openeo.spring.security.GlobalSecurityConfig.OIDC_AUTH_API_RESOURCE; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.openeo.spring.token.JWTAuthenticationFilter; -import org.openeo.spring.token.JWTAuthorizationFilter; +import org.openeo.spring.bearer.JWTAuthenticationFilter; +import org.openeo.spring.bearer.JWTAuthorizationFilter; +import org.openeo.spring.components.FilterChainExceptionHandler; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -27,6 +27,7 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.context.HttpRequestResponseHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; @@ -45,6 +46,9 @@ public class BasicSecurityConfig { @Autowired JWTAuthorizationFilter jwtAuthorizationFilter; + @Autowired + FilterChainExceptionHandler filterChainExHandler; + /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "BASIC_AUTH_FILE"; @@ -54,8 +58,6 @@ public class BasicSecurityConfig { /** Override default session repository. */ public static SecurityContextRepository REPO; - private @Autowired AutowireCapableBeanFactory beanFactory; - /** * Requires login input on the basic-auth endpoint. */ @@ -73,6 +75,7 @@ public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // add Bearer/JWT tokens management .and() + .addFilterBefore(filterChainExHandler, LogoutFilter.class) .addFilterAfter(jwtAuthenticationFilter, BasicAuthenticationFilter.class); // .rememberMe(Customizer.withDefaults()); TODO @@ -95,6 +98,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() + .addFilterBefore(filterChainExHandler, LogoutFilter.class) .addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/org/openeo/spring/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java similarity index 85% rename from src/main/java/org/openeo/spring/GlobalSecurityConfig.java rename to src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index dac8e3e..54a3bc6 100644 --- a/src/main/java/org/openeo/spring/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -1,4 +1,4 @@ -package org.openeo.spring; +package org.openeo.spring.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; @@ -37,7 +37,17 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { boolean enableBasicAuth; @Value("${spring.security.enable-keycloak}") - boolean enableKeycloakAuth; + boolean enableKeycloakAuth; + + /** API resources that do not require authentication. */ + public static final String[] NOAUTH_API_RESOURCES = new String[] { + "/", + "/conformance", + "/file_formats", + "/.well-known/**"}; + + public static final String BASIC_AUTH_API_RESOURCE = "/credentials/basic"; + public static final String OIDC_AUTH_API_RESOURCE = "/credentials/oidc"; /** * Configured the application environment (e.g. activate profiles @@ -70,4 +80,5 @@ public static class RecommendedSecurityConfig extends KeycloakSecurityConfig {} */ @Order(2) public static class OptionalSecurityConfig extends BasicSecurityConfig {} + } diff --git a/src/main/java/org/openeo/spring/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java similarity index 95% rename from src/main/java/org/openeo/spring/KeycloakSecurityConfig.java rename to src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java index 2d6a4a6..c375b7a 100644 --- a/src/main/java/org/openeo/spring/KeycloakSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java @@ -1,7 +1,7 @@ -package org.openeo.spring; +package org.openeo.spring.security; -import static org.openeo.spring.GlobalSecurityManager.NOAUTH_API_RESOURCES; -import static org.openeo.spring.GlobalSecurityManager.OIDC_AUTH_API_RESOURCE; +import static org.openeo.spring.security.GlobalSecurityConfig.NOAUTH_API_RESOURCES; +import static org.openeo.spring.security.GlobalSecurityConfig.OIDC_AUTH_API_RESOURCE; import org.openeo.spring.keycloak.KeycloakLogoutHandler; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/org/openeo/spring/security/package-info.java b/src/main/java/org/openeo/spring/security/package-info.java new file mode 100644 index 0000000..7e42efa --- /dev/null +++ b/src/main/java/org/openeo/spring/security/package-info.java @@ -0,0 +1,5 @@ +package org.openeo.spring.security; + +/** + * Application security configurations. + */ \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/token/ITokenService.java b/src/main/java/org/openeo/spring/token/ITokenService.java deleted file mode 100644 index c4a3f91..0000000 --- a/src/main/java/org/openeo/spring/token/ITokenService.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.openeo.spring.token; - -import org.springframework.security.core.userdetails.UserDetails; - -/** - * Interface for a (bearer) token service. - * - * @see https://datatracker.ietf.org/doc/html/rfc6750 - */ -public interface ITokenService { - - /** - * Generates a token hash. - * - * @param user the logged user. - */ - String generateToken(UserDetails user); - - /** - * Parses a token. - * - * @param token the received token. - */ - UserDetails parseToken(String token); - - //String extractToken(HttpRequest or HttpResponse) "Authorization: Bearer basic//$TOKEN" -} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 6ef8193..15f1fc9 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1 +1 @@ -org.springframework.boot.env.EnvironmentPostProcessor=org.openeo.spring.GlobalSecurityConfig +org.springframework.boot.env.EnvironmentPostProcessor=org.openeo.spring.security.GlobalSecurityConfig diff --git a/src/test/java/org/openeo/spring/api/loaders/ICollectionTester.java b/src/test/java/org/openeo/spring/loaders/ICollectionTester.java similarity index 96% rename from src/test/java/org/openeo/spring/api/loaders/ICollectionTester.java rename to src/test/java/org/openeo/spring/loaders/ICollectionTester.java index 3186894..cfdfc54 100644 --- a/src/test/java/org/openeo/spring/api/loaders/ICollectionTester.java +++ b/src/test/java/org/openeo/spring/loaders/ICollectionTester.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import org.openeo.spring.model.Collection; diff --git a/src/test/java/org/openeo/spring/api/loaders/Resource.java b/src/test/java/org/openeo/spring/loaders/Resource.java similarity index 97% rename from src/test/java/org/openeo/spring/api/loaders/Resource.java rename to src/test/java/org/openeo/spring/loaders/Resource.java index 9369879..b85b124 100644 --- a/src/test/java/org/openeo/spring/api/loaders/Resource.java +++ b/src/test/java/org/openeo/spring/loaders/Resource.java @@ -1,4 +1,4 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import java.io.IOException; import java.io.InputStream; diff --git a/src/test/java/org/openeo/spring/api/loaders/TestWCSCollectionsLoader.java b/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java similarity index 95% rename from src/test/java/org/openeo/spring/api/loaders/TestWCSCollectionsLoader.java rename to src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java index 60b06fb..2f5fe39 100644 --- a/src/test/java/org/openeo/spring/api/loaders/TestWCSCollectionsLoader.java +++ b/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java @@ -1,16 +1,18 @@ -package org.openeo.spring.api.loaders; +package org.openeo.spring.loaders; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openeo.spring.loaders.WCSCollectionsLoader.BANDS_DIM; +import static org.openeo.spring.model.Dimension.TypeEnum.BANDS; +import static org.openeo.spring.model.Dimension.TypeEnum.OTHER; +import static org.openeo.spring.model.Dimension.TypeEnum.SPATIAL; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; -import java.nio.file.Files; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -26,30 +28,21 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.openeo.spring.api.CollectionsApi; -import org.openeo.spring.api.CollectionsApiController; import org.openeo.spring.api.LinkRelType; +import org.openeo.spring.loaders.JSONMarshaller; +import org.openeo.spring.loaders.WCSCollectionsLoader; import org.openeo.spring.model.Collection; import org.openeo.spring.model.CollectionSpatialExtent; import org.openeo.spring.model.CollectionSummaries; import org.openeo.spring.model.CollectionTemporalExtent; -import org.openeo.spring.model.Collections; import org.openeo.spring.model.Dimension; -import org.openeo.spring.model.DimensionBands; import org.openeo.spring.model.Dimension.TypeEnum; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - +import org.openeo.spring.model.DimensionBands; import org.openeo.spring.model.DimensionSpatial; import org.openeo.spring.model.HasUnit; import org.openeo.spring.model.Link; import org.openeo.spring.model.Providers; -import static org.openeo.spring.model.Dimension.TypeEnum.*; -import static org.openeo.spring.api.loaders.WCSCollectionsLoader.BANDS_DIM; - /** * Unit tests for the {@link WCSCollectionsLoader} class. */ diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java new file mode 100644 index 0000000..1db67ff --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -0,0 +1,140 @@ +package org.openeo.spring.security; + +import static org.hamcrest.core.StringStartsWith.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.openeo.spring.api.CredentialsApiController; +import org.openeo.spring.bearer.JWTTokenService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.jayway.jsonpath.JsonPath; + +/** + * Tests the Basic Authentication login process + * and Bearer token session management. + */ +@RunWith(SpringRunner.class) +@WebMvcTest(CredentialsApiController.class) +public class TestBasicAuthentication { + + @Autowired + private MockMvc mvc; + + @Autowired + private JWTTokenService tokenService; + + @Test + @WithMockUser(username = "satan", password = "petrodragonic") + public void get_okBasic_shouldSucceedWith200() throws Exception { + MvcResult mvcResult = mvc.perform(get("/credentials/basic") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Basic c2F0YW46cGV0cm9kcmFnb25pYw==") + ).andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + // token is in header + header().string(HttpHeaders.AUTHORIZATION, startsWith("Bearer ")), + // token is in body + jsonPath("$.access_token").exists() + ).andReturn(); + + // body token equals header token + String response = mvcResult.getResponse().getContentAsString(); + String authHeader = mvcResult.getResponse().getHeader(HttpHeaders.AUTHORIZATION); + + String headerToken = authHeader.substring("Bearer ".length()); + String bodyAccessToken = JsonPath.parse(response).read("$.access_token"); + + assertEquals(bodyAccessToken, headerToken, "token in body and header should coincide"); + } + + @Test + @WithMockUser(value = "satan") + public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Exception { + // manually generate token + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UserDetails user = (UserDetails) auth.getPrincipal(); + String token = tokenService.generateToken(user); + + mvc.perform(get("/collections") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, + String.format("Bearer basic//%s", token)) + ).andExpect( + status().isOk()); + } + + @Test + public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Exception { + mvc.perform(get("/collections") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") + ).andExpect( + status().is(403)); + } + + @Test + public void get_wrongTokenPrefix_shouldReturn403() throws Exception { + mvc.perform(get("/collections") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") + ).andExpect( + status().is(403)); + } + + @Test + @WithMockUser(value = "satan") + public void get_expiredToken_shouldReturn403() throws Exception { + // manually generate token + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UserDetails user = (UserDetails) auth.getPrincipal(); + String token = tokenService.generateToken(user, -1, ChronoUnit.SECONDS); + + mvc.perform(get("/collections") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, + String.format("Bearer basic//%s", token)) + ).andExpect( + status().is(403)); + } + + @Test + public void get_noAuth_shouldReturnAuthRequired401() throws Exception { + mvc.perform(get("/credentials/basic") + .contentType(MediaType.APPLICATION_JSON) + ).andExpectAll( + status().is(401), + header().exists(HttpHeaders.WWW_AUTHENTICATE)); + } + + /** @see BasicAuthenticationFilter */ + @Test + @WithMockUser(username = "satan", password = "petrodragonic") + public void get_wrongAuth_shouldReturn401() throws Exception { + mvc.perform(get("/credentials/basic") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Basic _InfestTheRatsNest_=") + ).andExpect( + status().is(401)); + } +} From 73d0be8ba3928b911c026d51f393a748b8b9d2f4 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Mon, 24 Jul 2023 17:25:56 +0200 Subject: [PATCH 06/27] Add favicon.ico to static content. --- .../openeo/spring/FaviconConfiguration.java | 36 ++++++++++++++++++ .../spring/security/GlobalSecurityConfig.java | 1 + src/main/resources/static/openeo.ico | Bin 0 -> 11998 bytes 3 files changed, 37 insertions(+) create mode 100644 src/main/java/org/openeo/spring/FaviconConfiguration.java create mode 100644 src/main/resources/static/openeo.ico diff --git a/src/main/java/org/openeo/spring/FaviconConfiguration.java b/src/main/java/org/openeo/spring/FaviconConfiguration.java new file mode 100644 index 0000000..c79bb8e --- /dev/null +++ b/src/main/java/org/openeo/spring/FaviconConfiguration.java @@ -0,0 +1,36 @@ +package org.openeo.spring; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; +import org.springframework.web.servlet.resource.ResourceHttpRequestHandler; + +/** + * Handler of the favicon.ico request. + * + * TODO: Spring Boot should automatically serve static content in resources + * classpath, but without this explicit handler nothing happens on {@code GET /favicon.ico}. + */ +@Configuration +public class FaviconConfiguration { + + @Bean + public SimpleUrlHandlerMapping customFaviconHandlerMapping() { + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setOrder(Integer.MIN_VALUE); + mapping.setUrlMap(Collections.singletonMap("/favicon.ico", faviconRequestHandler())); + return mapping; + } + + @Bean + protected ResourceHttpRequestHandler faviconRequestHandler() { + ResourceHttpRequestHandler requestHandler = new ResourceHttpRequestHandler(); + List locations = Arrays.asList("classpath:static/"); + requestHandler.setLocationValues(locations); + return requestHandler; + } +} diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 54a3bc6..6e7f374 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -42,6 +42,7 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { /** API resources that do not require authentication. */ public static final String[] NOAUTH_API_RESOURCES = new String[] { "/", + "/favicon.ico", "/conformance", "/file_formats", "/.well-known/**"}; diff --git a/src/main/resources/static/openeo.ico b/src/main/resources/static/openeo.ico new file mode 100644 index 0000000000000000000000000000000000000000..146699badcc3702bcf02d46afbd06ffe43a30432 GIT binary patch literal 11998 zcmeI2e~eUD7017K=E2DBfYu-E?65HG>@M47m*IhRciEo|RGi&qV1LlvVYya~Vo)R! zf7BmAMVmG?RnjIXja7otrdExmF_LPf25Ty4+t5~P4Jk@&O+_(?K!76g+y^zICz>#_C zk1E9t#MW%Z;c=U;9@Ys{b@%Yi_@XM!u zk6#~u5x;%@1djjVB%XcoPx$AFV>o;A8Jv6h1)P8NCH(HhDg5!|%Q$iBuXyP%uj14z zr}4_)&fp)XPvgShPvP{Lf8os8f8*@A3pjWFA}*dkk1Llh;=;vCxOnL@E?>Tk|6IL- zE3aL}Yge!0+O=zPEM^Wf7WjX#K&axEi19L{-wJV5&I*@-8l)bLO@U=){KLA4m6`$z z9sXIZ#Oj#}%Nq6#ixaE8B9>TIgd}{kYQ)o(u>ygB;5GVYS+wRYtANF7hR4RnT8LHS zmz5T8)>Z*4lop800(#X^pRlEbVoS?s1yc$zI-dn3wKOk7CIo9Po5ec#;E+_Gj`;PB zAwmI5%4M-8&SNaxU#c@UJ?Ba)AXp9MvaC@)76hykuvEa~H!;C#ESJ^BIVE>Mz;@rd zP$7ycvwDeWQto)wPVlwTAVB;&;jj~^Hd*rZ%JjqV2p_K&?rl(($WtTHv!8M%s=~yUWA!n-P&_uVqF-3%VbeF+m-JB15 zm(~!`^F?$EQKNp7Y?Um7RZp#H6X6jnX{dCHlU3r8MaB);EsXeQSu|J!rUfN5wBFFE ziYw#|GaHfJvdYLeL_m^rQmS$f;fWmBxH5A?wt9_xxY|FfdJE@CmtYi@WtGa3t?tpJ z*U~0y;{I8E)G}%y+@dw2b;|RYi0LGq+1jwINcd)np_a2mw(}gk?%{OHU^&+4^~(wj zlD|7|>;ch*=y6tb-?P5q4075hi?xul0*)cEtFj<^A?>NwxKphb6wKj1S)nn`d$bna zFMXYj_!|b&avsYkq-jSM>d8-ekgxg0x;P)vjWo)#WG;oUJHs;7VEP3d)28&(AQiq? z3kz6~*;`UVaa`NVS=dbFocHU?0C}q)R*>GjLpO5Hj8SDXu#l#mlJQn&VRN2XDXJTE zvj|=IVbLa`*Qf z`@YU{UgWS?v^QzHJqN2v_U1>65cSGP1$@9%%=EC>ESdI+Z2^hZXm?V=o~m=kMAg!f zMVnPPb!$o#Sey)z;~P%?AToU{=y4aObbi-LD9^pE!n4^g9RY?HS18-Yjg$XeD4ZhHh?e=`vQ^^|_(WCqzBQWaja%xpU{DDOz1s zRaKu5N$rY;9zh(9vDH?bkJJPsc2{3tg7#aL3sX+Gao)UnFuF6?(9oHoYg+6>lzA?y z9ALC$Yh8k7IlF*WH3Snevt4cJj7F6eneh!ovtm6G-+?6GMDZ}n`wPU}3UKCkB$uW3 z(w(q+*OSWXw6P#mfN-0%yB4H)=#)F;-YglyLf#`DoRaIgBxJKh;y^fqkTCsqTUa!{ zewunDfT3Zj+c_ZIqIRN`QMNHOO>S`a!`Zj1v?OvpLuf7smdn*+td2fU@jpA99_O3o2?V#bvG;Vu%?kj zQPzm4YS28asTeG;ZFWnc-Q#s*E!BkV`SoI{%qhzeCA7pBWb?s1LsVG7>U4-OX(C3A zC8pIW=>4MEjgkRFbKk)5bOm*71&!Lc&LeWX+PK=ii{G24F05Af>Md#x>m%&>49hw1 zipMP$sEXB2mBtXI&9#!7-s}+?uPUfTImyJ7NZB#bs&25m~1`Bs#Vf=Mr-guxHXN|bOv7mmr*D^)V8s|Lf)Kn2r0-Bw$oSAiDu zMYWE?XCzu%mw6u_Q=?S7TDIuFPG-+oqt43;Yxma5^~;jq`m#5=Y}BoZKc-aCk>o#_5|!ePL24#~h`hZXOjaKnu&?f%1(=>vyEXS$yC2Cg0++z-l!u zS)*(-+$OxHK(w-2vOj#iF+V;OtI4v+)t$aYP$zdTJVWCB6^hnfE8ea131POP(J%jo z$)a%y)wT3ZP&5pNm$o-n1%p8qkKGZontDx8=`k{&$YBYkR*^txEwWQagfX@FE5lwm zTj(yLU)~_YRmJV3-|$crEMo6accQUomL`gy4#Za@g%5=4n(cnS|DT~KM&5Aa{N(6R tvdeC5SrU)c#bWWsB})?>J;|ZUM<<`_j`~bt#sV`In6bc&1^%}d_%EJ1QGWmc literal 0 HcmV?d00001 From 71df9d137daafbc564bd833097c43f0cbcc21361 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Mon, 24 Jul 2023 18:29:50 +0200 Subject: [PATCH 07/27] Add profiles in integration tests. --- .../spring/api/DefaultApiController.java | 39 +++++-- .../openeo/spring/api/FaviconController.java | 11 ++ .../openeo/spring/api/JobsApiController.java | 2 +- .../spring/api/ResultApiController.java | 2 +- .../spring/bearer/JWTAuthorizationFilter.java | 14 ++- .../FilterChainExceptionHandler.java | 7 +- .../spring/components/JobScheduler.java | 2 +- .../legacy/KeycloakConfiguration.java | 7 +- .../KeycloakSecurityConfigAdapter.java | 3 +- .../spring/loaders/WCSCollectionsLoader.java | 2 +- .../spring/security/BasicSecurityConfig.java | 2 +- .../security/KeycloakSecurityConfig.java | 5 +- .../static/{openeo.ico => favicon.ico} | Bin .../security/TestBasicAuthentication.java | 43 ++++++-- .../TestBasicAuthentication_OIDCDisabled.java | 21 ++++ .../TestBasicAuthentication_OIDCEnabled.java | 9 ++ .../security/TestOIDCAuthentication.java | 29 +++++ .../resources/application-ba+oidc.properties | 2 + src/test/resources/application-ba.properties | 2 + .../resources/application-oidc.properties | 2 + .../resources/application-test.properties | 99 ++++++++++++++++++ 21 files changed, 261 insertions(+), 42 deletions(-) create mode 100644 src/main/java/org/openeo/spring/api/FaviconController.java rename src/main/java/org/openeo/spring/{ => keycloak/legacy}/KeycloakSecurityConfigAdapter.java (98%) rename src/main/resources/static/{openeo.ico => favicon.ico} (100%) create mode 100644 src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java create mode 100644 src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java create mode 100644 src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java create mode 100644 src/test/resources/application-ba+oidc.properties create mode 100644 src/test/resources/application-ba.properties create mode 100644 src/test/resources/application-oidc.properties create mode 100644 src/test/resources/application-test.properties diff --git a/src/main/java/org/openeo/spring/api/DefaultApiController.java b/src/main/java/org/openeo/spring/api/DefaultApiController.java index 4140732..081e7e7 100644 --- a/src/main/java/org/openeo/spring/api/DefaultApiController.java +++ b/src/main/java/org/openeo/spring/api/DefaultApiController.java @@ -12,7 +12,9 @@ import org.openeo.spring.model.Endpoint; import org.openeo.spring.model.Endpoint.MethodsEnum; import org.openeo.spring.model.Link; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; @@ -31,8 +33,8 @@ @RequestMapping("${openapi.openEO.base-path:}") public class DefaultApiController implements DefaultApi { - public static final String DEFAULT_OPENEO_API_VERSION = "1.0.0"; - public static final String DEFAULT_STAC_VERSION = "0.9.0"; + public static final String IMPLEMENTED_OPENEO_API_VERSION = "1.0.0"; // TODO 1.1.0 ? + public static final String IMPLEMENTED_STAC_VERSION = "0.9.0"; private final NativeWebRequest request; @@ -43,6 +45,15 @@ public class DefaultApiController implements DefaultApi { @Value("${org.openeo.wcps.provider.url}") private String providerUrl; + + @Value("${spring.security.enable-basic}") + boolean enableBasicAuth; + + @Value("${spring.security.enable-keycloak}") + boolean enableKeycloakAuth; + + @Autowired + ConfigurableEnvironment env; @org.springframework.beans.factory.annotation.Autowired public DefaultApiController(NativeWebRequest request) { @@ -54,7 +65,8 @@ public Optional getRequest() { return Optional.ofNullable(request); } - @Operation(summary = "Information about the back-end", operationId = "capabilities", description = "Returns general information about the back-end, including which version and endpoints of the openEO API are supported. May also include billing information.", tags = { + @Override + @Operation(summary = "Information about the back-end", operationId = "capabilities", description = "Returns general information about the back-end, including which version and endpoints of the openEO API are supported. May also include billing information.", tags = { "Capabilities", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Information about the API version and supported endpoints / features."), @@ -65,13 +77,13 @@ public ResponseEntity capabilities() { Capabilities capabilities = new Capabilities(); - capabilities.apiVersion(DEFAULT_OPENEO_API_VERSION); + capabilities.apiVersion(IMPLEMENTED_OPENEO_API_VERSION); capabilities.backendVersion("Spring-Dev-1.0.0"); capabilities.description( "The Eurac Research backend provides EO data available for processing using OGC WC(P)S and the open data cube"); capabilities.title("Eurac Research - openEO - backend"); capabilities.setId("Eurac_openEO"); - capabilities.setStacVersion(DEFAULT_STAC_VERSION); + capabilities.setStacVersion(IMPLEMENTED_STAC_VERSION); Endpoint capabilitiesEndPoint = new Endpoint(); capabilitiesEndPoint.setPath("/"); @@ -123,10 +135,19 @@ public ResponseEntity capabilities() { conformanceEndpoint.addMethodsItem(MethodsEnum.GET); capabilities.addEndpointsItem(conformanceEndpoint); - Endpoint credntialsOIDCEndpoint = new Endpoint(); - credntialsOIDCEndpoint.setPath("/credentials/oidc"); - credntialsOIDCEndpoint.addMethodsItem(MethodsEnum.GET); - capabilities.addEndpointsItem(credntialsOIDCEndpoint); + if (enableBasicAuth) { + Endpoint credentialsBasicEndpoint = new Endpoint(); + credentialsBasicEndpoint.setPath("/credentials/basic"); + credentialsBasicEndpoint.addMethodsItem(MethodsEnum.GET); + capabilities.addEndpointsItem(credentialsBasicEndpoint); + } + + if (enableKeycloakAuth) { + Endpoint credentialsOIDCEndpoint = new Endpoint(); + credentialsOIDCEndpoint.setPath("/credentials/oidc"); + credentialsOIDCEndpoint.addMethodsItem(MethodsEnum.GET); + capabilities.addEndpointsItem(credentialsOIDCEndpoint); + } Endpoint jobsEndpoint = new Endpoint(); jobsEndpoint.setPath("/jobs"); diff --git a/src/main/java/org/openeo/spring/api/FaviconController.java b/src/main/java/org/openeo/spring/api/FaviconController.java new file mode 100644 index 0000000..07d0831 --- /dev/null +++ b/src/main/java/org/openeo/spring/api/FaviconController.java @@ -0,0 +1,11 @@ +package org.openeo.spring.api; + +import org.springframework.stereotype.Controller; + +@Controller +public class FaviconController { + +// @GetMapping("favicon.ico") +// @ResponseBody +// public ResponseEntity returnNoFavicon() {} +} diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index 84d3710..bc8c9ce 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -1,6 +1,6 @@ package org.openeo.spring.api; -import static org.openeo.spring.KeycloakSecurityConfigAdapter.EURAC_ROLE; +import static org.openeo.spring.keycloak.legacy.KeycloakSecurityConfigAdapter.EURAC_ROLE; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index 1fdc4e2..7cfe9cf 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -1,6 +1,6 @@ package org.openeo.spring.api; -import static org.openeo.spring.KeycloakSecurityConfigAdapter.EURAC_ROLE; +import static org.openeo.spring.keycloak.legacy.KeycloakSecurityConfigAdapter.EURAC_ROLE; import java.io.BufferedReader; import java.io.File; diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index 4f0b758..960cc2f 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -56,11 +56,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse "Invalid authorization header. Expected: %s%sTOKEN", BEARER_HEADER_PRE, TOKEN_PREFIX)); } else { - UsernamePasswordAuthenticationToken auth = parseToken(authorizationHeader); - if (null != auth) { - SecurityContextHolder.getContext().setAuthentication(auth); - } else { - LOGGER.error("Invalid token received: authentication unsuccessful."); + try { + UsernamePasswordAuthenticationToken auth = parseToken(authorizationHeader); + if (null != auth) { + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + LOGGER.error("Invalid token received: authentication unsuccessful."); + } + } catch (JwtException ex) { + throw ex; } } } diff --git a/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java index 485ac90..55000e3 100644 --- a/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java +++ b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java @@ -11,13 +11,12 @@ import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.HandlerExceptionResolver; /** - * The overarching exception handler in the HTTP chain of filters. + * The overarching catch-all exception handler in the HTTP chain of filters. * * This filter should be placed before other custom security, * authentication or authorization filters so that the new logic @@ -38,10 +37,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { filterChain.doFilter(request, response); - } catch (BadCredentialsException bex) { - LOGGER.error("TESTING", bex); - resolver.resolveException(request, response, null, bex); - } catch (Exception e) { LOGGER.error("Spring Security Filter Chain Exception:", e); resolver.resolveException(request, response, null, e); // --> to ExceptionTranslators diff --git a/src/main/java/org/openeo/spring/components/JobScheduler.java b/src/main/java/org/openeo/spring/components/JobScheduler.java index 0f60c7b..09cb7f2 100644 --- a/src/main/java/org/openeo/spring/components/JobScheduler.java +++ b/src/main/java/org/openeo/spring/components/JobScheduler.java @@ -74,7 +74,7 @@ public class JobScheduler implements JobEventListener, UDFEventListener { /** STAC spec. version used. */ - public static final String STAC_VERSION = DefaultApiController.DEFAULT_STAC_VERSION; + public static final String STAC_VERSION = DefaultApiController.IMPLEMENTED_STAC_VERSION; private static final Logger log = LogManager.getLogger(JobScheduler.class); diff --git a/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java index 221b24e..e9fc714 100644 --- a/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java @@ -12,10 +12,11 @@ import org.springframework.context.annotation.Configuration; /** - * Provides the configuation resolver to the Keycloak auth module. + * Provides the configuation resolver to the Keycloak auth module. + * + * @deprecated circular dependency issue form Spring Boot 2.6.X. + * @see https://stackoverflow.com/questions/70207564/spring-boot-2-6-regression-how-can-i-fix-keycloak-circular-dependency-in-adapte */ -// Separate file to avoid circular deps with Spring Boot 2.6.x -// https://stackoverflow.com/questions/70207564/spring-boot-2-6-regression-how-can-i-fix-keycloak-circular-dependency-in-adapte @ConditionalOnExpression(value = "false") @Configuration @Deprecated diff --git a/src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakSecurityConfigAdapter.java similarity index 98% rename from src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java rename to src/main/java/org/openeo/spring/keycloak/legacy/KeycloakSecurityConfigAdapter.java index c2612df..08404e5 100644 --- a/src/main/java/org/openeo/spring/KeycloakSecurityConfigAdapter.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakSecurityConfigAdapter.java @@ -1,4 +1,4 @@ -package org.openeo.spring; +package org.openeo.spring.keycloak.legacy; import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; @@ -9,7 +9,6 @@ import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter; import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter; import org.keycloak.adapters.springsecurity.management.HttpSessionManager; -import org.openeo.spring.keycloak.legacy.OpenEOKeycloakAuthenticationProcessingFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.ConfigurableBeanFactory; diff --git a/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java index 93c1983..c27f58d 100644 --- a/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java @@ -83,7 +83,7 @@ public class WCSCollectionsLoader implements ICollectionsLoader { /** STAC spec. version used. */ // FIXME this should be dictated by the model implemented - public static final String STAC_VERSION = DefaultApiController.DEFAULT_STAC_VERSION; + public static final String STAC_VERSION = DefaultApiController.IMPLEMENTED_STAC_VERSION; /** * The STAC extensions used in the catalog. diff --git a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java index be4d271..6b6ab43 100644 --- a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -50,7 +50,7 @@ public class BasicSecurityConfig { FilterChainExceptionHandler filterChainExHandler; /** Used to define a {@link Profile}. */ - public static final String PROFILE_ID = "BASIC_AUTH_FILE"; + public static final String PROFILE_ID = "BASIC_AUTH"; /** Label for the "realm" set in {@code WWW-Authenticate} response header. */ public static final String REALM_LABEL = "openEO"; diff --git a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java index c375b7a..183e549 100644 --- a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java @@ -1,5 +1,6 @@ package org.openeo.spring.security; +import static org.openeo.spring.security.GlobalSecurityConfig.BASIC_AUTH_API_RESOURCE; import static org.openeo.spring.security.GlobalSecurityConfig.NOAUTH_API_RESOURCES; import static org.openeo.spring.security.GlobalSecurityConfig.OIDC_AUTH_API_RESOURCE; @@ -85,8 +86,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web .ignoring() - .antMatchers(NOAUTH_API_RESOURCES); -// .antMatchers(BASIC_AUTH_API_RESOURCE); + .antMatchers(NOAUTH_API_RESOURCES) + .antMatchers(BASIC_AUTH_API_RESOURCE); } /** diff --git a/src/main/resources/static/openeo.ico b/src/main/resources/static/favicon.ico similarity index 100% rename from src/main/resources/static/openeo.ico rename to src/main/resources/static/favicon.ico diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 1db67ff..56a5460 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -23,31 +23,42 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.context.WebApplicationContext; import com.jayway.jsonpath.JsonPath; /** * Tests the Basic Authentication login process * and Bearer token session management. + * + * The class is abstract, to re-use the integration tests + * for multiple profiles. + * + * @see ActiveProfiles */ @RunWith(SpringRunner.class) @WebMvcTest(CredentialsApiController.class) -public class TestBasicAuthentication { +//@ActiveProfiles("test") // -> src/test/resources/application-$PROFILE.properties +public abstract class TestBasicAuthentication { @Autowired - private MockMvc mvc; + MockMvc mvc; @Autowired - private JWTTokenService tokenService; + JWTTokenService tokenService; + + @Autowired + WebApplicationContext applicationContext; @Test @WithMockUser(username = "satan", password = "petrodragonic") public void get_okBasic_shouldSucceedWith200() throws Exception { MvcResult mvcResult = mvc.perform(get("/credentials/basic") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Basic c2F0YW46cGV0cm9kcmFnb25pYw==") ).andExpectAll( status().isOk(), @@ -77,7 +88,6 @@ public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Except String token = tokenService.generateToken(user); mvc.perform(get("/collections") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpect( @@ -87,7 +97,6 @@ public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Except @Test public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Exception { mvc.perform(get("/collections") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") ).andExpect( status().is(403)); @@ -96,7 +105,6 @@ public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Except @Test public void get_wrongTokenPrefix_shouldReturn403() throws Exception { mvc.perform(get("/collections") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") ).andExpect( status().is(403)); @@ -111,7 +119,6 @@ public void get_expiredToken_shouldReturn403() throws Exception { String token = tokenService.generateToken(user, -1, ChronoUnit.SECONDS); mvc.perform(get("/collections") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpect( @@ -121,7 +128,6 @@ public void get_expiredToken_shouldReturn403() throws Exception { @Test public void get_noAuth_shouldReturnAuthRequired401() throws Exception { mvc.perform(get("/credentials/basic") - .contentType(MediaType.APPLICATION_JSON) ).andExpectAll( status().is(401), header().exists(HttpHeaders.WWW_AUTHENTICATE)); @@ -132,9 +138,26 @@ public void get_noAuth_shouldReturnAuthRequired401() throws Exception { @WithMockUser(username = "satan", password = "petrodragonic") public void get_wrongAuth_shouldReturn401() throws Exception { mvc.perform(get("/credentials/basic") - .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "Basic _InfestTheRatsNest_=") ).andExpect( status().is(401)); } + + /** + * Testing if disabling basic authentication returns the proper error. + * The test is not optimal: as we are manually changing the internal field + * in the controller, a posteriori of the initial mock setup from the properties. + * + * TODO create profiles of properties then + */ + @Test + @WithMockUser(username = "charlie") + public void disabledBasicAuth_shouldReturn501() throws Exception { + CredentialsApiController controller = applicationContext.getBean(CredentialsApiController.class); + ReflectionTestUtils.setField(controller, "enableBasicAuth", false); + + mvc.perform(get("/credentials/basic") + ).andExpect( + status().is(501)); + } } diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java new file mode 100644 index 0000000..e018b85 --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java @@ -0,0 +1,21 @@ +package org.openeo.spring.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +/** + * Run test suite for case where OIDC auth is disabled. + */ +@ActiveProfiles("ba") +public class TestBasicAuthentication_OIDCDisabled extends TestBasicAuthentication { + + @Test + public void disabledOIDCAuth_shouldReturn501() throws Exception { + mvc.perform(get("/credentials/oidc") + ).andExpect( + status().is(501)); + } +} diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java new file mode 100644 index 0000000..68778ec --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java @@ -0,0 +1,9 @@ +package org.openeo.spring.security; + +import org.springframework.test.context.ActiveProfiles; + +/** + * Run test suite for case where OIDC auth is also enabled. + */ +@ActiveProfiles("ba+oidc") +public class TestBasicAuthentication_OIDCEnabled extends TestBasicAuthentication {} diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java new file mode 100644 index 0000000..bc57b9b --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -0,0 +1,29 @@ +package org.openeo.spring.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.openeo.spring.api.CredentialsApiController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +@RunWith(SpringRunner.class) +@WebMvcTest(CredentialsApiController.class) +@ActiveProfiles("oidc") +public class TestOIDCAuthentication { + + @Autowired + private MockMvc mvc; + + @Test + public void disabledBasicAuth_shouldReturn501() throws Exception { + mvc.perform(get("/credentials/basic") + ).andExpect( + status().is(501)); + } +} diff --git a/src/test/resources/application-ba+oidc.properties b/src/test/resources/application-ba+oidc.properties new file mode 100644 index 0000000..28e9256 --- /dev/null +++ b/src/test/resources/application-ba+oidc.properties @@ -0,0 +1,2 @@ +spring.security.enable-basic=true +spring.security.enable-keycloak=true \ No newline at end of file diff --git a/src/test/resources/application-ba.properties b/src/test/resources/application-ba.properties new file mode 100644 index 0000000..6d0652b --- /dev/null +++ b/src/test/resources/application-ba.properties @@ -0,0 +1,2 @@ +spring.security.enable-basic=true +spring.security.enable-keycloak=false \ No newline at end of file diff --git a/src/test/resources/application-oidc.properties b/src/test/resources/application-oidc.properties new file mode 100644 index 0000000..82689b2 --- /dev/null +++ b/src/test/resources/application-oidc.properties @@ -0,0 +1,2 @@ +spring.security.enable-basic=false +spring.security.enable-keycloak=true \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..e0bb00a --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,99 @@ +#################################################################### +# FIXME: this is workaround to allow Spring Security conf from XML +spring.main.allow-bean-definition-overriding=true +# Spring 5.3 : https://spring.io/blog/2020/06/30/url-matching-with-pathpattern-in-spring-mvc : +#spring.mvc.pathmatch.matching-strategy = ANT_PATH_MATCHER +#################################################################### +spring.security.enable-basic=true +spring.security.enable-keycloak=true +# Basic JWT tokens +jwt.secret=cf6d7564cf06725c99cff8b8722f58b8f33aeb7b9df22ad7c0e5e7b896fd0411edbe3de7ca76939db0840fd8cf4640d256c27302f12d5c20804d2b952da97685 +jwt.issuer=ACME Srl +jwt.type=JWT +jwt.audience=openEO +jwt.exp-minutes=30 +# OAuth2/Keycloak +# @see https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings +#spring.autoconfigure.exclude = org.keycloak.adapters.springboot.KeycloakAutoConfiguration # legacy +spring.security.oauth2.client.registration.keycloak.client-id=openEO +spring.security.oauth2.client.registration.keycloak.client-secret=47eca175-ac0d-4938-8b4f-f30718c29405 +spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.keycloak.scope=openid +# OpenID Connect (OIDC) +spring.security.oauth2.client.provider.keycloak.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username +# OAUTH2-JWT token +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp +# openEO credentials +org.openeo.oidc.providers.list=classpath:oidc_providers.json +# legacy: +#org.openeo.oidc.configuration.endpoint=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.keycloak.conf-file=keycloak.json +#################################################################### + +#springfox.documentation.swagger.v2.path=/api-docs +# TODO v3 ? +spring.jackson.date-format=org.openeo.spring.RFC3339DateFormat +spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false +spring.h2.console.enabled=true +spring.h2.console.settings.web-allow-others=true + +server.tomcat.port=8082 +server.port=8444 + +spring.datasource.jdbc=jdbc:h2:~/openeo/DB/openeo.dev.test.db;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.initialization-mode + +server.ssl.key-store-type=PKCS12 +server.ssl.key-store=classpath:keystore.p12 +server.ssl.key-store-password=spring85 +server.ssl.key-alias=myalias +#server.ssl.trust-store=classpath:keystore.p12 +#server.ssl.trust-store-password=changeit + +security.require-ssl=true +spring.security.filter.order=5 + +spring.jackson.serialization.write-dates-as-timestamps=false + +co.elasticsearch.endpoint=http://10.8.244.194:9200 +co.elasticsearch.service.name=openeo +co.elasticsearch.service.node.name=alex_dev + +# !! must coincide with TMP_FOLDER_PATH of openEO ODC driver: +org.openeo.tmp.dir=/tmp/openeo/ +org.openeo.tmp.file.expiry=60 +org.openeo.file.expiry=1 +org.openeo.querycollectionsonstartup=true +org.openeo.parallelizedHarvest=true + +#org.openeo.wcps.endpoint=http://eosao66:8088/rasdaman/ows +org.openeo.wcps.endpoint= +org.openeo.wcps.endpoint.version=2.0.1 +#org.openeo.wcps.endpoint=http://saocompute.eurac.edu/rasdaman/ows +org.openeo.wcps.provider.name=Eurac_EO_WCS +org.openeo.wcps.provider.url=http://www.eurac.edu +org.openeo.wcps.provider.type=host +org.openeo.wcps.processes.list=classpath:processes_wcps.json +org.openeo.wcps.collections.list=classpath:collections_wcps.json + +#org.openeo.odc.endpoint=http://10.8.244.123:5000/graph +#org.openeo.odc.endpoint=http://eosao66:5000/graph +org.openeo.odc.endpoint= +org.openeo.odc.deleteResultEndpoint=http://eosao66:5000/ +#org.openeo.odc.collectionsEndpoint=http://eosao66:5000/collections +org.openeo.odc.collectionsEndpoint= +org.openeo.odc.provider.name=Eurac_EO_ODC +org.openeo.odc.provider.url=http://www.eurac.edu +org.openeo.odc.provider.type=host +org.openeo.odc.processes.list=classpath:processes_odc.json +org.openeo.odc.collections.list=classpath:collections_odc.json + +org.openeo.endpoint=https://eosao66:8444 +org.openeo.public.endpoint=https://10.8.244.94:8444 +org.openeo.udf.python.endpoint=http://10.8.246.140:5000 +org.openeo.udf.candela.endpoint=http://10.8.246.140:5001 +org.openeo.udf.r.endpoint=http://10.8.246.140:5555 +org.openeo.udf.dir=/opt/rasdaman/import/10.8.244.194/ +org.openeo.udf.importscript=/opt/rasdaman/import/10.8.244.194/udf_result/import_udf_multi.sh From 7d0c8027eb3049b86cc640ad3220bd304aac6d40 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 25 Jul 2023 18:31:58 +0200 Subject: [PATCH 08/27] Add test for favicon request. --- .../org/openeo/spring/api/TestFavicon.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/test/java/org/openeo/spring/api/TestFavicon.java diff --git a/src/test/java/org/openeo/spring/api/TestFavicon.java b/src/test/java/org/openeo/spring/api/TestFavicon.java new file mode 100644 index 0000000..358e1eb --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestFavicon.java @@ -0,0 +1,29 @@ +package org.openeo.spring.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +@RunWith(SpringRunner.class) +@WebMvcTest(FaviconController.class) +public class TestFavicon { + + @Autowired + private MockMvc mvc; + + @Test + public void getFavicon_shouldReturn200() throws Exception { + mvc.perform(get("/favicon.ico") + ).andExpectAll( + status().is(200), + content().contentType(MediaType.valueOf("image/x-icon"))); + } +} From 7165ddb8517e5e918bc2cb45a26d8043582567ee Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 25 Jul 2023 19:15:12 +0200 Subject: [PATCH 09/27] Add CORS pre-flight requests management + tests. --- .../java/org/openeo/spring/ApiFilter.java | 36 ++--- ...nConfiguration.java => FaviconConfig.java} | 2 +- ...ibernateConf.java => HibernateConfig.java} | 2 +- .../org/openeo/spring/OpenAPI2SpringBoot.java | 15 +- .../java/org/openeo/spring/api/ApiUtil.java | 31 ++++ .../spring/api/CredentialsApiController.java | 28 +--- .../openeo/spring/api/JobsApiController.java | 152 ++++++++---------- .../openeo/spring/api/MeApiController.java | 12 +- .../spring/api/ResultApiController.java | 9 +- .../spring/api/WellKnownApiController.java | 10 +- .../components/ExceptionTranslator.java | 28 +--- .../spring/components/JobScheduler.java | 9 +- .../openeo/spring/security/CorsConfig.java | 102 ++++++++++++ .../spring/security/GlobalSecurityConfig.java | 13 +- .../openeo/spring/security/TestCorsConf.java | 68 ++++++++ 15 files changed, 341 insertions(+), 176 deletions(-) rename src/main/java/org/openeo/spring/{FaviconConfiguration.java => FaviconConfig.java} (97%) rename src/main/java/org/openeo/spring/{HibernateConf.java => HibernateConfig.java} (98%) create mode 100644 src/main/java/org/openeo/spring/security/CorsConfig.java create mode 100644 src/test/java/org/openeo/spring/security/TestCorsConf.java diff --git a/src/main/java/org/openeo/spring/ApiFilter.java b/src/main/java/org/openeo/spring/ApiFilter.java index b33ea5c..c710b99 100644 --- a/src/main/java/org/openeo/spring/ApiFilter.java +++ b/src/main/java/org/openeo/spring/ApiFilter.java @@ -20,32 +20,30 @@ public class ApiFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletResponse res = (HttpServletResponse) response; - HttpServletRequest req = (HttpServletRequest) request; + + HttpServletResponse res = response; + HttpServletRequest req = request; log.debug("Filter: URL" + " called: "+req.getRequestURL().toString()); - Enumeration headerEnum = req.getHeaderNames(); + + Enumeration headerEnum = req.getHeaderNames(); while(headerEnum.hasMoreElements()) { String headerName = headerEnum.nextElement(); log.trace(headerName + " = " + req.getHeader(headerName)); } + String clientIp = req.getHeader("Origin"); if(clientIp == null) { - clientIp = req.getHeader("X-Forwarded-For"); - if(clientIp == null) { - clientIp = request.getRemoteHost(); - log.debug("Got direct request from the following client: " + clientIp); - }else { - log.debug("Got proxy forwared request from the following client: " + clientIp); - } - }else { - log.debug("Got request from the following js client: " + clientIp); - } - res.addHeader("Access-Control-Allow-Origin", clientIp); - res.addHeader("Access-Control-Allow-Methods", "OPTIONS, GET, POST, DELETE, PUT, PATCH"); - res.addHeader("Access-Control-Allow-Headers", "origin, content-type, accept, authorization"); - res.addHeader("Access-Control-Allow-Credentials", "true"); - res.addHeader("Access-Control-Expose-Headers", "Location, OpenEO-Identifier, OpenEO-Costs"); + clientIp = req.getHeader("X-Forwarded-For"); + if(clientIp == null) { + clientIp = request.getRemoteHost(); + log.debug("Got direct request from the following client: " + clientIp); + } else { + log.debug("Got proxy forwared request from the following client: " + clientIp); + } + } else { + log.debug("Got request from the following js client: " + clientIp); + } + chain.doFilter(request, response); } - } \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/FaviconConfiguration.java b/src/main/java/org/openeo/spring/FaviconConfig.java similarity index 97% rename from src/main/java/org/openeo/spring/FaviconConfiguration.java rename to src/main/java/org/openeo/spring/FaviconConfig.java index c79bb8e..f20b518 100644 --- a/src/main/java/org/openeo/spring/FaviconConfiguration.java +++ b/src/main/java/org/openeo/spring/FaviconConfig.java @@ -16,7 +16,7 @@ * classpath, but without this explicit handler nothing happens on {@code GET /favicon.ico}. */ @Configuration -public class FaviconConfiguration { +public class FaviconConfig { @Bean public SimpleUrlHandlerMapping customFaviconHandlerMapping() { diff --git a/src/main/java/org/openeo/spring/HibernateConf.java b/src/main/java/org/openeo/spring/HibernateConfig.java similarity index 98% rename from src/main/java/org/openeo/spring/HibernateConf.java rename to src/main/java/org/openeo/spring/HibernateConfig.java index 92e5797..576e7f4 100644 --- a/src/main/java/org/openeo/spring/HibernateConf.java +++ b/src/main/java/org/openeo/spring/HibernateConfig.java @@ -18,7 +18,7 @@ @Configuration @EnableTransactionManagement -public class HibernateConf { +public class HibernateConfig { @Autowired private Environment env; diff --git a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java index 716a20e..d048f6a 100644 --- a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java +++ b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java @@ -1,6 +1,7 @@ package org.openeo.spring; import org.openapitools.jackson.nullable.JsonNullableModule; +import org.openeo.spring.security.CorsConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.ExitCodeGenerator; @@ -10,6 +11,7 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -51,7 +53,7 @@ public int getExitCode() { } @Bean - public FilterRegistrationBean apifilter() + public FilterRegistrationBean apiFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ApiFilter()); @@ -59,7 +61,16 @@ public FilterRegistrationBean apifilter() registrationBean.setOrder(1); return registrationBean; } - + + @Bean + public FilterRegistrationBean corsFilterRegistration() + { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(CorsConfig.corsFilter()); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(2); + return registrationBean; + } @Bean public WebMvcConfigurer webConfigurer() { diff --git a/src/main/java/org/openeo/spring/api/ApiUtil.java b/src/main/java/org/openeo/spring/api/ApiUtil.java index 377968d..d69e083 100644 --- a/src/main/java/org/openeo/spring/api/ApiUtil.java +++ b/src/main/java/org/openeo/spring/api/ApiUtil.java @@ -4,9 +4,18 @@ import javax.servlet.http.HttpServletResponse; +import org.openeo.spring.model.Error; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.context.request.NativeWebRequest; +/** + * Shared utilities for API controllers. + */ public class ApiUtil { + public static void setExampleResponse(NativeWebRequest req, String contentType, String example) { try { HttpServletResponse res = req.getNativeResponse(HttpServletResponse.class); @@ -17,4 +26,26 @@ public static void setExampleResponse(NativeWebRequest req, String contentType, throw new RuntimeException(e); } } + + /** + * Crafts an error response. + * @see Error + */ + public static ResponseEntity errorResponse(HttpStatus code, String message) + throws HttpServerErrorException { + if (!code.isError()) { + throw new HttpServerErrorException( + HttpStatus.INTERNAL_SERVER_ERROR, + String.format("%d is not an error code.", code.value())); + } + + Error error = new Error(); + error.setCode(String.valueOf(code.value())); + error.setMessage(message); + + return ResponseEntity + .status(HttpStatus.NOT_IMPLEMENTED) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .body(error); + } } diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index 201f69c..d67382f 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -66,10 +66,8 @@ public ResponseEntity authenticateOidc() { resp = ResponseEntity.ok(providers); } else { log.debug("OIDC authentication is disabled."); - Error error = new Error(); - error.setCode("501"); - error.setMessage("OIDC authentication is not enabled."); - resp = ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(error); + resp = ApiUtil.errorResponse(HttpStatus.NOT_IMPLEMENTED, + "OIDC authentication is not enabled."); } } catch (IOException e) { log.error("The list of oidc providers is currently not available! " + e.getMessage()); @@ -78,10 +76,8 @@ public ResponseEntity authenticateOidc() { builder.append(element.toString() + "\n"); } log.error(builder.toString()); - Error error = new Error(); - error.setCode("500"); - error.setMessage("The list of oidc providers is currently not available!"); - resp = ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + resp = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "The list of oidc providers is currently not available."); } return resp; @@ -105,20 +101,12 @@ public ResponseEntity authenticateOidc() { .accessToken(token)); } else { - Error error = new Error(); - error.setCode("401"); - error.setMessage("Basic Authentication header required."); - resp = ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body(error); + resp = ApiUtil.errorResponse(HttpStatus.UNAUTHORIZED, + "Basic Authentication header required."); } } else { - Error error = new Error(); - error.setCode("501"); - error.setMessage("Basic authentication mechanism not supported by the server."); - resp = ResponseEntity - .status(HttpStatus.NOT_IMPLEMENTED) - .body(error); + resp = ApiUtil.errorResponse(HttpStatus.NOT_IMPLEMENTED, + "Basic authentication mechanism not supported by the server."); } return resp; diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index bc8c9ce..dcb3186 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -287,27 +287,21 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning } } catch (URISyntaxException e) { - Error error = new Error(); - error.setCode("500"); - error.setMessage("The submitted job " + job.toString() + " has an invalid URI"); - log.error("The submitted job {} has an invalid URI.", job); + response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("The submitted job \"%s\" has an invalid URI.", job)); + log.error(response.getBody()); ThreadContext.clearMap(); - response = new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); } } else { - Error error = new Error(); - error.setCode("500"); - error.setMessage("The submitted job " + job.toString() + " was not saved persistently"); - log.error("The submitted job {} was not saved persistently.", job); + response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("The submitted job \"%s\" was not saved persistently.", job)); + log.error(response.getBody()); ThreadContext.clearMap(); - response = new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); } } else { - Error error = new Error(); - error.setCode("401"); - error.setMessage("You are not authorized to create this job"); - log.error("You are not authorized to create this job"); - return new ResponseEntity(error, HttpStatus.UNAUTHORIZED); + response = ApiUtil.errorResponse(HttpStatus.UNAUTHORIZED, + "You are not authorized to create this job"); + log.error(response.getBody()); } return response; @@ -423,12 +417,11 @@ public ResponseEntity debugJob( errorMessage.append(line); errorMessage.append(System.getProperty("line.separator")); } - log.error("An error when accessing logs from elastic stac: " + errorMessage.toString()); - Error error = new Error(); - error.setCode("500"); - error.setMessage("An error when accessing logs from elastic stac: " + errorMessage.toString()); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); - }else { + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("An error when accessing logs from elastic stac: %s", errorMessage)); + log.error(response.getBody()); + return response; + } else { ByteArrayOutputStream result = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; for (int length; (length = conn.getInputStream().read(buffer)) != -1; ) { @@ -452,17 +445,16 @@ public ResponseEntity debugJob( logEntries.addLogsItem(logEntry); }); } - }catch(Exception e) { + } catch(Exception e) { log.error("An error when accessing logs from elastic stac: " + e.getMessage()); StringBuilder builder = new StringBuilder(e.getMessage()); for (StackTraceElement element : e.getStackTrace()) { builder.append(element.toString() + "\n"); } log.error(builder.toString()); - Error error = new Error(); - error.setCode("500"); - error.setMessage("An error when accessing logs from elastic stac: " + builder.toString()); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + builder.toString()); + return response; } //TODO implement logEntry and add to logEntries list and return result with pagination links return new ResponseEntity(logEntries, HttpStatus.OK); @@ -509,33 +501,31 @@ public ResponseEntity deleteJob( if (job != null) { BatchJobResult jobResult = resultDAO.findOne(UUID.fromString(jobId)); if(jobResult != null) { - log.debug("The job result " + jobId + " was detected."); + log.debug("The job result {} was detected.", jobId); File jobResults = new File(tmpDir + jobId); if(jobResults.exists()) { - log.debug("Directory of job results has been found: " + jobResults.getAbsolutePath()); + log.debug("Directory of job results has been found: {}", jobResults.getAbsolutePath()); for(File file: jobResults.listFiles()) { - log.debug("The following result will be deleted: " + file.getName()); + log.debug("The following result will be deleted: {}", file.getName()); file.delete(); } jobResults.delete(); log.debug("All persistent files have been successfully deleted for job with id: " + jobId); } resultDAO.delete(jobResult); - log.debug("The job result " + jobId + " was successfully deleted."); + log.debug("The job result {} was successfully deleted.", jobId); } jobDAO.delete(job); - log.debug("The job " + jobId + " was successfully deleted."); + log.debug("The job {} was successfully deleted.", jobId); authzService.deleteProtectedResource(job); - log.debug("The job " + jobId + " was successfully deleted from Keycloak."); + log.debug("The job {} was successfully deleted from Keycloak.", jobId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } else { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " could not be found."); - log.error("The requested job " + jobId + " could not be found."); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s could not be found.", jobId)); + log.error(response.getBody()); + return response; } - } /** @@ -581,11 +571,11 @@ public ResponseEntity describeJob( ThreadContext.clearMap(); return new ResponseEntity(job, HttpStatus.OK); } else { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " could not be found."); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s could not be found.", jobId)); + log.error(response.getBody()); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + return response; } } @@ -791,12 +781,10 @@ public ResponseEntity listResults( log.trace(result.toString()); return new ResponseEntity(result, HttpStatus.OK); } else { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " could not be found."); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s could not be found.", jobId)); + return response; } - } @Operation(summary = "Download data for given file", operationId = "downloadAsset", description = "Download asset as a result from a successfully executed process graph.", tags = { @@ -831,11 +819,10 @@ public ResponseEntity downloadResult( builder.append(element.toString() + "\n"); } log.error(builder.toString()); - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested file " + fileName + " could not be found."); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested file %s could not be found.", fileName)); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + return response; } catch (IOException e) { // TODO Auto-generated catch block log.error("IOEXception error"); @@ -844,11 +831,10 @@ public ResponseEntity downloadResult( builder.append(element.toString() + "\n"); } log.error(builder.toString()); - Error error = new Error(); - error.setCode("500"); - error.setMessage("IOEXception error " + builder.toString()); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("IOException error %s", builder)); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + return response; } } @@ -901,28 +887,25 @@ public ResponseEntity startJob( Job job = jobDAO.findOne(UUID.fromString(jobId)); if (job != null) { if (job.getStatus() == JobStates.FINISHED) { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " has been finished and can't be restarted. Please create a new job."); - log.error(error.getMessage()); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s has been finished and cannot be restarted. Please create a new job.", jobId)); + log.error(response.getBody()); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + return response; } if (job.getStatus() == JobStates.QUEUED) { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " is queued and can't be restarted before finishing."); - log.error(error.getMessage()); - ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s is queued and cannot be restarted before finishing.", jobId)); + log.error(response.getBody()); + ThreadContext.clearMap(); + return response; } if (job.getStatus() == JobStates.RUNNING) { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " is running and can't be restarted before finishing."); - log.error(error.getMessage()); - ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s is running and cannot be restarted before finishing.", jobId)); + log.error(response.getBody()); + ThreadContext.clearMap(); + return response; } job.setStatus(JobStates.QUEUED); job.setUpdated(OffsetDateTime.now()); @@ -931,14 +914,12 @@ public ResponseEntity startJob( ThreadContext.clearMap(); return new ResponseEntity(HttpStatus.ACCEPTED); } else { - Error error = new Error(); - error.setCode("400"); - error.setMessage("The requested job " + jobId + " could not be found."); - log.error("The requested job " + jobId + " could not be found."); - ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.BAD_REQUEST); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + String.format("The requested job %s could not be found.", jobId)); + log.error(response.getBody()); + ThreadContext.clearMap(); + return response; } - } /** @@ -989,10 +970,10 @@ public ResponseEntity stopJob( Job job = jobDAO.findOne(UUID.fromString(jobId)); if (job != null) { if(job.getEngine()==EngineTypes.WCPS){ - Error error = new Error(); - error.setMessage("The requested WCPS job " + jobId + " cannot be stopped, not implemented."); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.NOT_IMPLEMENTED, + String.format("The requested WCPS job %s cannot be stopped, not implemented.", jobId)); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.NOT_IMPLEMENTED); + return response; } else if(job.getEngine()==EngineTypes.ODC_DASK) { if (job.getStatus()==JobStates.RUNNING) { @@ -1066,11 +1047,10 @@ public ResponseEntity updateJob( Job job = jobDAO.findOne(UUID.fromString(jobId)); if (job != null) { if (job.getStatus()==JobStates.QUEUED || job.getStatus()==JobStates.RUNNING) { - Error error = new Error(); - error.setCode("400"); - error.setMessage("JobLocked: The requested job " + jobId + " is queued or running, not possible to update it now."); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.FORBIDDEN, + String.format("JobLocked: The requested job %s is queued or running, not possible to update it now.", jobId)); ThreadContext.clearMap(); - return new ResponseEntity(error, HttpStatus.FORBIDDEN); + return response; } if(updateBatchJobRequest.getEngine() != null) { job.setEngine(updateBatchJobRequest.getEngine()); diff --git a/src/main/java/org/openeo/spring/api/MeApiController.java b/src/main/java/org/openeo/spring/api/MeApiController.java index 4a35f8c..9709c0f 100644 --- a/src/main/java/org/openeo/spring/api/MeApiController.java +++ b/src/main/java/org/openeo/spring/api/MeApiController.java @@ -70,7 +70,8 @@ public Optional getRequest() { * [Error Handling](#section/API-Principles/Error-Handling) in the API * in general. * [Common Error Codes](errors.json) (status code 500) */ - @Operation(summary = "Information about the authenticated user", operationId = "describeAccount", description = "This endpoint always returns the user id and MAY return the disk quota available to the user. It MAY also return links related to user management and the user profile, e.g. where payments are handled or the user profile could be edited. For back-ends that involve accounting, this service MAY also return the currently available money or credits in the currency the back-end is working with. This endpoint MAY be extended to fulfil the specification of the [OpenID Connect UserInfo Endpoint](http://openid.net/specs/openid-connect-core-1_0.html#UserInfo).", security = { + @Override + @Operation(summary = "Information about the authenticated user", operationId = "describeAccount", description = "This endpoint always returns the user id and MAY return the disk quota available to the user. It MAY also return links related to user management and the user profile, e.g. where payments are handled or the user profile could be edited. For back-ends that involve accounting, this service MAY also return the currently available money or credits in the currency the back-end is working with. This endpoint MAY be extended to fulfil the specification of the [OpenID Connect UserInfo Endpoint](http://openid.net/specs/openid-connect-core-1_0.html#UserInfo).", security = { @SecurityRequirement(name = "bearer-key") }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Information about the logged in user."), @ApiResponse(responseCode = "400", description = "The request can't be fulfilled due to an error on client-side, i.e. the request is invalid. The client should not repeat the request without modifications. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). This request MUST respond with HTTP status codes 401 if authorization is required or 403 if the authorization failed or access is forbidden in general to the authenticated user. HTTP status code 404 should be used if the value of a path parameter is invalid. See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)"), @@ -85,11 +86,10 @@ public ResponseEntity describeAccount(Principal principal) { log.debug("registered user id: " + principal.getName()); userData.setName(accessToken.getName()); log.debug("registered user name: " + accessToken.getName()); - }else { - Error error = new Error(); - error.setCode("500"); - error.setMessage("Security Principal is null, verification not possible!"); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + } else { + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "Security Principal is null, verification not possible!"); + return response; } return new ResponseEntity(userData, HttpStatus.OK); diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index 7cfe9cf..eaa2e89 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -164,11 +164,10 @@ public ResponseEntity computeResult(@Parameter(description = "", required = t resultEngine = checkGraphValidityAndEngine(processGraphJSON); job.setEngine(resultEngine); // it might not have been defined at creation time } catch (Exception e) { - Error error = new Error(); - error.setCode("500"); - error.setMessage(e.getMessage()); - log.error(error.getMessage()); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage()); + log.error(response.getBody()); + return response; } if (resultEngine == EngineTypes.ODC_DASK) { JSONObject process = new JSONObject(); diff --git a/src/main/java/org/openeo/spring/api/WellKnownApiController.java b/src/main/java/org/openeo/spring/api/WellKnownApiController.java index 0e7a0d7..040e451 100644 --- a/src/main/java/org/openeo/spring/api/WellKnownApiController.java +++ b/src/main/java/org/openeo/spring/api/WellKnownApiController.java @@ -39,7 +39,8 @@ public Optional getRequest() { return Optional.ofNullable(request); } - @Operation(summary = "Supported openEO versions", operationId = "connect", description = "Well-Known URI (see [RFC 5785](https://tools.ietf.org/html/rfc5785)) for openEO, listing all implemented openEO versions supported by the service provider. This allows a client to easily identify the most recent openEO implementation it supports. By default, a client SHOULD connect to the most recent production-ready version it supports. If not available, the most recent supported version of *all* versions SHOULD be connected to. Clients MAY let users choose to connect to versions that are not production-ready or outdated. The most recent version is determined by comparing the version numbers according to rules from [Semantic Versioning](https://semver.org/), especially [§11](https://semver.org/#spec-item-11). Any pair of API versions in this list MUST NOT be equal according to Semantic Versioning. The Well-Known URI is the entry point for clients and users, so make sure it is permanent and easy to use and remember. Clients MUST NOT require the well-known path (`./well-known/openeo`) in the URL that is specified by a user to connect to the back-end. A client MUST request `https://example.com/.well-known/openeo` if a user tries to connect to `https://example.com`. If the request to the well-known URI fails, the client SHOULD try to request the capabilities at `/` from `https://example.com`. **This URI MUST NOT be versioned as the other endpoints.** If your API is available at `https://example.com/api/v1.0`, the Well-Known URI SHOULD be located at `https://example.com/.well-known/openeo` and the URI users connect to SHOULD be `https://example.com`. Clients MAY get additional information (e.g. title or description) about a back-end from the most recent version that has the `production` flag set to `true`.", tags = { + @Override + @Operation(summary = "Supported openEO versions", operationId = "connect", description = "Well-Known URI (see [RFC 5785](https://tools.ietf.org/html/rfc5785)) for openEO, listing all implemented openEO versions supported by the service provider. This allows a client to easily identify the most recent openEO implementation it supports. By default, a client SHOULD connect to the most recent production-ready version it supports. If not available, the most recent supported version of *all* versions SHOULD be connected to. Clients MAY let users choose to connect to versions that are not production-ready or outdated. The most recent version is determined by comparing the version numbers according to rules from [Semantic Versioning](https://semver.org/), especially [§11](https://semver.org/#spec-item-11). Any pair of API versions in this list MUST NOT be equal according to Semantic Versioning. The Well-Known URI is the entry point for clients and users, so make sure it is permanent and easy to use and remember. Clients MUST NOT require the well-known path (`./well-known/openeo`) in the URL that is specified by a user to connect to the back-end. A client MUST request `https://example.com/.well-known/openeo` if a user tries to connect to `https://example.com`. If the request to the well-known URI fails, the client SHOULD try to request the capabilities at `/` from `https://example.com`. **This URI MUST NOT be versioned as the other endpoints.** If your API is available at `https://example.com/api/v1.0`, the Well-Known URI SHOULD be located at `https://example.com/.well-known/openeo` and the URI users connect to SHOULD be `https://example.com`. Clients MAY get additional information (e.g. title or description) about a back-end from the most recent version that has the `production` flag set to `true`.", tags = { "Capabilities", }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "List of all available API instances, each with URL and the implemented openEO API version."), @@ -55,10 +56,9 @@ public ResponseEntity connect() { try { apiInstance.setUrl(new URI(openEOPublicEndpoint)); } catch (URISyntaxException e) { - Error error = new Error(); - error.setCode("500"); - error.setMessage("The api endpoint uri was not correctly set: " + e.getMessage()); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("The api endpoint uri was not correctly set: %s", e.getMessage())); + return response; } wellKnownDiscovery.addVersionsItem(apiInstance); diff --git a/src/main/java/org/openeo/spring/components/ExceptionTranslator.java b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java index a675648..4a38cf5 100644 --- a/src/main/java/org/openeo/spring/components/ExceptionTranslator.java +++ b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java @@ -2,6 +2,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.openeo.spring.api.ApiUtil; import org.openeo.spring.model.Error; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -36,13 +37,8 @@ public class ExceptionTranslator { public ResponseEntity processBearerTokenValidationException(JwtException e) { LOGGER.error("JWT token exception caught: ", e); - Error error = new Error(); - error.setCode("403"); - error.setMessage(e.getMessage()); - - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(error); + ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.FORBIDDEN, e.getMessage()); + return resp; } /** @@ -55,13 +51,8 @@ public ResponseEntity processBearerTokenValidationException(JwtException public ResponseEntity processAuthenticationException(AuthenticationException e) { LOGGER.error("Authentication exception caught: ", e); - Error error = new Error(); - error.setCode("403"); - error.setMessage(e.getMessage()); - - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(error); + ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.FORBIDDEN, e.getMessage()); + return resp; } /** @@ -72,12 +63,7 @@ public ResponseEntity processAuthenticationException(AuthenticationExcept public ResponseEntity processAllException(RuntimeException e) { LOGGER.error("Runtime exception caught: ", e); - Error error = new Error(); - error.setCode("500"); - error.setMessage(e.getMessage()); - - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(error); + ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + return resp; } } diff --git a/src/main/java/org/openeo/spring/components/JobScheduler.java b/src/main/java/org/openeo/spring/components/JobScheduler.java index 09cb7f2..01c7e70 100644 --- a/src/main/java/org/openeo/spring/components/JobScheduler.java +++ b/src/main/java/org/openeo/spring/components/JobScheduler.java @@ -12,9 +12,6 @@ import java.io.InputStreamReader; import java.io.OutputStream; import java.io.UnsupportedEncodingException; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.math.BigDecimal; import java.net.ConnectException; import java.net.HttpURLConnection; @@ -23,16 +20,17 @@ import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.OffsetDateTime; import java.util.ArrayList; -import java.util.Date; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.UUID; -import java.util.concurrent.TimeUnit; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -40,7 +38,6 @@ import org.json.JSONException; import org.json.JSONObject; import org.openeo.spring.api.DefaultApiController; -import org.openeo.spring.api.ResultApiController; import org.openeo.spring.dao.BatchJobResultDAO; import org.openeo.spring.dao.JobDAO; import org.openeo.spring.model.Asset; diff --git a/src/main/java/org/openeo/spring/security/CorsConfig.java b/src/main/java/org/openeo/spring/security/CorsConfig.java new file mode 100644 index 0000000..ae8f98a --- /dev/null +++ b/src/main/java/org/openeo/spring/security/CorsConfig.java @@ -0,0 +1,102 @@ +package org.openeo.spring.security; + +import java.io.IOException; + +import org.openeo.spring.OpenAPI2SpringBoot; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.cors.DefaultCorsProcessor; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * Centralized CORS (Cross-Origin Resource Sharing) configuration. + * + * A CORS filter is manually registered in all security chains + * in the {@link OpenAPI2SpringBoot} class. + * The {@link #corsFilter()} method exposes it also as a bean + * in the application context. + */ +@Configuration +public class CorsConfig { + + /** Factory method. */ + @Bean // -> to share configuration in case of Bean-based CORS (HttpScurity.cors()) + public static CorsFilter corsFilter() { + CorsFilter corsFilter = new CorsFilter(corsConfigurationSource()); + corsFilter.setCorsProcessor(new CorsProcessor()); + return corsFilter; + } + + @Bean + static CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.addAllowedOrigin("*"); +// configuration.setAllowCredentials(true); > incompatible with "*" above + + configuration.addAllowedMethod(HttpMethod.OPTIONS); + configuration.addAllowedMethod(HttpMethod.GET); + configuration.addAllowedMethod(HttpMethod.PATCH); + configuration.addAllowedMethod(HttpMethod.POST); + configuration.addAllowedMethod(HttpMethod.PUT); + configuration.addAllowedMethod(HttpMethod.DELETE); + + configuration.addAllowedHeader(HttpHeaders.ACCEPT); + configuration.addAllowedHeader(HttpHeaders.AUTHORIZATION); + configuration.addAllowedHeader(HttpHeaders.CONTENT_TYPE); + configuration.addAllowedHeader(HttpHeaders.ORIGIN); + + configuration.addExposedHeader("Location, OpenEO-Identifier, OpenEO-Costs, Link"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + /** + * The CORS processor, where the HTTP response headers are crafted. + */ + static class CorsProcessor extends DefaultCorsProcessor { + + @Override + protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response, + CorsConfiguration config, boolean preFlightRequest) throws IOException { + + boolean bOut = super.handleInternal(request, response, config, preFlightRequest); + + // set 204 on empty preflight responses + if (CorsProcessor.isPreFlightRequest(request)) { + response.setStatusCode(HttpStatus.NO_CONTENT); + } + + return bOut; + } + + /** Detects a CORS pre-flight request. */ + static boolean isPreFlightRequest(ServerHttpRequest request) { + if (null == request) { + return false; + } + + if (request instanceof ServletServerHttpRequest) { + ServletServerHttpRequest ssReq = (ServletServerHttpRequest) request; + return CorsUtils.isPreFlightRequest(ssReq.getServletRequest()); + } + + return (HttpMethod.OPTIONS.equals(request.getMethod()) && + request.getHeaders().getOrigin() != null && + request.getHeaders().getAccessControlRequestMethod() != null); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 6e7f374..bcee9f1 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; @@ -30,7 +29,6 @@ @EnableGlobalMethodSecurity( securedEnabled = true, prePostEnabled = true) // -> @PreAuthorize annotations on controller methods -@Order(Ordered.LOWEST_PRECEDENCE) public class GlobalSecurityConfig implements EnvironmentPostProcessor { @Value("${spring.security.enable-basic}") @@ -70,16 +68,23 @@ public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplicatio } } + /** - * Recommended authentication mechanism: OIDC/OAuth2 via Keycloak. + * Global CORS configuration. */ @Order(1) + public static class GlobalCorsConfig extends CorsConfig {} + + /** + * Recommended authentication mechanism: OIDC/OAuth2 via Keycloak. + */ + @Order(2) public static class RecommendedSecurityConfig extends KeycloakSecurityConfig {} /** * Optional "basic" (user/password) authentication mechanism. */ - @Order(2) + @Order(3) public static class OptionalSecurityConfig extends BasicSecurityConfig {} } diff --git a/src/test/java/org/openeo/spring/security/TestCorsConf.java b/src/test/java/org/openeo/spring/security/TestCorsConf.java new file mode 100644 index 0000000..a530f44 --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestCorsConf.java @@ -0,0 +1,68 @@ +package org.openeo.spring.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +@RunWith(SpringRunner.class) +@WebMvcTest() +@ActiveProfiles("test") +public class TestCorsConf { + + @Autowired + private MockMvc mvc; + + @ParameterizedTest + @MethodSource("testAPIResources") + public void preflightRequest_shouldReturn204(String resource) throws Exception { + mvc.perform(options(resource) + .header(HttpHeaders.ORIGIN, "https://kingink.org:666") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization, Content-Type") + ).andExpectAll( + status().is(204), +// header().exists(HttpHeaders.CONTENT_TYPE) TODO (SHOULD contain, anyway) + header().exists(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS), + header().exists(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS), + header().exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN), + header().exists(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS)); + } + + @ParameterizedTest + @MethodSource("testAPIResources") + @WithMockUser(value = "robin") + public void request_shouldReturnCorsHeader(String resource) throws Exception { + mvc.perform(get(resource) + .header(HttpHeaders.ORIGIN, "https://deadjoe.org") + ).andExpectAll( + header().exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN), + header().exists(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS), + header().exists(HttpHeaders.CONTENT_TYPE)); + } + + /** Provides inputs for parameterized tests. */ + private static Stream testAPIResources() { + return Stream.of( + Arguments.of("/credentials/basic"), + Arguments.of("/"), + Arguments.of("/jobs"), + Arguments.of("/collections"), + Arguments.of("/.well-known/openeo"), + Arguments.of("/")); + } +} From 83cec66fbfeea1854771921d737dd6a749d8ce64 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Thu, 27 Jul 2023 19:21:57 +0200 Subject: [PATCH 10/27] Inhibit both BA and OIDC authentication mechanisms to be enabled. --- .../org/openeo/spring/OpenAPI2SpringBoot.java | 8 +-- .../java/org/openeo/spring/api/ApiUtil.java | 12 +++- .../spring/api/CredentialsApiController.java | 40 ++++++----- .../spring/api/CustomErrorController.java | 23 +++++++ .../bearer/JWTAuthenticationFilter.java | 18 ++++- .../spring/bearer/JWTAuthorizationFilter.java | 14 +++- .../spring/components/BAuthEntrypoint.java | 49 +++++++++++++ .../spring/components/ErrorAttributes.java | 49 +++++++++++++ .../components/ExceptionTranslator.java | 23 ++----- .../FilterChainExceptionHandler.java | 14 +++- .../spring/json/ProcessSerializerFull.java | 16 ++--- .../spring/loaders/WCSCollectionsLoader.java | 2 +- .../spring/security/BasicSecurityConfig.java | 24 +++++-- .../spring/security/GlobalSecurityConfig.java | 22 +++--- .../security/KeycloakSecurityConfig.java | 20 +++--- .../security/TestBasicAuthentication.java | 68 +++++++++++++------ .../TestBasicAuthentication_OIDCDisabled.java | 9 ++- .../TestBasicAuthentication_OIDCEnabled.java | 2 + .../openeo/spring/security/TestCorsConf.java | 2 +- .../security/TestOIDCAuthentication.java | 12 ++-- 20 files changed, 321 insertions(+), 106 deletions(-) create mode 100644 src/main/java/org/openeo/spring/api/CustomErrorController.java create mode 100644 src/main/java/org/openeo/spring/components/BAuthEntrypoint.java create mode 100644 src/main/java/org/openeo/spring/components/ErrorAttributes.java diff --git a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java index d048f6a..79deb84 100644 --- a/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java +++ b/src/main/java/org/openeo/spring/OpenAPI2SpringBoot.java @@ -53,8 +53,7 @@ public int getExitCode() { } @Bean - public FilterRegistrationBean apiFilter() - { + public FilterRegistrationBean openeoApiFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ApiFilter()); registrationBean.addUrlPatterns("/*"); @@ -63,15 +62,14 @@ public FilterRegistrationBean apiFilter() } @Bean - public FilterRegistrationBean corsFilterRegistration() - { + public FilterRegistrationBean corsFilterRegistration() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(CorsConfig.corsFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(2); return registrationBean; } - + @Bean public WebMvcConfigurer webConfigurer() { return new WebMvcConfigurer() {}; diff --git a/src/main/java/org/openeo/spring/api/ApiUtil.java b/src/main/java/org/openeo/spring/api/ApiUtil.java index d69e083..17e05da 100644 --- a/src/main/java/org/openeo/spring/api/ApiUtil.java +++ b/src/main/java/org/openeo/spring/api/ApiUtil.java @@ -20,7 +20,7 @@ public static void setExampleResponse(NativeWebRequest req, String contentType, try { HttpServletResponse res = req.getNativeResponse(HttpServletResponse.class); res.setCharacterEncoding("UTF-8"); - res.addHeader("Content-Type", contentType); + res.addHeader(HttpHeaders.CONTENT_TYPE, contentType); res.getWriter().print(example); } catch (IOException e) { throw new RuntimeException(e); @@ -44,8 +44,16 @@ public static ResponseEntity errorResponse(HttpStatus code, String messag error.setMessage(message); return ResponseEntity - .status(HttpStatus.NOT_IMPLEMENTED) + .status(code) .header(HttpHeaders.CONTENT_TYPE, "application/json") .body(error); } + + /** + * Overload with default message. + * @see HttpStatus#getReasonPhrase() + */ + public static ResponseEntity errorResponse(HttpStatus code) { + return errorResponse(code, code.getReasonPhrase()); + } } diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index d67382f..9186a36 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -3,12 +3,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import java.io.IOException; -import java.security.Principal; import java.util.Optional; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.openeo.spring.model.Error; import org.openeo.spring.model.HTTPBasicAccessToken; import org.openeo.spring.model.OpenIDConnectProviders; import org.springframework.beans.factory.annotation.Autowired; @@ -16,6 +14,10 @@ import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,10 +29,6 @@ @Controller @RequestMapping("${openapi.openEO.base-path:}") public class CredentialsApiController implements CredentialsApi { - - private final NativeWebRequest request; - - private final Logger log = LogManager.getLogger(CredentialsApiController.class); @Value("${org.openeo.oidc.providers.list}") private Resource oidcProvidersFile; @@ -40,6 +38,10 @@ public class CredentialsApiController implements CredentialsApi { @Value("${spring.security.enable-keycloak}") boolean enableKeycloakAuth; + + private final NativeWebRequest request; + + private static final Logger log = LogManager.getLogger(CredentialsApiController.class); @Autowired public CredentialsApiController(NativeWebRequest request) { @@ -90,16 +92,22 @@ public ResponseEntity authenticateOidc() { ResponseEntity resp; if (enableBasicAuth) { - Principal principal = request.getUserPrincipal(); - - if (null != principal) { - String username = principal.getName(); - String token = TokenUtil.getCurrentBAAccessToken(request.getUserPrincipal()); - log.debug("Access token for user {}: {}", username, token); - resp = ResponseEntity - .ok(new HTTPBasicAccessToken() - .accessToken(token)); - +// Principal principal = request.getUserPrincipal(); sometimes null even when auth FIXME + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (null != auth) { + if (auth instanceof BearerTokenAuthenticationToken) { + BearerTokenAuthenticationToken tokenAuth = (BearerTokenAuthenticationToken) auth; + User authUser = (User) auth.getDetails(); + String username = authUser.getUsername(); + String token = tokenAuth.getToken(); + log.debug("Access token for user {}: {}", username, token); + resp = ResponseEntity + .ok(new HTTPBasicAccessToken() + .accessToken(token)); + } else { + resp = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "Invalid authentication object."); + } } else { resp = ApiUtil.errorResponse(HttpStatus.UNAUTHORIZED, "Basic Authentication header required."); diff --git a/src/main/java/org/openeo/spring/api/CustomErrorController.java b/src/main/java/org/openeo/spring/api/CustomErrorController.java new file mode 100644 index 0000000..0460eab --- /dev/null +++ b/src/main/java/org/openeo/spring/api/CustomErrorController.java @@ -0,0 +1,23 @@ +package org.openeo.spring.api; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * Custom /error endpoint handler. + */ +@Controller +@ConditionalOnExpression(value = "false") +public class CustomErrorController implements ErrorController { + + @RequestMapping("/error") + @ResponseBody + String error(HttpServletRequest request) { + return "

Error occurred

"; // TODO + } +} diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java index b3ad762..7b483d9 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java @@ -11,6 +11,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -39,7 +41,17 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { private static String BA_HEADER_PRE = "Basic "; private static final Logger LOGGER = LogManager.getLogger(JWTAuthenticationFilter.class); - + + /** + * Control the filter registration that Spring would otherwise automatically do. + */ + @Bean + public FilterRegistrationBean jwtAuthenticationRegistration(JWTAuthenticationFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -47,7 +59,7 @@ protected void doFilterInternal(HttpServletRequest request, String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (authorizationHeaderIsValid(authorizationHeader)) { + if (authorizationHeaderIsValid(authorizationHeader)) { Authentication authResult = SecurityContextHolder.getContext().getAuthentication(); if (null != authResult) { onSuccessfulAuthentication(request, response, authResult); @@ -55,7 +67,7 @@ protected void doFilterInternal(HttpServletRequest request, LOGGER.debug("Unauthenticated user: NOOP."); } } else { - LOGGER.debug("No \"Authorization\" header found."); + LOGGER.debug("No valid \"Authorization\" header found."); } // do not break the chain! diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index 960cc2f..d6eaa4d 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -12,6 +12,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -34,7 +36,7 @@ public class JWTAuthorizationFilter extends OncePerRequestFilter { @Autowired ITokenService tokenService; - + /** HTTP Bearer scheme id. */ private static String BEARER_HEADER_PRE = "Bearer "; @@ -42,6 +44,16 @@ public class JWTAuthorizationFilter extends OncePerRequestFilter { private static String TOKEN_PREFIX = "basic//"; private static final Logger LOGGER = LogManager.getLogger(JWTAuthorizationFilter.class); + + /** + * Control the filter registration that Spring would otherwise automatically do. + */ + @Bean + public FilterRegistrationBean jwtAuthorizationRegistration(JWTAuthorizationFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) diff --git a/src/main/java/org/openeo/spring/components/BAuthEntrypoint.java b/src/main/java/org/openeo/spring/components/BAuthEntrypoint.java new file mode 100644 index 0000000..2b8d292 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/BAuthEntrypoint.java @@ -0,0 +1,49 @@ +package org.openeo.spring.components; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.openeo.spring.api.ApiUtil; +import org.openeo.spring.model.Error; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +/** + * Override the default behaviour of the Spring Basic Authentication + * entrypoint. + * @see HttpBasicConfigurer#authenticationEntryPoint(org.springframework.security.web.AuthenticationEntryPoint) + */ +@Component +public class BAuthEntrypoint extends BasicAuthenticationEntryPoint { + + /** Label for the "realm" set in {@code WWW-Authenticate} response header. */ + public static final String REALM_LABEL = "openEO"; + + public BAuthEntrypoint() { + super(); + setRealmName(REALM_LABEL); + } + + /** + * Sends a response where authentication is requested. + * Default error attributes are overridden in {@link ErrorAttributes}. + */ + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"" + getRealmName() + "\""); + + ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.UNAUTHORIZED); + request.setAttribute(REALM_LABEL, resp); // [!] + + response.sendError(resp.getStatusCodeValue(), resp.toString()); + } + +} diff --git a/src/main/java/org/openeo/spring/components/ErrorAttributes.java b/src/main/java/org/openeo/spring/components/ErrorAttributes.java new file mode 100644 index 0000000..c5ba406 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/ErrorAttributes.java @@ -0,0 +1,49 @@ +package org.openeo.spring.components; + +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openeo.spring.model.Error; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.WebRequest; + +/** + * Override Spring auto-configured white-label error structure. + */ +@Component +public class ErrorAttributes extends DefaultErrorAttributes { + + private static final Logger LOGGER = LogManager.getLogger(ErrorAttributes.class); + + @Override + public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) { + + Map errorAttributes = super.getErrorAttributes(webRequest, options); + ResponseEntity response = getAttribute(webRequest, BAuthEntrypoint.REALM_LABEL); + + if (null != response) { + LOGGER.debug("Will change default attributes for error: {}", response.getStatusCode()); + + Error error = response.getBody(); + errorAttributes.clear(); + + // TODO extract attributes labels from Error annotations + errorAttributes.put("id", error.getId()); + errorAttributes.put("code", error.getCode()); + errorAttributes.put("message", error.getMessage()); + errorAttributes.put("link", error.getLinks()); + } + + return errorAttributes; + } + + @SuppressWarnings("unchecked") + private T getAttribute(RequestAttributes requestAttributes, String name) { + return (T) requestAttributes.getAttribute(name, RequestAttributes.SCOPE_REQUEST); + } +} diff --git a/src/main/java/org/openeo/spring/components/ExceptionTranslator.java b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java index 4a38cf5..5e4b3a3 100644 --- a/src/main/java/org/openeo/spring/components/ExceptionTranslator.java +++ b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java @@ -4,10 +4,10 @@ import org.apache.logging.log4j.Logger; import org.openeo.spring.api.ApiUtil; import org.openeo.spring.model.Error; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -24,6 +24,7 @@ * @see FilterChainExceptionHandler */ @RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) public class ExceptionTranslator { private static final Logger LOGGER = LogManager.getLogger(ExceptionTranslator.class); @@ -35,33 +36,19 @@ public class ExceptionTranslator { @ExceptionHandler(JwtException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ResponseEntity processBearerTokenValidationException(JwtException e) { - LOGGER.error("JWT token exception caught: ", e); + LOGGER.error("JWT token exception caught: {}", e.getMessage()); ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.FORBIDDEN, e.getMessage()); return resp; } - - /** - * @deprecated All {@link AuthenticationException} thrown are handled by the - * {@link BasicAuthenticationFilter}. - */ - @Deprecated - @ExceptionHandler(AuthenticationException.class) - @ResponseStatus(HttpStatus.FORBIDDEN) - public ResponseEntity processAuthenticationException(AuthenticationException e) { - LOGGER.error("Authentication exception caught: ", e); - ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.FORBIDDEN, e.getMessage()); - return resp; - } - /** * Last resort handler of all not-yet managed runtime error. */ @ExceptionHandler(RuntimeException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ResponseEntity processAllException(RuntimeException e) { - LOGGER.error("Runtime exception caught: ", e); + LOGGER.error("Runtime exception caught: {}", e.getMessage()); ResponseEntity resp = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); return resp; diff --git a/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java index 55000e3..717aff1 100644 --- a/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java +++ b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java @@ -11,6 +11,8 @@ import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.HandlerExceptionResolver; @@ -20,7 +22,7 @@ * * This filter should be placed before other custom security, * authentication or authorization filters so that the new logic - * is safely placed inside a trz-catch block. + * is safely placed inside a try-catch block. */ @Component public class FilterChainExceptionHandler extends OncePerRequestFilter { @@ -30,6 +32,16 @@ public class FilterChainExceptionHandler extends OncePerRequestFilter { @Autowired @Qualifier("handlerExceptionResolver") private HandlerExceptionResolver resolver; + + /** + * Control the filter registration that Spring would otherwise automatically do. + */ + @Bean + public FilterRegistrationBean exceptionFilterRegistration(FilterChainExceptionHandler filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) diff --git a/src/main/java/org/openeo/spring/json/ProcessSerializerFull.java b/src/main/java/org/openeo/spring/json/ProcessSerializerFull.java index 161c4b8..836d9cc 100644 --- a/src/main/java/org/openeo/spring/json/ProcessSerializerFull.java +++ b/src/main/java/org/openeo/spring/json/ProcessSerializerFull.java @@ -1,18 +1,16 @@ package org.openeo.spring.json; import java.io.IOException; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.util.List; import org.json.JSONArray; import org.json.JSONObject; -import java.util.List; -import java.util.ArrayList; import org.openeo.spring.model.Link; import org.openeo.spring.model.Process; -import org.openeo.spring.model.ProcessReturnValue; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; public class ProcessSerializerFull extends StdSerializer{ @@ -43,11 +41,11 @@ public void serialize(Process value, JsonGenerator gen, SerializerProvider provi if(parameters != null) { gen.writeObjectField("parameters", parameters.toList()); } - List categories = (ArrayList) value.getCategories(); + List categories = value.getCategories(); if(categories != null) { gen.writeObjectField("categories", categories.toArray()); } - List links = (ArrayList) value.getLinks(); + List links = value.getLinks(); if(links != null) { gen.writeObjectField("links", links.toArray()); } diff --git a/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java index c27f58d..37bd464 100644 --- a/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java +++ b/src/main/java/org/openeo/spring/loaders/WCSCollectionsLoader.java @@ -1185,7 +1185,7 @@ else if (TEMPORAL_AXIS_LABELS.contains(label)) { // FIXME optimize cloud-cover min/max extraction: - if (!cloudCovArray.isEmpty()) { + if (cloudCovArray.length() > 0) { try { maxCCValue = cloudCovArray.getDouble(0); minCCValue = cloudCovArray.getDouble(0); diff --git a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java index 6b6ab43..64af936 100644 --- a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -7,8 +7,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.openeo.spring.bearer.JWTAuthenticationFilter; import org.openeo.spring.bearer.JWTAuthorizationFilter; +import org.openeo.spring.components.BAuthEntrypoint; import org.openeo.spring.components.FilterChainExceptionHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -49,26 +52,28 @@ public class BasicSecurityConfig { @Autowired FilterChainExceptionHandler filterChainExHandler; + @Autowired + BAuthEntrypoint authEntrypoint; + /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "BASIC_AUTH"; - /** Label for the "realm" set in {@code WWW-Authenticate} response header. */ - public static final String REALM_LABEL = "openEO"; - /** Override default session repository. */ public static SecurityContextRepository REPO; + private static final Logger LOGGER = LogManager.getLogger(BasicSecurityConfig.class); + /** * Requires login input on the basic-auth endpoint. */ @Bean - public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain baLoginFilterChain(HttpSecurity http) throws Exception { http .antMatcher(BASIC_AUTH_API_RESOURCE) .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated()) .httpBasic() - .realmName(REALM_LABEL) // [Authenticate: Basic realm="REALM"] + .authenticationEntryPoint(authEntrypoint) .and() // disable session management (JSESSIONID cookies -> security risks) .sessionManagement() @@ -77,7 +82,10 @@ public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception .and() .addFilterBefore(filterChainExHandler, LogoutFilter.class) .addFilterAfter(jwtAuthenticationFilter, BasicAuthenticationFilter.class); +// - filters non ci sono se KEYCLOAK abilitato? solo quelli REGISTRATI. FIXME // .rememberMe(Customizer.withDefaults()); TODO + + LOGGER.info("Basic authentication security chain set."); return http.build(); } @@ -89,7 +97,7 @@ public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception * configured in {@link #webSecurityCustomizer()}. */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain baSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() @@ -101,6 +109,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .addFilterBefore(filterChainExHandler, LogoutFilter.class) .addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class); + LOGGER.info("Basic authorization security chain set."); + return http.build(); } @@ -108,7 +118,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti * Sets the resources that do not required security rules. */ @Bean - public WebSecurityCustomizer webSecurityCustomizer() { + public WebSecurityCustomizer baWebSecurityCustomizer() { return (web) -> web .ignoring() .antMatchers(NOAUTH_API_RESOURCES) diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index bcee9f1..7d209d9 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -1,5 +1,6 @@ package org.openeo.spring.security; +import org.apache.commons.lang3.NotImplementedException; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; @@ -66,25 +67,28 @@ public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplicatio if (enableKeycloakAuth) { env.addActiveProfile(KeycloakSecurityConfig.PROFILE_ID); } + + if (!enableBasicAuth && !enableKeycloakAuth) { + throw new InternalError("Enable at least one security provider."); + } + + // TODO make both providers working together fine + // @see TestBasicAuthentication_OIDCEnabled class + if (enableBasicAuth && enableKeycloakAuth) { + throw new NotImplementedException("Maximum 1 security agent is provider."); + } } - - /** - * Global CORS configuration. - */ - @Order(1) - public static class GlobalCorsConfig extends CorsConfig {} - /** * Recommended authentication mechanism: OIDC/OAuth2 via Keycloak. */ - @Order(2) + @Order(1) public static class RecommendedSecurityConfig extends KeycloakSecurityConfig {} /** * Optional "basic" (user/password) authentication mechanism. */ - @Order(3) + @Order(2) public static class OptionalSecurityConfig extends BasicSecurityConfig {} } diff --git a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java index 183e549..49fd8e0 100644 --- a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java @@ -4,6 +4,8 @@ import static org.openeo.spring.security.GlobalSecurityConfig.NOAUTH_API_RESOURCES; import static org.openeo.spring.security.GlobalSecurityConfig.OIDC_AUTH_API_RESOURCE; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.openeo.spring.keycloak.KeycloakLogoutHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -18,20 +20,18 @@ import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +@Configuration //@Profile(KeycloakSecurityConfig.PROFILE_ID) -> better use: @ConditionalOnProperty(prefix="spring.security", value="enable-keycloak") -@Configuration public class KeycloakSecurityConfig { /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "KEYCLOAK_AUTH"; + + private static final Logger LOGGER = LogManager.getLogger(KeycloakSecurityConfig.class); @Autowired private KeycloakLogoutHandler keycloakLogoutHandler; - -// KeycloakSecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) { -// this.keycloakLogoutHandler = keycloakLogoutHandler; -// } @Bean protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { @@ -45,7 +45,7 @@ protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { * @throws Exception */ @Bean - public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain kcLoginFilterChain(HttpSecurity http) throws Exception { http .antMatcher(OIDC_AUTH_API_RESOURCE) .oauth2Login() @@ -55,6 +55,8 @@ public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception .logoutSuccessUrl("/"); http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + + LOGGER.info("Keycloak authentication security chain set."); return http.build(); } @@ -70,11 +72,13 @@ public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception * @throws Exception */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain kcSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ); + + LOGGER.info("Keycloak authorization security chain set."); return http.build(); } @@ -83,7 +87,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti * Sets the resources that do not required security rules. */ @Bean - public WebSecurityCustomizer webSecurityCustomizer() { + public WebSecurityCustomizer kcWebSecurityCustomizer() { return (web) -> web .ignoring() .antMatchers(NOAUTH_API_RESOURCES) diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 56a5460..19481d7 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -42,22 +42,22 @@ * @see ActiveProfiles */ @RunWith(SpringRunner.class) -@WebMvcTest(CredentialsApiController.class) +@WebMvcTest //@ActiveProfiles("test") // -> src/test/resources/application-$PROFILE.properties public abstract class TestBasicAuthentication { - @Autowired + @Autowired MockMvc mvc; @Autowired - JWTTokenService tokenService; - + WebApplicationContext context; + @Autowired - WebApplicationContext applicationContext; + JWTTokenService tokenService; @Test @WithMockUser(username = "satan", password = "petrodragonic") - public void get_okBasic_shouldSucceedWith200() throws Exception { + public void get_okBasic_shouldSucceedWith200() throws Exception { MvcResult mvcResult = mvc.perform(get("/credentials/basic") .header(HttpHeaders.AUTHORIZATION, "Basic c2F0YW46cGV0cm9kcmFnb25pYw==") ).andExpectAll( @@ -68,7 +68,7 @@ public void get_okBasic_shouldSucceedWith200() throws Exception { // token is in body jsonPath("$.access_token").exists() ).andReturn(); - + // body token equals header token String response = mvcResult.getResponse().getContentAsString(); String authHeader = mvcResult.getResponse().getHeader(HttpHeaders.AUTHORIZATION); @@ -98,16 +98,26 @@ public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Except public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Exception { mvc.perform(get("/collections") .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") - ).andExpect( - status().is(403)); + ).andExpectAll( + status().is(403) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } @Test public void get_wrongTokenPrefix_shouldReturn403() throws Exception { mvc.perform(get("/collections") .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") - ).andExpect( - status().is(403)); + ).andExpectAll( + status().is(403) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } @Test @@ -121,8 +131,13 @@ public void get_expiredToken_shouldReturn403() throws Exception { mvc.perform(get("/collections") .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) - ).andExpect( - status().is(403)); + ).andExpectAll( + status().is(403) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } @Test @@ -130,7 +145,12 @@ public void get_noAuth_shouldReturnAuthRequired401() throws Exception { mvc.perform(get("/credentials/basic") ).andExpectAll( status().is(401), - header().exists(HttpHeaders.WWW_AUTHENTICATE)); + header().exists(HttpHeaders.WWW_AUTHENTICATE) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } /** @see BasicAuthenticationFilter */ @@ -139,8 +159,13 @@ public void get_noAuth_shouldReturnAuthRequired401() throws Exception { public void get_wrongAuth_shouldReturn401() throws Exception { mvc.perform(get("/credentials/basic") .header(HttpHeaders.AUTHORIZATION, "Basic _InfestTheRatsNest_=") - ).andExpect( - status().is(401)); + ).andExpectAll( + status().is(401) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } /** @@ -153,11 +178,16 @@ public void get_wrongAuth_shouldReturn401() throws Exception { @Test @WithMockUser(username = "charlie") public void disabledBasicAuth_shouldReturn501() throws Exception { - CredentialsApiController controller = applicationContext.getBean(CredentialsApiController.class); + CredentialsApiController controller = context.getBean(CredentialsApiController.class); ReflectionTestUtils.setField(controller, "enableBasicAuth", false); mvc.perform(get("/credentials/basic") - ).andExpect( - status().is(501)); + ).andExpectAll( + status().is(501) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } } diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java index e018b85..87cbc99 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java @@ -15,7 +15,12 @@ public class TestBasicAuthentication_OIDCDisabled extends TestBasicAuthenticatio @Test public void disabledOIDCAuth_shouldReturn501() throws Exception { mvc.perform(get("/credentials/oidc") - ).andExpect( - status().is(501)); + ).andExpectAll( + status().is(501) +// header().exists("id"), FIXME ErrorAttributes not picked in tests +// header().exists("code"), +// header().exists("message"), +// header().exists("links") + ); } } diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java index 68778ec..a25b4e2 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java @@ -1,9 +1,11 @@ package org.openeo.spring.security; +import org.junit.Ignore; import org.springframework.test.context.ActiveProfiles; /** * Run test suite for case where OIDC auth is also enabled. */ @ActiveProfiles("ba+oidc") +@Ignore("coexistence of multiple providers needs more work to do still") public class TestBasicAuthentication_OIDCEnabled extends TestBasicAuthentication {} diff --git a/src/test/java/org/openeo/spring/security/TestCorsConf.java b/src/test/java/org/openeo/spring/security/TestCorsConf.java index a530f44..dd84773 100644 --- a/src/test/java/org/openeo/spring/security/TestCorsConf.java +++ b/src/test/java/org/openeo/spring/security/TestCorsConf.java @@ -46,7 +46,7 @@ public void preflightRequest_shouldReturn204(String resource) throws Exception { @ParameterizedTest @MethodSource("testAPIResources") @WithMockUser(value = "robin") - public void request_shouldReturnCorsHeader(String resource) throws Exception { + public void request_shouldReturnCorsHeaders(String resource) throws Exception { mvc.perform(get(resource) .header(HttpHeaders.ORIGIN, "https://deadjoe.org") ).andExpectAll( diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java index bc57b9b..c7dd088 100644 --- a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -1,11 +1,11 @@ package org.openeo.spring.security; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; import org.junit.runner.RunWith; -import org.openeo.spring.api.CredentialsApiController; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.ActiveProfiles; @@ -13,7 +13,7 @@ import org.springframework.test.web.servlet.MockMvc; @RunWith(SpringRunner.class) -@WebMvcTest(CredentialsApiController.class) +@WebMvcTest() @ActiveProfiles("oidc") public class TestOIDCAuthentication { @@ -23,7 +23,11 @@ public class TestOIDCAuthentication { @Test public void disabledBasicAuth_shouldReturn501() throws Exception { mvc.perform(get("/credentials/basic") - ).andExpect( - status().is(501)); + ).andExpectAll( + status().is(501), + header().exists("id"), + header().exists("code"), + header().exists("message"), + header().exists("links")); } } From a327e317e8576829077e39876873557443ac525b Mon Sep 17 00:00:00 2001 From: pcampalani Date: Fri, 28 Jul 2023 20:04:10 +0200 Subject: [PATCH 11/27] Fix multiple occurrences of JSONObject in classpath. --- pom.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 74a3259..25de610 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,7 @@ 5.2.0 2.17.0 2.8.0 - 20200518 + 20230618 12.0.4 9.0.37 0.2.1 @@ -128,6 +128,12 @@ org.springframework.boot spring-boot-starter-test test + + + com.vaadin.external.google + android-json + +
org.springframework.security From 99d72204948d81220d5a6e6a15e50a9905bed644 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Mon, 31 Jul 2023 18:49:05 +0200 Subject: [PATCH 12/27] Fix GET /me controller + tests. --- .../openeo/spring/api/JobsApiController.java | 12 ++- .../openeo/spring/api/MeApiController.java | 58 ++++++++++--- .../spring/api/ResultApiController.java | 15 ++-- .../java/org/openeo/spring/api/TokenUtil.java | 86 +++++++++++++------ .../openeo/spring/bearer/ITokenService.java | 19 +++- .../spring/bearer/JWTAuthorizationFilter.java | 16 ++-- .../openeo/spring/bearer/JWTTokenService.java | 61 +++++++++++-- .../java/org/openeo/spring/api/TestMe.java | 72 ++++++++++++++++ .../java/org/openeo/spring/api/TestMe_BA.java | 9 ++ .../org/openeo/spring/api/TestMe_OIDC.java | 9 ++ .../security/TestBasicAuthentication.java | 11 ++- 11 files changed, 303 insertions(+), 65 deletions(-) create mode 100644 src/test/java/org/openeo/spring/api/TestMe.java create mode 100644 src/test/java/org/openeo/spring/api/TestMe_BA.java create mode 100644 src/test/java/org/openeo/spring/api/TestMe_OIDC.java diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index dcb3186..733e749 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -36,6 +36,7 @@ import org.json.JSONArray; import org.json.JSONObject; import org.keycloak.representations.AccessToken; +import org.openeo.spring.bearer.ITokenService; import org.openeo.spring.components.JobScheduler; import org.openeo.spring.dao.BatchJobResultDAO; import org.openeo.spring.dao.JobDAO; @@ -117,6 +118,9 @@ public class JobsApiController implements JobsApi { @Autowired private AuthzService authzService; + + @Autowired + private ITokenService tokenService; @Autowired private ResultApiController resultApiController; @@ -192,7 +196,7 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) AccessToken token = null; if(principal != null) { - token = TokenUtil.getKCAccessToken(principal); + token = TokenUtil.getAccessToken(principal, tokenService); job.setOwnerPrincipal(token.getPreferredUsername()); ThreadContext.put("userid", token.getPreferredUsername()); } @@ -693,7 +697,11 @@ public ResponseEntity estimateJob( public ResponseEntity listJobs( @Min(1) @Parameter(description = "This parameter enables pagination for the endpoint and specifies the maximum number of elements that arrays in the top-level object (e.g. jobs or log entries) are allowed to contain. The only exception is the `links` array, which MUST NOT be paginated as otherwise the pagination links may be missing ins responses. If the parameter is not provided or empty, all elements are returned. Pagination is OPTIONAL and back-ends and clients may not support it. Therefore it MUST be implemented in a way that clients not supporting pagination get all resources regardless. Back-ends not supporting pagination will return all resources. If the response is paginated, the links array MUST be used to propagate the links for pagination with pre-defined `rel` types. See the links array schema for supported `rel` types. *Note:* Implementations can use all kind of pagination techniques, depending on what is supported best by their infrastructure. So it doesn't care whether it is page-based, offset-based or uses tokens for pagination. The clients will use whatever is specified in the links with the corresponding `rel` types.") @Valid @RequestParam(value = "limit", required = false) Integer limit, Principal principal) { - AccessToken token = TokenUtil.getKCAccessToken(principal); + AccessToken token = TokenUtil.getAccessToken(principal, tokenService); + if (null == token) { + throw new InternalError("Could not fetch token of user " + principal); + } + String username = token.getPreferredUsername(); BatchJobs batchJobs = new BatchJobs(); diff --git a/src/main/java/org/openeo/spring/api/MeApiController.java b/src/main/java/org/openeo/spring/api/MeApiController.java index 9709c0f..f7b7f0c 100644 --- a/src/main/java/org/openeo/spring/api/MeApiController.java +++ b/src/main/java/org/openeo/spring/api/MeApiController.java @@ -5,12 +5,17 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; +import org.apache.logging.log4j.ThreadContext; import org.keycloak.representations.AccessToken; +import org.openeo.spring.bearer.ITokenService; import org.openeo.spring.model.Error; import org.openeo.spring.model.UserData; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,6 +29,12 @@ @Controller @RequestMapping("${openapi.openEO.base-path:}") public class MeApiController implements MeApi { + + @Autowired + UserDetailsService udService; + + @Autowired + ITokenService tokenService; private final NativeWebRequest request; @@ -78,21 +89,42 @@ public Optional getRequest() { @ApiResponse(responseCode = "500", description = "The request can't be fulfilled due to an error at the back-end. The error is never the client’s fault and therefore it is reasonable for the client to retry the exact same request that triggered this response. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)") }) @GetMapping(value = "/me", produces = { "application/json" }) public ResponseEntity describeAccount(Principal principal) { - UserData userData = new UserData(); - KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal; - AccessToken accessToken = keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken(); - if(principal != null) { - userData.setUserId(principal.getName()); - log.debug("registered user id: " + principal.getName()); - userData.setName(accessToken.getName()); - log.debug("registered user name: " + accessToken.getName()); + + UserData userData = new UserData(); + + if (principal != null) { + String username = principal.getName(); + // KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal; + // AccessToken accessToken = keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken(); + + // username might be a token hash: + AccessToken accessToken = TokenUtil.getAccessToken(principal, tokenService); + if (null != accessToken) { + username = accessToken.getName(); + } + + userData.setName(username); + userData.setUserId(username); + + try { + UserDetails userDetails = udService.loadUserByUsername(username); + userData.setUserId("" + userDetails.hashCode()); // FIXME what to put here + } catch (UsernameNotFoundException ex) { +// ResponseEntity response = ApiUtil.errorResponse( +// HttpStatus.INTERNAL_SERVER_ERROR, +// "No user found by name: " + username); +// return response; + // NOP for now: what is the User Id? + } + + ThreadContext.put("userid", username); // ? + log.debug("registered user {}/{}", userData.getUserId(), userData.getName()); } else { - ResponseEntity response = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + ResponseEntity response = ApiUtil.errorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, "Security Principal is null, verification not possible!"); return response; } - return new ResponseEntity(userData, HttpStatus.OK); - + return new ResponseEntity<>(userData, HttpStatus.OK); } - } diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index eaa2e89..fedb2a9 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -33,6 +33,7 @@ import org.apache.logging.log4j.Logger; import org.json.JSONObject; import org.keycloak.representations.AccessToken; +import org.openeo.spring.bearer.ITokenService; import org.openeo.spring.components.CollectionMap; import org.openeo.spring.components.CollectionsMap; import org.openeo.spring.components.JobScheduler; @@ -79,6 +80,9 @@ public class ResultApiController implements ResultApi { @Autowired private CollectionMap collectionMap; + + @Autowired + private ITokenService tokenService; private final NativeWebRequest request; @@ -98,15 +102,17 @@ public class ResultApiController implements ResultApi { @Value("${org.openeo.wcps.collections.list}") Resource collectionsFileWCPS; + @Value("${org.openeo.odc.collections.list}") Resource collectionsFileODC; @Value("${org.openeo.wcps.processes.list}") Resource processesFileWCPS; + @Value("${org.openeo.odc.processes.list}") Resource processesFileODC; - @org.springframework.beans.factory.annotation.Autowired + @Autowired public ResultApiController(NativeWebRequest request) { this.request = request; } @@ -125,12 +131,12 @@ public Optional getRequest() { @ApiResponse(responseCode = "500", description = "The request can't be fulfilled due to an error at the back-end. The error is never the client’s fault and therefore it is reasonable for the client to retry the exact same request that triggered this response. The response body SHOULD contain a JSON error object. MUST be any HTTP status code specified in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-6.6). See also: * [Error Handling](#section/API-Principles/Error-Handling) in the API in general. * [Common Error Codes](errors.json)") }) @RequestMapping(value = "/result", produces = { "*" }, consumes = { "application/json" }, method = RequestMethod.POST) - public ResponseEntity computeResult(@Parameter(description = "", required = true) @Valid @RequestBody Job job,Principal principal) { + public ResponseEntity computeResult(@Parameter(description = "", required = true) @Valid @RequestBody Job job, Principal principal) { JSONObject processGraphJSON = (JSONObject) job.getProcess().getProcessGraph(); AccessToken token = null; if(principal != null) { - token = TokenUtil.getKCAccessToken(principal); + token = TokenUtil.getAccessToken(principal, tokenService); } Set roles = new HashSet<>(); @@ -143,7 +149,6 @@ public ResponseEntity computeResult(@Parameter(description = "", required = t } } - boolean isEuracUser = roles.contains(EURAC_ROLE); Iterator keys = processGraphJSON.keys(); @@ -152,7 +157,7 @@ public ResponseEntity computeResult(@Parameter(description = "", required = t String key = keys.next(); JSONObject processNode = (JSONObject) processGraphJSON.get(key); String process_id = processNode.get("process_id").toString(); - if (process_id.equals("run_udf") && !isEuracUser) { + if (process_id.equals("run_udf") && !isEuracUser) { // FIXME ? isRunProcessAllow =false; } } diff --git a/src/main/java/org/openeo/spring/api/TokenUtil.java b/src/main/java/org/openeo/spring/api/TokenUtil.java index 079dba8..d1a31d9 100644 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ b/src/main/java/org/openeo/spring/api/TokenUtil.java @@ -5,6 +5,8 @@ import org.keycloak.adapters.OidcKeycloakAccount; import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.keycloak.representations.AccessToken; +import org.openeo.spring.bearer.ITokenService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; @@ -12,26 +14,48 @@ /** * Utilities for fetching user sessions' access tokens. */ -// TODOs unique method: getAccessToken(Principal p) public class TokenUtil { private TokenUtil() {}; + + /** + * Fetches the access/bearer token of a user. + * @param principal the authenticated user + * @param service the service that is used to encode/decode Bearer tokens + * (required for users on Basic Authentication, not required for OIDC) + */ + public static AccessToken getAccessToken(Principal principal, ITokenService service) { + if (null == principal) { + return null; + } + + AccessToken token = null; + + if (principal instanceof KeycloakAuthenticationToken) { + token = TokenUtil.getKCAccessToken((KeycloakAuthenticationToken) principal); + } + + else if (principal instanceof UsernamePasswordAuthenticationToken) { + token = TokenUtil.getBAAccessToken((UsernamePasswordAuthenticationToken) principal, service); + } + + else if (principal instanceof BearerTokenAuthenticationToken) { + token = TokenUtil.getBearerAccessToken((BearerTokenAuthenticationToken) principal, service); + } + + return token; + } /** * Fetches the Keycloak (KC) access token of a given user. * @param principal the user asking for the token * @return the access token; {@code null} when not found (or with {@code null} input) */ - public static AccessToken getKCAccessToken(Principal principal) { + static AccessToken getKCAccessToken(KeycloakAuthenticationToken principal) { if (null == principal) { return null; } - if (!(principal instanceof KeycloakAuthenticationToken)) { - return null; - } - - KeycloakAuthenticationToken kcPrincipal = (KeycloakAuthenticationToken) principal; - OidcKeycloakAccount account = kcPrincipal.getAccount(); + OidcKeycloakAccount account = principal.getAccount(); AccessToken token = account.getKeycloakSecurityContext().getToken(); return token; @@ -40,33 +64,45 @@ public static AccessToken getKCAccessToken(Principal principal) { /** * Fetches the Basic-Authentication (BA) access token of a given user. * @param principal the user asking for the token + * @param tokenService * @return the access token; {@code null} when not found (or with {@code null} input) */ - public static String getCurrentBAAccessToken(Principal principal) { + static AccessToken getBAAccessToken(UsernamePasswordAuthenticationToken principal, + ITokenService tokenService) { if (null == principal) { return null; } + if (null == tokenService) { + throw new InternalError("ITokenService required to fetch Bearer token."); + } - String givenUsername = principal.getName(); - String token = null; - - Authentication auth = SecurityContextHolder.getContext().getAuthentication();//? which one? + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + AccessToken token = null; if (auth instanceof BearerTokenAuthenticationToken) { - BearerTokenAuthenticationToken tokenAuth = (BearerTokenAuthenticationToken) auth; - String loggedUsername = tokenAuth.getName(); - - if (givenUsername.equals(loggedUsername)) { - token = tokenAuth.getToken(); - } else { - // TODO - // throw internal error exception? given user != logged user - // or just return null? - } - } else { - // TODO as above + token = TokenUtil.getBearerAccessToken((BearerTokenAuthenticationToken) auth, tokenService); +// } else { +// throw new InternalError("Expected BearerTokenAuthenticationToken, got: " + auth.getClass()); } return token; } + + /** + * FEtches and decodes a bearer token. + * @param principal + * @param tokenService + * @return + */ + static AccessToken getBearerAccessToken(BearerTokenAuthenticationToken principal, + ITokenService tokenService) { + + String tokenHash = null; + AccessToken token = null; + + tokenHash = principal.getToken(); + token = tokenService.decodeToken(tokenHash); + + return token; + } } diff --git a/src/main/java/org/openeo/spring/bearer/ITokenService.java b/src/main/java/org/openeo/spring/bearer/ITokenService.java index 0d654f9..a751fda 100644 --- a/src/main/java/org/openeo/spring/bearer/ITokenService.java +++ b/src/main/java/org/openeo/spring/bearer/ITokenService.java @@ -2,9 +2,13 @@ import java.time.temporal.TemporalUnit; +import org.keycloak.representations.AccessToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import io.jsonwebtoken.ClaimJwtException; +import io.jsonwebtoken.JwtException; /** * Interface for a (bearer) token service. @@ -32,10 +36,21 @@ public interface ITokenService { String generateToken(UserDetails user, int expUnits, TemporalUnit uom); /** - * Parses a token and its claims. + * Parses a token and its claims, and integrates the user details. * * @param token the received token. + * @return the authentication object this token refers to * @throws ClaimJwtException + * @see {@link SecurityContextHolder#setContext(org.springframework.security.core.context.SecurityContext)} */ - UserDetails parseToken(String token) throws ClaimJwtException; + BearerTokenAuthenticationToken parseToken(String token) throws ClaimJwtException; + + /** + * Decode a token hash to a Java object, assuming we are the issuers. + * + * @param tokenHash + * @return the decoded token. + * @throws JwtException + */ + AccessToken decodeToken(String tokenHash) throws JwtException; } diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index d6eaa4d..81324b0 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -15,10 +15,9 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -69,7 +68,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse BEARER_HEADER_PRE, TOKEN_PREFIX)); } else { try { - UsernamePasswordAuthenticationToken auth = parseToken(authorizationHeader); + BearerTokenAuthenticationToken auth = parseToken(authorizationHeader); if (null != auth) { SecurityContextHolder.getContext().setAuthentication(auth); } else { @@ -82,6 +81,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } else { LOGGER.debug("No \"Bearer\" token found in the request."); + should we request a 401/WWWW-Authorize here? How to recognize is protected or public resource? } // do not break the chain! @@ -102,20 +102,20 @@ private boolean authBearerHeaderIsInvalid(String authorizationHeader) { } /** Deciphers a JWT bearer token attached to a given request header. */ - private UsernamePasswordAuthenticationToken parseToken(String authorizationHeader) + private BearerTokenAuthenticationToken parseToken(String authorizationHeader) throws ClaimJwtException { String prefixedToken = authorizationHeader.replace(BEARER_HEADER_PRE, ""); String jwtToken = prefixedToken.replaceAll(TOKEN_PREFIX, ""); - UserDetails userPrincipal = tokenService.parseToken(jwtToken); - UsernamePasswordAuthenticationToken auth = null; + BearerTokenAuthenticationToken auth = tokenService.parseToken(jwtToken); - if (null != userPrincipal) { + if (null != auth) { List authorities = new ArrayList<>(); // if (userPrincipal.isAdmin()) { // authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // FIXME String // } - auth = new UsernamePasswordAuthenticationToken(userPrincipal, null, authorities); + // TODO +// auth = new UsernamePasswordAuthenticationToken(userPrincipal, null, authorities); } return auth; diff --git a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java index a2984b0..1644cff 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java +++ b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java @@ -9,16 +9,20 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.keycloak.representations.AccessToken; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; @@ -68,6 +72,7 @@ public String generateToken(UserDetails user, int expUnits, TemporalUnit uom) { String compactTokenString = Jwts.builder() // .claim(ID_CLAIM, user.getId()) // .claim(IS_ADMIN_CLAIM, user.isAdmin()) + .setIssuedAt(Date.from(Instant.now())) .setExpiration(expirationDate) .setSubject(user.getUsername()) .signWith(key, SA) @@ -80,10 +85,10 @@ public String generateToken(UserDetails user, int expUnits, TemporalUnit uom) { } @Override - public UserDetails parseToken(String token) throws JwtException { + public BearerTokenAuthenticationToken parseToken(String token) throws JwtException { byte[] secretBytes = jwtSecret.getBytes(); - UserDetails user = null; + BearerTokenAuthenticationToken auth = null; try { Jws jwsClaims = Jwts.parserBuilder() @@ -98,16 +103,60 @@ public UserDetails parseToken(String token) throws JwtException { // Integer userId = jwsClaims.getBody().get(ID_CLAIM, Integer.class); // boolean isAdmin = jwsClaims.getBody().get(IS_ADMIN_CLAIM, Boolean.class); if (null != username) { - user = udService.loadUserByUsername(username); + try { + UserDetails user = udService.loadUserByUsername(username); + auth = new BearerTokenAuthenticationToken(token); + auth.setDetails(user); + auth.setAuthenticated(true); + } catch (UsernameNotFoundException e) { + throw new JwtException("No user found for " + username); + } + } else { + throw new MalformedJwtException("No username in token subject."); } } catch (JwtException ex) { // TODO handle via registered runtime exceptions handler: -// ExpiredJwtException | UnsupportedJwtException | -// MalformedJwtException | SignatureException | IllegalArgumentException ex) { + // ExpiredJwtException | UnsupportedJwtException | + // MalformedJwtException | SignatureException | IllegalArgumentException ex) { LOGGER.error("Illegal or expired token received.", ex); throw ex; } - return user; + return auth; + } + + @Override + public AccessToken decodeToken(String tokenHash) throws JwtException { + + byte[] secretBytes = jwtSecret.getBytes(); + AccessToken token = new AccessToken(); + + try { + Jws jwsClaims = Jwts.parserBuilder() + .setSigningKey(secretBytes) + .requireIssuer(jwtIssuer) + .requireAudience(jwtAudience) + .setAllowedClockSkewSeconds(0) + .build() + .parseClaimsJws(tokenHash); + + Date issued = jwsClaims.getBody().getIssuedAt(); + Date expires = jwsClaims.getBody().getExpiration(); + long expSeconds = (expires.getTime() - issued.getTime()) / 1000; + + token.exp(expSeconds) + .addAudience(jwsClaims.getBody().getAudience()) + .issuer(jwsClaims.getBody().getIssuer()) + .id(tokenHash); + + token.setName(jwsClaims.getBody().getSubject()); + token.setPreferredUsername(token.getName()); + + } catch (JwtException ex) { + LOGGER.error("Illegal or expired token received.", ex); + throw ex; + } + + return token; } // JWT labels diff --git a/src/test/java/org/openeo/spring/api/TestMe.java b/src/test/java/org/openeo/spring/api/TestMe.java new file mode 100644 index 0000000..6ed7543 --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestMe.java @@ -0,0 +1,72 @@ +package org.openeo.spring.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.openeo.spring.bearer.ITokenService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +@RunWith(SpringRunner.class) +@WebMvcTest(MeApiController.class) +public abstract class TestMe { + + @Autowired + private MockMvc mvc; + + @Autowired + ITokenService tokenService; + + @Test + @WithMockUser(username = "john") + @WithUserDetails("john") + // see also : https://stackoverflow.com/a/43920932/1329340 + public void getMe_shouldReturn200() throws Exception { + mvc.perform(get("/me") + ).andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.user_id").exists(), + jsonPath("$.name").value("john")); + } + + @Test + @WithMockUser(username = "jill") + public void getMeWrongToken_shouldReturn403() throws Exception { + // manually generate token + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UserDetails user = (UserDetails) auth.getPrincipal(); + UserDetails fakeUser = new User("fakeJill", user.getPassword(), user.getAuthorities()); + String token = tokenService.generateToken(fakeUser); + + mvc.perform(get("/me") + .header(HttpHeaders.AUTHORIZATION, + String.format("Bearer basic//%s", token)) + ).andExpectAll( + status().is(403), + content().contentType(MediaType.APPLICATION_JSON)); + } + + @Test + public void getMeUnauthenticated_shouldReturn401() throws Exception { + mvc.perform(get("/me") + ).andExpectAll( + status().is(401), + header().exists(HttpHeaders.WWW_AUTHENTICATE)); + } +} diff --git a/src/test/java/org/openeo/spring/api/TestMe_BA.java b/src/test/java/org/openeo/spring/api/TestMe_BA.java new file mode 100644 index 0000000..9a6ac62 --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestMe_BA.java @@ -0,0 +1,9 @@ +package org.openeo.spring.api; + +import org.springframework.test.context.ActiveProfiles; + +/** + * Run test suite for case where Basic Authentication is enabled. + */ +@ActiveProfiles("ba") +public class TestMe_BA extends TestMe {} diff --git a/src/test/java/org/openeo/spring/api/TestMe_OIDC.java b/src/test/java/org/openeo/spring/api/TestMe_OIDC.java new file mode 100644 index 0000000..e711a76 --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestMe_OIDC.java @@ -0,0 +1,9 @@ +package org.openeo.spring.api; + +import org.springframework.test.context.ActiveProfiles; + +/** + * Run test suite for case where OIDC Authentication is enabled. + */ +@ActiveProfiles("oidc") +public class TestMe_OIDC extends TestMe {} diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 19481d7..8b12952 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -65,7 +65,7 @@ public void get_okBasic_shouldSucceedWith200() throws Exception { content().contentType(MediaType.APPLICATION_JSON), // token is in header header().string(HttpHeaders.AUTHORIZATION, startsWith("Bearer ")), - // token is in body + // token is in body jsonPath("$.access_token").exists() ).andReturn(); @@ -99,7 +99,8 @@ public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Except mvc.perform(get("/collections") .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") ).andExpectAll( - status().is(403) + status().is(403), + content().contentType(MediaType.APPLICATION_JSON) // header().exists("id"), FIXME ErrorAttributes not picked in tests // header().exists("code"), // header().exists("message"), @@ -112,7 +113,8 @@ public void get_wrongTokenPrefix_shouldReturn403() throws Exception { mvc.perform(get("/collections") .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") ).andExpectAll( - status().is(403) + status().is(403), + content().contentType(MediaType.APPLICATION_JSON) // header().exists("id"), FIXME ErrorAttributes not picked in tests // header().exists("code"), // header().exists("message"), @@ -132,7 +134,8 @@ public void get_expiredToken_shouldReturn403() throws Exception { .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpectAll( - status().is(403) + status().is(403), + content().contentType(MediaType.APPLICATION_JSON) // header().exists("id"), FIXME ErrorAttributes not picked in tests // header().exists("code"), // header().exists("message"), From 7952ff4343d7bbc6614af5d8cbaa4567e75998bc Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 1 Aug 2023 16:41:32 +0200 Subject: [PATCH 13/27] Add authentication exception handling in all API. --- .../org/openeo/spring/api/JobsApiController.java | 8 +++++--- .../java/org/openeo/spring/api/TokenUtil.java | 15 +++++++++------ .../spring/bearer/JWTAuthorizationFilter.java | 2 +- .../spring/security/BasicSecurityConfig.java | 2 ++ .../spring/security/GlobalSecurityConfig.java | 2 +- .../org/openeo/spring/security/TestCorsConf.java | 4 ++-- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index 733e749..da174d6 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -697,12 +697,14 @@ public ResponseEntity estimateJob( public ResponseEntity listJobs( @Min(1) @Parameter(description = "This parameter enables pagination for the endpoint and specifies the maximum number of elements that arrays in the top-level object (e.g. jobs or log entries) are allowed to contain. The only exception is the `links` array, which MUST NOT be paginated as otherwise the pagination links may be missing ins responses. If the parameter is not provided or empty, all elements are returned. Pagination is OPTIONAL and back-ends and clients may not support it. Therefore it MUST be implemented in a way that clients not supporting pagination get all resources regardless. Back-ends not supporting pagination will return all resources. If the response is paginated, the links array MUST be used to propagate the links for pagination with pre-defined `rel` types. See the links array schema for supported `rel` types. *Note:* Implementations can use all kind of pagination techniques, depending on what is supported best by their infrastructure. So it doesn't care whether it is page-based, offset-based or uses tokens for pagination. The clients will use whatever is specified in the links with the corresponding `rel` types.") @Valid @RequestParam(value = "limit", required = false) Integer limit, Principal principal) { + // tentative + String username = principal.getName(); + AccessToken token = TokenUtil.getAccessToken(principal, tokenService); - if (null == token) { - throw new InternalError("Could not fetch token of user " + principal); + if (null != token) { + username = token.getPreferredUsername(); } - String username = token.getPreferredUsername(); BatchJobs batchJobs = new BatchJobs(); for (Job job : jobDAO.findWithOwner(username)) { diff --git a/src/main/java/org/openeo/spring/api/TokenUtil.java b/src/main/java/org/openeo/spring/api/TokenUtil.java index d1a31d9..072fcdd 100644 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ b/src/main/java/org/openeo/spring/api/TokenUtil.java @@ -76,13 +76,16 @@ static AccessToken getBAAccessToken(UsernamePasswordAuthenticationToken principa throw new InternalError("ITokenService required to fetch Bearer token."); } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); AccessToken token = null; - - if (auth instanceof BearerTokenAuthenticationToken) { - token = TokenUtil.getBearerAccessToken((BearerTokenAuthenticationToken) auth, tokenService); -// } else { -// throw new InternalError("Expected BearerTokenAuthenticationToken, got: " + auth.getClass()); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (null != auth) { + if (auth instanceof BearerTokenAuthenticationToken) { + token = TokenUtil.getBearerAccessToken((BearerTokenAuthenticationToken) auth, tokenService); + } else if (!auth.isAuthenticated()) { + // we reach this point e.g. with mock MVC users in tests + throw new InternalError("Could not fetch token from " + auth.getClass()); + } } return token; diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index 81324b0..dffd9e5 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -81,7 +81,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } else { LOGGER.debug("No \"Bearer\" token found in the request."); - should we request a 401/WWWW-Authorize here? How to recognize is protected or public resource? +// should we request a 401/WWWW-Authorize here? How to recognize is protected or public resource? } // do not break the chain! diff --git a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java index 64af936..05b0e4f 100644 --- a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -102,6 +102,8 @@ public SecurityFilterChain baSecurityFilterChain(HttpSecurity http) throws Excep .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) + .exceptionHandling().authenticationEntryPoint(authEntrypoint) + .and() // disable session management (JSESSIONID cookies -> security risks) .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 7d209d9..1ccd40a 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -75,7 +75,7 @@ public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplicatio // TODO make both providers working together fine // @see TestBasicAuthentication_OIDCEnabled class if (enableBasicAuth && enableKeycloakAuth) { - throw new NotImplementedException("Maximum 1 security agent is provider."); + throw new NotImplementedException("Maximum 1 security agent is allowed."); } } diff --git a/src/test/java/org/openeo/spring/security/TestCorsConf.java b/src/test/java/org/openeo/spring/security/TestCorsConf.java index dd84773..ecbba6e 100644 --- a/src/test/java/org/openeo/spring/security/TestCorsConf.java +++ b/src/test/java/org/openeo/spring/security/TestCorsConf.java @@ -20,8 +20,8 @@ import org.springframework.test.web.servlet.MockMvc; @RunWith(SpringRunner.class) -@WebMvcTest() -@ActiveProfiles("test") +@WebMvcTest +@ActiveProfiles("ba") public class TestCorsConf { @Autowired From e3f4cc10d64018ca85bded903060eb943f0999d8 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 1 Aug 2023 17:55:10 +0200 Subject: [PATCH 14/27] Tests to be run with JUnit5 + ok `mvn test` call. --- pom.xml | 11 +++++++++++ src/test/java/org/openeo/spring/api/TestFavicon.java | 3 --- src/test/java/org/openeo/spring/api/TestMe.java | 3 --- src/test/java/org/openeo/spring/api/TestMe_OIDC.java | 2 ++ .../spring/loaders/TestWCSCollectionsLoader.java | 3 +-- .../spring/security/TestBasicAuthentication.java | 3 --- .../security/TestBasicAuthentication_OIDCEnabled.java | 4 ++-- .../java/org/openeo/spring/security/TestCorsConf.java | 3 --- .../spring/security/TestOIDCAuthentication.java | 7 +++---- 9 files changed, 19 insertions(+), 20 deletions(-) diff --git a/pom.xml b/pom.xml index 25de610..2091249 100644 --- a/pom.xml +++ b/pom.xml @@ -129,10 +129,16 @@ spring-boot-starter-test test + com.vaadin.external.google android-json + + + junit + junit + @@ -140,6 +146,11 @@ spring-security-test test + + org.junit.jupiter + junit-jupiter-api + test + diff --git a/src/test/java/org/openeo/spring/api/TestFavicon.java b/src/test/java/org/openeo/spring/api/TestFavicon.java index 358e1eb..9e941b1 100644 --- a/src/test/java/org/openeo/spring/api/TestFavicon.java +++ b/src/test/java/org/openeo/spring/api/TestFavicon.java @@ -5,14 +5,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -@RunWith(SpringRunner.class) @WebMvcTest(FaviconController.class) public class TestFavicon { diff --git a/src/test/java/org/openeo/spring/api/TestMe.java b/src/test/java/org/openeo/spring/api/TestMe.java index 6ed7543..81b7c61 100644 --- a/src/test/java/org/openeo/spring/api/TestMe.java +++ b/src/test/java/org/openeo/spring/api/TestMe.java @@ -7,7 +7,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.openeo.spring.bearer.ITokenService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -19,10 +18,8 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithUserDetails; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -@RunWith(SpringRunner.class) @WebMvcTest(MeApiController.class) public abstract class TestMe { diff --git a/src/test/java/org/openeo/spring/api/TestMe_OIDC.java b/src/test/java/org/openeo/spring/api/TestMe_OIDC.java index e711a76..257192f 100644 --- a/src/test/java/org/openeo/spring/api/TestMe_OIDC.java +++ b/src/test/java/org/openeo/spring/api/TestMe_OIDC.java @@ -1,9 +1,11 @@ package org.openeo.spring.api; +import org.junit.jupiter.api.Disabled; import org.springframework.test.context.ActiveProfiles; /** * Run test suite for case where OIDC Authentication is enabled. */ @ActiveProfiles("oidc") +@Disabled("OIDC login/sessions not tested yet") public class TestMe_OIDC extends TestMe {} diff --git a/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java b/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java index 2f5fe39..ff85b73 100644 --- a/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java +++ b/src/test/java/org/openeo/spring/loaders/TestWCSCollectionsLoader.java @@ -29,8 +29,6 @@ import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.openeo.spring.api.LinkRelType; -import org.openeo.spring.loaders.JSONMarshaller; -import org.openeo.spring.loaders.WCSCollectionsLoader; import org.openeo.spring.model.Collection; import org.openeo.spring.model.CollectionSpatialExtent; import org.openeo.spring.model.CollectionSummaries; @@ -48,6 +46,7 @@ */ // TODO fetch and compare metadata attributes from XML input to unmarshalled object @DisplayName("WCS collections loader") +@Disabled("Unsatisfied link error to GDAL libraries") class TestWCSCollectionsLoader { static final Logger log = LogManager.getLogger(TestWCSCollectionsLoader.class); diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 8b12952..18b68c7 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -11,7 +11,6 @@ import java.time.temporal.ChronoUnit; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.openeo.spring.api.CredentialsApiController; import org.openeo.spring.bearer.JWTTokenService; import org.springframework.beans.factory.annotation.Autowired; @@ -24,7 +23,6 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -41,7 +39,6 @@ * * @see ActiveProfiles */ -@RunWith(SpringRunner.class) @WebMvcTest //@ActiveProfiles("test") // -> src/test/resources/application-$PROFILE.properties public abstract class TestBasicAuthentication { diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java index a25b4e2..a1286c9 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java @@ -1,11 +1,11 @@ package org.openeo.spring.security; -import org.junit.Ignore; +import org.junit.jupiter.api.Disabled; import org.springframework.test.context.ActiveProfiles; /** * Run test suite for case where OIDC auth is also enabled. */ @ActiveProfiles("ba+oidc") -@Ignore("coexistence of multiple providers needs more work to do still") +@Disabled("coexistence of multiple providers needs more work to do still") public class TestBasicAuthentication_OIDCEnabled extends TestBasicAuthentication {} diff --git a/src/test/java/org/openeo/spring/security/TestCorsConf.java b/src/test/java/org/openeo/spring/security/TestCorsConf.java index ecbba6e..e5c6239 100644 --- a/src/test/java/org/openeo/spring/security/TestCorsConf.java +++ b/src/test/java/org/openeo/spring/security/TestCorsConf.java @@ -10,16 +10,13 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -@RunWith(SpringRunner.class) @WebMvcTest @ActiveProfiles("ba") public class TestCorsConf { diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java index c7dd088..55d691c 100644 --- a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -4,17 +4,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -@RunWith(SpringRunner.class) -@WebMvcTest() +@WebMvcTest @ActiveProfiles("oidc") +@Disabled("OIDC login/sessions not tested yet") public class TestOIDCAuthentication { @Autowired From c5860a67d31cac83eeafe526ffa4bb6083fb9d4b Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 1 Aug 2023 17:55:51 +0200 Subject: [PATCH 15/27] Disable CSRF to allow for Web Editor to work with server. --- .../spring/api/CredentialsApiController.java | 6 +++--- .../openeo/spring/api/JobsApiController.java | 18 +++++++++--------- .../openeo/spring/api/ResultApiController.java | 17 ++++++++++------- .../spring/security/BasicSecurityConfig.java | 2 ++ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index 9186a36..ec1c89c 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -1,7 +1,5 @@ package org.openeo.spring.api; -import static org.junit.jupiter.api.Assertions.assertFalse; - import java.io.IOException; import java.util.Optional; @@ -64,7 +62,9 @@ public ResponseEntity authenticateOidc() { log.debug(oidcProvidersFile.getFilename()); ObjectMapper mapper = new ObjectMapper(); providers = mapper.readValue(oidcProvidersFile.getInputStream(), OpenIDConnectProviders.class); - assertFalse(providers.getProviders().isEmpty()); + if (providers.getProviders().isEmpty()) { + throw new InternalError("Providers list should not me empty."); + } resp = ResponseEntity.ok(providers); } else { log.debug("OIDC authentication is disabled."); diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index da174d6..7d463c6 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -193,13 +193,15 @@ public Optional getRequest() { "application/json" }, method = RequestMethod.POST) public ResponseEntity createJob(@Parameter(description = "", required = true) @Valid @RequestBody Job job, Principal principal) { - AccessToken token = null; - - if(principal != null) { - token = TokenUtil.getAccessToken(principal, tokenService); - job.setOwnerPrincipal(token.getPreferredUsername()); - ThreadContext.put("userid", token.getPreferredUsername()); + String username = principal.getName(); + AccessToken token = TokenUtil.getAccessToken(principal, tokenService); + if (null != token) { + username = token.getName(); } + + job.setOwnerPrincipal(username); + ThreadContext.put("userid", username); + //TODO add validity check of the job using ValidationApiController // UUID jobID = UUID.randomUUID(); // job.setId(jobID); @@ -697,12 +699,10 @@ public ResponseEntity estimateJob( public ResponseEntity listJobs( @Min(1) @Parameter(description = "This parameter enables pagination for the endpoint and specifies the maximum number of elements that arrays in the top-level object (e.g. jobs or log entries) are allowed to contain. The only exception is the `links` array, which MUST NOT be paginated as otherwise the pagination links may be missing ins responses. If the parameter is not provided or empty, all elements are returned. Pagination is OPTIONAL and back-ends and clients may not support it. Therefore it MUST be implemented in a way that clients not supporting pagination get all resources regardless. Back-ends not supporting pagination will return all resources. If the response is paginated, the links array MUST be used to propagate the links for pagination with pre-defined `rel` types. See the links array schema for supported `rel` types. *Note:* Implementations can use all kind of pagination techniques, depending on what is supported best by their infrastructure. So it doesn't care whether it is page-based, offset-based or uses tokens for pagination. The clients will use whatever is specified in the links with the corresponding `rel` types.") @Valid @RequestParam(value = "limit", required = false) Integer limit, Principal principal) { - // tentative String username = principal.getName(); - AccessToken token = TokenUtil.getAccessToken(principal, tokenService); if (null != token) { - username = token.getPreferredUsername(); + username = token.getName(); } BatchJobs batchJobs = new BatchJobs(); diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index fedb2a9..64b0946 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -135,18 +135,21 @@ public ResponseEntity computeResult(@Parameter(description = "", required = t JSONObject processGraphJSON = (JSONObject) job.getProcess().getProcessGraph(); AccessToken token = null; + if(principal != null) { token = TokenUtil.getAccessToken(principal, tokenService); } Set roles = new HashSet<>(); - Map resourceAccess = token.getResourceAccess(); - for (Map.Entry e : resourceAccess.entrySet()) { - if (e.getValue().getRoles() != null){ - for(String r: e.getValue().getRoles()) { - roles.add(r); - } - } + if (null != token) { + Map resourceAccess = token.getResourceAccess(); + for (Map.Entry e : resourceAccess.entrySet()) { + if (e.getValue().getRoles() != null){ + for(String r: e.getValue().getRoles()) { + roles.add(r); + } + } + } } boolean isEuracUser = roles.contains(EURAC_ROLE); diff --git a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java index 05b0e4f..1b68cd6 100644 --- a/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -104,6 +104,8 @@ public SecurityFilterChain baSecurityFilterChain(HttpSecurity http) throws Excep ) .exceptionHandling().authenticationEntryPoint(authEntrypoint) .and() + // POST /jobs -> CsrfFilter Invalid CSRF token found for https://10.8.244.94:8444/jobs + .csrf().disable() // disable session management (JSESSIONID cookies -> security risks) .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) From 94e1f7751ec4187762c0380bc67dc643ff164428 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 1 Aug 2023 18:15:07 +0200 Subject: [PATCH 16/27] Make project compile with OIDC auth enabled. --- .../openeo/spring/api/JobsApiController.java | 2 +- .../openeo/spring/api/MeApiController.java | 24 ++++++++++--------- .../spring/api/ResultApiController.java | 2 +- .../bearer/JWTAuthenticationFilter.java | 2 ++ .../spring/bearer/JWTAuthorizationFilter.java | 2 ++ .../openeo/spring/bearer/JWTTokenService.java | 2 ++ 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index 7d463c6..86826a6 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -119,7 +119,7 @@ public class JobsApiController implements JobsApi { @Autowired private AuthzService authzService; - @Autowired + @Autowired(required = false) private ITokenService tokenService; @Autowired diff --git a/src/main/java/org/openeo/spring/api/MeApiController.java b/src/main/java/org/openeo/spring/api/MeApiController.java index f7b7f0c..29cf656 100644 --- a/src/main/java/org/openeo/spring/api/MeApiController.java +++ b/src/main/java/org/openeo/spring/api/MeApiController.java @@ -30,10 +30,10 @@ @RequestMapping("${openapi.openEO.base-path:}") public class MeApiController implements MeApi { - @Autowired + @Autowired(required = false) UserDetailsService udService; - @Autowired + @Autowired(required = false) ITokenService tokenService; private final NativeWebRequest request; @@ -106,15 +106,17 @@ public ResponseEntity describeAccount(Principal principal) { userData.setName(username); userData.setUserId(username); - try { - UserDetails userDetails = udService.loadUserByUsername(username); - userData.setUserId("" + userDetails.hashCode()); // FIXME what to put here - } catch (UsernameNotFoundException ex) { -// ResponseEntity response = ApiUtil.errorResponse( -// HttpStatus.INTERNAL_SERVER_ERROR, -// "No user found by name: " + username); -// return response; - // NOP for now: what is the User Id? + if (null != udService) { + try { + UserDetails userDetails = udService.loadUserByUsername(username); + userData.setUserId("" + userDetails.hashCode()); // FIXME what to put here + } catch (UsernameNotFoundException ex) { + // ResponseEntity response = ApiUtil.errorResponse( + // HttpStatus.INTERNAL_SERVER_ERROR, + // "No user found by name: " + username); + // return response; + // NOP for now: what is the User Id? + } } ThreadContext.put("userid", username); // ? diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index 64b0946..59c930b 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -81,7 +81,7 @@ public class ResultApiController implements ResultApi { @Autowired private CollectionMap collectionMap; - @Autowired + @Autowired(required = false) private ITokenService tokenService; private final NativeWebRequest request; diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java index 7b483d9..d676eab 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java @@ -11,6 +11,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; @@ -29,6 +30,7 @@ * @see BearerTokenAuthenticationFilter */ @Component +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") public class JWTAuthenticationFilter extends OncePerRequestFilter { @Autowired diff --git a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java index dffd9e5..5f01d17 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -12,6 +12,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; @@ -31,6 +32,7 @@ * Basic HTTP authentication is assumed (basic// prefix is expected on the token). */ @Component +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") public class JWTAuthorizationFilter extends OncePerRequestFilter { @Autowired diff --git a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java index 1644cff..11d0dfa 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java +++ b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java @@ -12,6 +12,7 @@ import org.keycloak.representations.AccessToken; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -31,6 +32,7 @@ */ //FIXME use org.springframework.security.core.token.TokenService ? @Component +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") public class JWTTokenService implements ITokenService { @Value("${jwt.secret}") From 2bd29994654d1f1871ca7ad287f66f8c2d2d2125 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Wed, 2 Aug 2023 17:26:12 +0200 Subject: [PATCH 17/27] Migrate OIDC configuration to Spring Security 2.6.1 + tests. --- .../spring/api/CredentialsApiController.java | 3 + .../openeo/spring/api/JobsApiController.java | 1 + .../openeo/spring/api/MeApiController.java | 5 +- .../spring/api/ResultApiController.java | 1 + .../openeo/spring/bearer/JWTTokenService.java | 2 + .../bearer/PrefixedBearerTokenResolver.java | 68 +++++++++++++++ .../spring/{api => bearer}/TokenUtil.java | 43 +++++++--- .../keycloak/KeycloakLogoutHandler.java | 3 +- .../spring/security/GlobalSecurityConfig.java | 2 +- .../security/KeycloakSecurityConfig.java | 63 +++++++++----- src/main/resources/jwt.properties | 6 ++ src/main/resources/keycloak.properties | 21 +++++ .../components/TestErrorAttributes.java | 70 +++++++++++++++ .../security/TestBasicAuthentication.java | 82 +++++++++++------- .../TestBasicAuthentication_OIDCDisabled.java | 11 +-- .../security/TestOIDCAuthentication.java | 85 +++++++++++++++++-- 16 files changed, 389 insertions(+), 77 deletions(-) create mode 100644 src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java rename src/main/java/org/openeo/spring/{api => bearer}/TokenUtil.java (71%) create mode 100644 src/main/resources/jwt.properties create mode 100644 src/main/resources/keycloak.properties create mode 100644 src/test/java/org/openeo/spring/components/TestErrorAttributes.java diff --git a/src/main/java/org/openeo/spring/api/CredentialsApiController.java b/src/main/java/org/openeo/spring/api/CredentialsApiController.java index ec1c89c..1d4cd28 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -9,6 +9,7 @@ import org.openeo.spring.model.OpenIDConnectProviders; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -26,8 +27,10 @@ @javax.annotation.Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2020-07-02T08:45:00.334+02:00[Europe/Rome]") @Controller @RequestMapping("${openapi.openEO.base-path:}") +@PropertySource("classpath:keycloak.properties") public class CredentialsApiController implements CredentialsApi { + // TODO do we need it still? @Value("${org.openeo.oidc.providers.list}") private Resource oidcProvidersFile; diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index 86826a6..2e225fe 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -37,6 +37,7 @@ import org.json.JSONObject; import org.keycloak.representations.AccessToken; import org.openeo.spring.bearer.ITokenService; +import org.openeo.spring.bearer.TokenUtil; import org.openeo.spring.components.JobScheduler; import org.openeo.spring.dao.BatchJobResultDAO; import org.openeo.spring.dao.JobDAO; diff --git a/src/main/java/org/openeo/spring/api/MeApiController.java b/src/main/java/org/openeo/spring/api/MeApiController.java index 29cf656..74f85d8 100644 --- a/src/main/java/org/openeo/spring/api/MeApiController.java +++ b/src/main/java/org/openeo/spring/api/MeApiController.java @@ -8,6 +8,7 @@ import org.apache.logging.log4j.ThreadContext; import org.keycloak.representations.AccessToken; import org.openeo.spring.bearer.ITokenService; +import org.openeo.spring.bearer.TokenUtil; import org.openeo.spring.model.Error; import org.openeo.spring.model.UserData; import org.springframework.beans.factory.annotation.Autowired; @@ -94,6 +95,7 @@ public ResponseEntity describeAccount(Principal principal) { if (principal != null) { String username = principal.getName(); + String userId = username; // KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal; // AccessToken accessToken = keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken(); @@ -101,10 +103,11 @@ public ResponseEntity describeAccount(Principal principal) { AccessToken accessToken = TokenUtil.getAccessToken(principal, tokenService); if (null != accessToken) { username = accessToken.getName(); + userId = accessToken.getId(); } userData.setName(username); - userData.setUserId(username); + userData.setUserId(userId); if (null != udService) { try { diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index 59c930b..0692153 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -34,6 +34,7 @@ import org.json.JSONObject; import org.keycloak.representations.AccessToken; import org.openeo.spring.bearer.ITokenService; +import org.openeo.spring.bearer.TokenUtil; import org.openeo.spring.components.CollectionMap; import org.openeo.spring.components.CollectionsMap; import org.openeo.spring.components.JobScheduler; diff --git a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java index 11d0dfa..fb9396a 100644 --- a/src/main/java/org/openeo/spring/bearer/JWTTokenService.java +++ b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.PropertySource; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -33,6 +34,7 @@ //FIXME use org.springframework.security.core.token.TokenService ? @Component @ConditionalOnProperty(prefix="spring.security", value="enable-basic") +@PropertySource("classpath:jwt.properties") public class JWTTokenService implements ITokenService { @Value("${jwt.secret}") diff --git a/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java new file mode 100644 index 0000000..f19d641 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java @@ -0,0 +1,68 @@ +package org.openeo.spring.bearer; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; + +import io.jsonwebtoken.JwtException; + +/** + * A JWT Bearer token resolver which allows for prefix(es) "/" in tokens. + */ +public class PrefixedBearerTokenResolver implements BearerTokenResolver { + + private final DefaultBearerTokenResolver innerResolver; + private final String prefixStart; + + /** + * Constructor with a required (start of) token prefix. + * For instance, a {@code startsWith} value of "oidc/" will + * make a "oidc/acme/qwerzYYYYYY" token accepted. + * + * @param startsWith + */ + public PrefixedBearerTokenResolver(String startsWith) { + if (null == startsWith) { + startsWith = ""; + } + prefixStart = startsWith; + innerResolver = new DefaultBearerTokenResolver(); + } + + /** + * Constructor with no specific prefix required. + */ + public PrefixedBearerTokenResolver() { + this(null); + } + + /** + * @return The configured (start of) the prefix (an empty string + * if any prefix allowed. + * A token prefix is any string + * before the last forwards slash in the token hash. + */ + public String getPrefixStartsWith() { + return prefixStart; + } + + /** + * Strips the prefixes from the token {@code "prefix_1\/[prefix_2\/][...]TOKEN"}. + */ + @Override + public String resolve(HttpServletRequest request) { + String pureToken = null; + String prefixedToken = innerResolver.resolve(request); + + if (null != prefixedToken) { + if (!prefixedToken.startsWith(prefixStart)) { + throw new JwtException(String.format( + "Invalid token prefix. Expected: {}*", prefixStart)); + } + pureToken = prefixedToken.substring(prefixedToken.lastIndexOf('/') + 1); + } + + return pureToken; + } +} diff --git a/src/main/java/org/openeo/spring/api/TokenUtil.java b/src/main/java/org/openeo/spring/bearer/TokenUtil.java similarity index 71% rename from src/main/java/org/openeo/spring/api/TokenUtil.java rename to src/main/java/org/openeo/spring/bearer/TokenUtil.java index 072fcdd..18cb9d0 100644 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ b/src/main/java/org/openeo/spring/bearer/TokenUtil.java @@ -1,15 +1,14 @@ -package org.openeo.spring.api; +package org.openeo.spring.bearer; import java.security.Principal; -import org.keycloak.adapters.OidcKeycloakAccount; -import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.keycloak.representations.AccessToken; -import org.openeo.spring.bearer.ITokenService; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; /** * Utilities for fetching user sessions' access tokens. @@ -31,8 +30,8 @@ public static AccessToken getAccessToken(Principal principal, ITokenService serv AccessToken token = null; - if (principal instanceof KeycloakAuthenticationToken) { - token = TokenUtil.getKCAccessToken((KeycloakAuthenticationToken) principal); + if (principal instanceof JwtAuthenticationToken) { + token = TokenUtil.getKCAccessToken((JwtAuthenticationToken) principal); } else if (principal instanceof UsernamePasswordAuthenticationToken) { @@ -51,12 +50,36 @@ else if (principal instanceof BearerTokenAuthenticationToken) { * @param principal the user asking for the token * @return the access token; {@code null} when not found (or with {@code null} input) */ - static AccessToken getKCAccessToken(KeycloakAuthenticationToken principal) { + static AccessToken getKCAccessToken(JwtAuthenticationToken principal) { if (null == principal) { return null; } - OidcKeycloakAccount account = principal.getAccount(); - AccessToken token = account.getKeycloakSecurityContext().getToken(); + + AccessToken token = new AccessToken(); + + // TODO is it always a Jwt object? + Jwt jwt = (Jwt) principal.getPrincipal(); + + token.id(jwt.getId()); +// token.setName(jwt.getSubject()); -> gives the token hash + token.setName(jwt.getClaimAsString("name")); + token.setAccessTokenHash(jwt.getTokenValue()); + + if (null != jwt.getAudience()) { + jwt.getAudience().forEach( + (x) -> token.addAudience(x)); + } + + if (null != jwt.getIssuedAt()) { + token.iat(jwt.getIssuedAt().toEpochMilli()); + } + token.exp(jwt.getExpiresAt().toEpochMilli()); + + // other claims + jwt.getClaims().forEach(( + k,v) -> token.setOtherClaims(k, v) + ); + // more needed ? certficates etc. just a format conversion, we might abandon AccessToken class return token; } @@ -92,7 +115,7 @@ static AccessToken getBAAccessToken(UsernamePasswordAuthenticationToken principa } /** - * FEtches and decodes a bearer token. + * Fetches and decodes a bearer token. * @param principal * @param tokenService * @return diff --git a/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java index 5d4fc33..19815a9 100644 --- a/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java +++ b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java @@ -44,10 +44,9 @@ private void logoutFromKeycloak(OidcUser user) { ResponseEntity logoutResponse = restTemplate.getForEntity( builder.toUriString(), String.class); if (logoutResponse.getStatusCode().is2xxSuccessful()) { - logger.info("Successfulley logged out from Keycloak"); + logger.info("Successfully logged out from Keycloak"); } else { logger.error("Could not propagate logout to Keycloak"); } } - } diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 1ccd40a..6048bf2 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -44,7 +44,7 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { "/favicon.ico", "/conformance", "/file_formats", - "/.well-known/**"}; + "/.well-known/openeo"}; public static final String BASIC_AUTH_API_RESOURCE = "/credentials/basic"; public static final String OIDC_AUTH_API_RESOURCE = "/credentials/oidc"; diff --git a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java index 49fd8e0..2c041f6 100644 --- a/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java @@ -6,32 +6,45 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.openeo.spring.bearer.PrefixedBearerTokenResolver; +import org.openeo.spring.components.FilterChainExceptionHandler; import org.openeo.spring.keycloak.KeycloakLogoutHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.PropertySource; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; @Configuration //@Profile(KeycloakSecurityConfig.PROFILE_ID) -> better use: @ConditionalOnProperty(prefix="spring.security", value="enable-keycloak") +@PropertySource("classpath:keycloak.properties") public class KeycloakSecurityConfig { /** Used to define a {@link Profile}. */ public static final String PROFILE_ID = "KEYCLOAK_AUTH"; + /** The beginning of an OIDC JWT token prefix (full prefix depends on provider) */ + public static final String TOKEN_PREFIX_START = "oidc/"; // "oidc/ms/TOKEN" + private static final Logger LOGGER = LogManager.getLogger(KeycloakSecurityConfig.class); @Autowired - private KeycloakLogoutHandler keycloakLogoutHandler; + KeycloakLogoutHandler keycloakLogoutHandler; + + @Autowired + FilterChainExceptionHandler filterChainExHandler; @Bean protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { @@ -48,38 +61,40 @@ protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { public SecurityFilterChain kcLoginFilterChain(HttpSecurity http) throws Exception { http .antMatcher(OIDC_AUTH_API_RESOURCE) - .oauth2Login() + .oauth2Login(/*withDefaults()*/) .and() - .logout() - .addLogoutHandler(keycloakLogoutHandler) - .logoutSuccessUrl("/"); - - http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + // disable session management (JSESSIONID cookies -> security risks) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + // logout + .and() + .logout(logout -> logout + .addLogoutHandler(keycloakLogoutHandler) + .logoutSuccessUrl("/") + ); LOGGER.info("Keycloak authentication security chain set."); return http.build(); } - /** - * Requires authenticated user on all resources. - * - * NOTE: resources to be ignored by the authorization service are - * configured in {@link #webSecurityCustomizer()}. -// * - * @param http - * @return - * @throws Exception - */ @Bean public SecurityFilterChain kcSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() - ); + ) + // disable session management (JSESSIONID cookies -> security risks) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + // JWT token + .and() + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + // catch exceptions and resolve them to error for the client + .addFilterBefore(filterChainExHandler, LogoutFilter.class); - LOGGER.info("Keycloak authorization security chain set."); - + LOGGER.info("Keycloak authentication security chain set."); + return http.build(); } @@ -94,6 +109,14 @@ public WebSecurityCustomizer kcWebSecurityCustomizer() { .antMatchers(BASIC_AUTH_API_RESOURCE); } + /** + * Need to strip away the {@code oidc/ms/} prefix from the token before decoding it. + */ + @Bean + BearerTokenResolver bearerTokenResolver() { + return new PrefixedBearerTokenResolver(TOKEN_PREFIX_START); + } + /** * Overrides default API filters set up by Spring Boot auto-configuration. * Without this, OAuth2 login redirection specified in {@code application.properties} is processed. diff --git a/src/main/resources/jwt.properties b/src/main/resources/jwt.properties new file mode 100644 index 0000000..ab43a74 --- /dev/null +++ b/src/main/resources/jwt.properties @@ -0,0 +1,6 @@ +# Basic JWT tokens +jwt.secret=cf6d7564cf06725c99cff8b8722f58b8f33aeb7b9df22ad7c0e5e7b896fd0411edbe3de7ca76939db0840fd8cf4640d256c27302f12d5c20804d2b952da97685 +jwt.issuer=ACME Srl +jwt.type=JWT +jwt.audience=openEO +jwt.exp-minutes=60 \ No newline at end of file diff --git a/src/main/resources/keycloak.properties b/src/main/resources/keycloak.properties new file mode 100644 index 0000000..a5e21fb --- /dev/null +++ b/src/main/resources/keycloak.properties @@ -0,0 +1,21 @@ +# OAuth2/Keycloak +# @see https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings + +#spring.autoconfigure.exclude = org.keycloak.adapters.springboot.KeycloakAutoConfiguration # legacy +spring.security.oauth2.client.registration.keycloak.client-id=openEO +spring.security.oauth2.client.registration.keycloak.client-secret=47eca175-ac0d-4938-8b4f-f30718c29405 +spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.keycloak.scope=openid + +# OpenID Connect (OIDC) +spring.security.oauth2.client.provider.keycloak.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username + +# OAUTH2-JWT token +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp + +# openEO credentials +org.openeo.oidc.providers.list=classpath:oidc_providers.json +# legacy: +#org.openeo.oidc.configuration.endpoint=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.keycloak.conf-file=keycloak.json diff --git a/src/test/java/org/openeo/spring/components/TestErrorAttributes.java b/src/test/java/org/openeo/spring/components/TestErrorAttributes.java new file mode 100644 index 0000000..40a80a7 --- /dev/null +++ b/src/test/java/org/openeo/spring/components/TestErrorAttributes.java @@ -0,0 +1,70 @@ +package org.openeo.spring.components; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openeo.spring.api.ApiUtil; +import org.openeo.spring.model.Error; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.error.ErrorAttributeOptions.Include; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.ModelAndView; + +/** + * Unit testing of the customized error attributes + * in the Spring Boot application (which do not get (easily) + * wired in in mock MVC integration tests) + */ +public class TestErrorAttributes { + + private ErrorAttributes errorAttributes; + private MockHttpServletRequest request; + + @BeforeEach + public void setup() { + errorAttributes = new ErrorAttributes(); + request = new MockHttpServletRequest(); + } + + @ParameterizedTest + @EnumSource(value = HttpStatus.class, names = { + "BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", + "INTERNAL_SERVER_ERROR", "NOT_IMPLEMENTED", "BAD_GATEWAY"}) + void mvcError(HttpStatus errCode) { + + String errMsg = "Massive Black Hole"; + ResponseEntity error = ApiUtil.errorResponse(errCode, errMsg); + request.setAttribute(BAuthEntrypoint.REALM_LABEL, error); + + RuntimeException ex = new RuntimeException("Test Exception"); + ModelAndView modelAndView = errorAttributes.resolveException(request, null, null, ex); + + WebRequest webRequest = new ServletWebRequest(this.request); + Map attributes = errorAttributes.getErrorAttributes(webRequest, + ErrorAttributeOptions.of(Include.MESSAGE)); + + assertEquals(errorAttributes.getError(webRequest), ex); + + assertThat(attributes.size()).isEqualTo(4); + assertTrue(attributes.containsKey("id")); + assertTrue(attributes.containsKey("code")); + assertTrue(attributes.containsKey("message")); + assertTrue(attributes.containsKey("link")); + + assertEquals(attributes.get("code"), String.valueOf(errCode.value())); + assertEquals(attributes.get("message"), errMsg); + + assertThat(modelAndView).isNull(); + } + +} diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 18b68c7..b6719b9 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -9,8 +9,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.openeo.spring.api.CredentialsApiController; import org.openeo.spring.bearer.JWTTokenService; import org.springframework.beans.factory.annotation.Autowired; @@ -43,12 +47,12 @@ //@ActiveProfiles("test") // -> src/test/resources/application-$PROFILE.properties public abstract class TestBasicAuthentication { - @Autowired + @Autowired MockMvc mvc; @Autowired WebApplicationContext context; - + @Autowired JWTTokenService tokenService; @@ -76,6 +80,14 @@ public void get_okBasic_shouldSucceedWith200() throws Exception { assertEquals(bodyAccessToken, headerToken, "token in body and header should coincide"); } + @ParameterizedTest + @MethodSource("providePublicAPIResources") + public void get_publicResourceNoAuth_shouldSucceedWith200(String resource) throws Exception { + mvc.perform(get(resource) + ).andExpect( + status().isOk()); + } + @Test @WithMockUser(value = "satan") public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Exception { @@ -97,11 +109,11 @@ public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Except .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") ).andExpectAll( status().is(403), - content().contentType(MediaType.APPLICATION_JSON) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() ); } @@ -111,11 +123,11 @@ public void get_wrongTokenPrefix_shouldReturn403() throws Exception { .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") ).andExpectAll( status().is(403), - content().contentType(MediaType.APPLICATION_JSON) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() ); } @@ -132,11 +144,11 @@ public void get_expiredToken_shouldReturn403() throws Exception { String.format("Bearer basic//%s", token)) ).andExpectAll( status().is(403), - content().contentType(MediaType.APPLICATION_JSON) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() ); } @@ -146,10 +158,11 @@ public void get_noAuth_shouldReturnAuthRequired401() throws Exception { ).andExpectAll( status().is(401), header().exists(HttpHeaders.WWW_AUTHENTICATE) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + // Hard to wire in custom ErrorAttributes in MockMvc TODO +// jsonPath("$.id").hasJsonPath(), +// jsonPath("$.code").exists(), +// jsonPath("$.message").exists(), +// jsonPath("$.links").hasJsonPath() ); } @@ -161,10 +174,13 @@ public void get_wrongAuth_shouldReturn401() throws Exception { .header(HttpHeaders.AUTHORIZATION, "Basic _InfestTheRatsNest_=") ).andExpectAll( status().is(401) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + // Hard to wire in custom ErrorAttributes in MockMvc TODO + // @see https://stackoverflow.com/questions/29120948/testing-a-spring-boot-application-with-custom-errorattributes + // @see https://stackoverflow.com/questions/43836231/re-use-spring-boot-errorattributes-when-using-mockmvc-and-spring-rest-docs +// jsonPath("$.id").hasJsonPath(), +// jsonPath("$.code").exists(), +// jsonPath("$.message").exists(), +// jsonPath("$.links").hasJsonPath() ); } @@ -183,11 +199,15 @@ public void disabledBasicAuth_shouldReturn501() throws Exception { mvc.perform(get("/credentials/basic") ).andExpectAll( - status().is(501) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + status().is(501), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() ); } -} + + private static List providePublicAPIResources() { + return Arrays.asList(GlobalSecurityConfig.NOAUTH_API_RESOURCES); + } + } diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java index 87cbc99..dadfe0e 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java @@ -1,6 +1,7 @@ package org.openeo.spring.security; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; @@ -16,11 +17,11 @@ public class TestBasicAuthentication_OIDCDisabled extends TestBasicAuthenticatio public void disabledOIDCAuth_shouldReturn501() throws Exception { mvc.perform(get("/credentials/oidc") ).andExpectAll( - status().is(501) -// header().exists("id"), FIXME ErrorAttributes not picked in tests -// header().exists("code"), -// header().exists("message"), -// header().exists("links") + status().is(501), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() ); } } diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java index 55d691c..e3e3afe 100644 --- a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -1,32 +1,103 @@ package org.openeo.spring.security; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import org.junit.jupiter.api.Disabled; +import java.util.Arrays; +import java.util.List; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest @ActiveProfiles("oidc") -@Disabled("OIDC login/sessions not tested yet") public class TestOIDCAuthentication { @Autowired private MockMvc mvc; - + + @Test + public void get_noAuth_shouldReturnProviders200() throws Exception { + mvc.perform(get("/credentials/oidc") + ).andExpectAll( + status().isOk(), + jsonPath("$.providers").exists(), + jsonPath("$.providers").isArray(), + jsonPath("$.providers").isNotEmpty(), +// jsonPath("$.providers[*].*", is(7)), TODO + jsonPath("$.providers[*].id").exists(), + jsonPath("$.providers[*].issuer").exists(), + jsonPath("$.providers[*].scopes").hasJsonPath(), + jsonPath("$.providers[*].title").exists(), + jsonPath("$.providers[*].description").hasJsonPath(), + jsonPath("$.providers[*].default_clients").hasJsonPath(), + jsonPath("$.providers[*].links").hasJsonPath() + ); + } + + @Test + @WithMockUser(value = "brutus") + public void get_protectedResourceAuth_shouldSucceedWith200() throws Exception { + mvc.perform(get("/collections") + ).andExpect( + status().isOk()); + } + + @ParameterizedTest + @MethodSource("providePublicAPIResources") + public void get_publicResourceNoAuth_shouldSucceedWith200(String resource) throws Exception { + mvc.perform(get(resource) + ).andExpect( + status().isOk()); + } + + @Test + public void get_InvalidToken_shouldReturn401() throws Exception { + mvc.perform(get("/collections") + .header(HttpHeaders.AUTHORIZATION, "Bearer oidc/ACME/00000000000FAKE00000000000") + ).andExpectAll( + status().is(401), + header().exists(HttpHeaders.WWW_AUTHENTICATE) + ); + } + + @Test + public void get_InvalidTokenPrefix_shouldReturn403() throws Exception { + mvc.perform(get("/collections") + .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") + ).andExpectAll( + status().is(403), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() + ); + } + @Test public void disabledBasicAuth_shouldReturn501() throws Exception { mvc.perform(get("/credentials/basic") ).andExpectAll( status().is(501), - header().exists("id"), - header().exists("code"), - header().exists("message"), - header().exists("links")); + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath()); + } + + private static List providePublicAPIResources() { + return Arrays.asList(GlobalSecurityConfig.NOAUTH_API_RESOURCES); } } From 711fffed9bd1a9e6b76939d16d03511d3b6b6155 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Fri, 4 Aug 2023 09:32:42 +0200 Subject: [PATCH 18/27] Refactor test application.properties. --- .../resources/application-test.properties | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index e0bb00a..04223f6 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,35 +1,10 @@ -#################################################################### -# FIXME: this is workaround to allow Spring Security conf from XML -spring.main.allow-bean-definition-overriding=true -# Spring 5.3 : https://spring.io/blog/2020/06/30/url-matching-with-pathpattern-in-spring-mvc : -#spring.mvc.pathmatch.matching-strategy = ANT_PATH_MATCHER -#################################################################### + +# authentication spring.security.enable-basic=true -spring.security.enable-keycloak=true -# Basic JWT tokens -jwt.secret=cf6d7564cf06725c99cff8b8722f58b8f33aeb7b9df22ad7c0e5e7b896fd0411edbe3de7ca76939db0840fd8cf4640d256c27302f12d5c20804d2b952da97685 -jwt.issuer=ACME Srl -jwt.type=JWT -jwt.audience=openEO -jwt.exp-minutes=30 -# OAuth2/Keycloak -# @see https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings -#spring.autoconfigure.exclude = org.keycloak.adapters.springboot.KeycloakAutoConfiguration # legacy -spring.security.oauth2.client.registration.keycloak.client-id=openEO -spring.security.oauth2.client.registration.keycloak.client-secret=47eca175-ac0d-4938-8b4f-f30718c29405 -spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code -spring.security.oauth2.client.registration.keycloak.scope=openid -# OpenID Connect (OIDC) -spring.security.oauth2.client.provider.keycloak.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp -spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username -# OAUTH2-JWT token -spring.security.oauth2.resourceserver.jwt.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp -# openEO credentials -org.openeo.oidc.providers.list=classpath:oidc_providers.json -# legacy: -#org.openeo.oidc.configuration.endpoint=https://edp-portal.eurac.edu/auth/realms/edp -spring.security.keycloak.conf-file=keycloak.json -#################################################################### +spring.security.enable-keycloak=false + +# FIXME: this is workaround to allow Spring Security conf from XML +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration #springfox.documentation.swagger.v2.path=/api-docs # TODO v3 ? From bac37a7229dc4ad5e7ba1ef3ded180c7e1cfff2d Mon Sep 17 00:00:00 2001 From: pcampalani Date: Fri, 4 Aug 2023 11:29:38 +0200 Subject: [PATCH 19/27] GET /collections not to require authentication. --- .../org/openeo/spring/security/GlobalSecurityConfig.java | 3 ++- .../openeo/spring/security/TestBasicAuthentication.java | 8 ++++---- .../openeo/spring/security/TestOIDCAuthentication.java | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 6048bf2..4ca8e23 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -41,8 +41,9 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { /** API resources that do not require authentication. */ public static final String[] NOAUTH_API_RESOURCES = new String[] { "/", - "/favicon.ico", "/conformance", + "/collections", + "/favicon.ico", "/file_formats", "/.well-known/openeo"}; diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index b6719b9..5b90945 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -96,7 +96,7 @@ public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Except UserDetails user = (UserDetails) auth.getPrincipal(); String token = tokenService.generateToken(user); - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpect( @@ -105,7 +105,7 @@ public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Except @Test public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Exception { - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, "Bearer basic//00000000000FAKE00000000000") ).andExpectAll( status().is(403), @@ -119,7 +119,7 @@ public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Except @Test public void get_wrongTokenPrefix_shouldReturn403() throws Exception { - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") ).andExpectAll( status().is(403), @@ -139,7 +139,7 @@ public void get_expiredToken_shouldReturn403() throws Exception { UserDetails user = (UserDetails) auth.getPrincipal(); String token = tokenService.generateToken(user, -1, ChronoUnit.SECONDS); - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpectAll( diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java index e3e3afe..5f755a1 100644 --- a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -49,7 +49,7 @@ public void get_noAuth_shouldReturnProviders200() throws Exception { @Test @WithMockUser(value = "brutus") public void get_protectedResourceAuth_shouldSucceedWith200() throws Exception { - mvc.perform(get("/collections") + mvc.perform(get("/jobs") ).andExpect( status().isOk()); } @@ -64,7 +64,7 @@ public void get_publicResourceNoAuth_shouldSucceedWith200(String resource) throw @Test public void get_InvalidToken_shouldReturn401() throws Exception { - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, "Bearer oidc/ACME/00000000000FAKE00000000000") ).andExpectAll( status().is(401), @@ -74,7 +74,7 @@ public void get_InvalidToken_shouldReturn401() throws Exception { @Test public void get_InvalidTokenPrefix_shouldReturn403() throws Exception { - mvc.perform(get("/collections") + mvc.perform(get("/jobs") .header(HttpHeaders.AUTHORIZATION, "Bearer wysiwyg//00000000000FAKE00000000000") ).andExpectAll( status().is(403), From 3acdfff17321a00aee11deb5b41bdd73c0fadf70 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 8 Aug 2023 12:08:03 +0200 Subject: [PATCH 20/27] Keep a BA credentials XML configuration for tests. --- src/test/resources/spring-ba-security.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/test/resources/spring-ba-security.xml diff --git a/src/test/resources/spring-ba-security.xml b/src/test/resources/spring-ba-security.xml new file mode 100644 index 0000000..2fb094b --- /dev/null +++ b/src/test/resources/spring-ba-security.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + From 24d7a5e36ec5e8c7c7dd2aeae6cce7da517be0f6 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 8 Aug 2023 20:08:55 +0200 Subject: [PATCH 21/27] Disable legacy AuthzService in /jobs controller. --- .../openeo/spring/api/JobsApiController.java | 31 ++++++++++++------- .../legacy}/AuthzService.java | 0 2 files changed, 19 insertions(+), 12 deletions(-) rename src/main/java/org/openeo/spring/{api => keycloak/legacy}/AuthzService.java (100%) diff --git a/src/main/java/org/openeo/spring/api/JobsApiController.java b/src/main/java/org/openeo/spring/api/JobsApiController.java index 2e225fe..c7367a7 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -41,6 +41,7 @@ import org.openeo.spring.components.JobScheduler; import org.openeo.spring.dao.BatchJobResultDAO; import org.openeo.spring.dao.JobDAO; +import org.openeo.spring.keycloak.legacy.AuthzService; import org.openeo.spring.model.BatchJobEstimate; import org.openeo.spring.model.BatchJobResult; import org.openeo.spring.model.BatchJobs; @@ -117,7 +118,7 @@ public class JobsApiController implements JobsApi { @Autowired private JobScheduler jobScheduler; - @Autowired + @Autowired(required = false) private AuthzService authzService; @Autowired(required = false) @@ -251,14 +252,10 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) resultEngine = resultApiController.checkGraphValidityAndEngine(processGraph); job.setEngine(resultEngine); } catch (Exception e) { - job.setEngine(null); // lenient: I can create an impossible graph -// Error error = new Error(); -// error.setCode("500"); -// error.setMessage(e.getMessage()); - warningMessage = e.getMessage(); - log.warn("Creating an unfeasible process graph: {}", warningMessage); -// ThreadContext.clearMap(); -// return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); +// job.setEngine(null); // lenient: I can create an impossible "draft" graph ? + ThreadContext.clearMap(); + return ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, + "Creating an unfeasible process graph."); } jobDAO.save(job); @@ -274,7 +271,7 @@ public ResponseEntity createJob(@Parameter(description = "", required = true) Job verifiedSave = jobDAO.findOne(job.getId()); if (verifiedSave != null) { - if(token != null) { + if((null != token) && (null != authzService)) { authzService.createProtectedResource(job, token); } @@ -524,8 +521,10 @@ public ResponseEntity deleteJob( } jobDAO.delete(job); log.debug("The job {} was successfully deleted.", jobId); - authzService.deleteProtectedResource(job); - log.debug("The job {} was successfully deleted from Keycloak.", jobId); + if (null != authzService) { + authzService.deleteProtectedResource(job); + log.debug("The job {} was successfully deleted from Keycloak.", jobId); + } return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } else { ResponseEntity response = ApiUtil.errorResponse(HttpStatus.BAD_REQUEST, @@ -1003,6 +1002,14 @@ else if(job.getStatus()==JobStates.QUEUED) { ThreadContext.clearMap(); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + } else { + log.warn("No engine attached to job {}.", job); + if (JobStates.CREATED != job.getStatus()) { + throw new InternalError("Job draft should not have status " + job.getStatus()); + } else { + return ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + String.format("The requested job %s has no engine.", jobId)); + } } } Error error = new Error(); diff --git a/src/main/java/org/openeo/spring/api/AuthzService.java b/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java similarity index 100% rename from src/main/java/org/openeo/spring/api/AuthzService.java rename to src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java From 92201fcae92b58f7d3127b71df767f188c0d9a3b Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 8 Aug 2023 20:12:08 +0200 Subject: [PATCH 22/27] Add /collections to public API resources. --- .../spring/api/ResultApiController.java | 34 ++++++++++--------- .../bearer/PrefixedBearerTokenResolver.java | 3 +- .../spring/keycloak/legacy/AuthzService.java | 8 ++--- .../spring/security/GlobalSecurityConfig.java | 2 ++ .../security/TestBasicAuthentication.java | 16 +++++---- .../security/TestOIDCAuthentication.java | 8 ++--- 6 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/openeo/spring/api/ResultApiController.java b/src/main/java/org/openeo/spring/api/ResultApiController.java index 0692153..b044b28 100644 --- a/src/main/java/org/openeo/spring/api/ResultApiController.java +++ b/src/main/java/org/openeo/spring/api/ResultApiController.java @@ -314,22 +314,24 @@ public EngineTypes checkGraphValidityAndEngine(JSONObject processGraphJSON) thro for (JSONObject loadCollectionNode: loadCollectionNodes) { collectionID = loadCollectionNode.getJSONObject("arguments").get("id").toString(); // The collection id requested in the process graph - Collections engineCollections = collectionsMap.get(enType); // All the collections offered by this engine type - Collection collection = null; - - for (Collection coll: engineCollections.getCollections()) { - if (coll.getId().equals(collectionID)) { - collection = coll; // We found the requested collection in the current engine of the loop - break; - } - } - if (collection == null) { - containsSameEngineCollections = false; - break; - } - else { - selectedEngineType = enType; - containsSameEngineCollections = true; + if (collectionsMap.containsKey(enType)) { + Collections engineCollections = collectionsMap.get(enType); // All the collections offered by this engine type + Collection collection = null; + + for (Collection coll: engineCollections.getCollections()) { + if (coll.getId().equals(collectionID)) { + collection = coll; // We found the requested collection in the current engine of the loop + break; + } + } + if (collection == null) { + containsSameEngineCollections = false; + break; + } + else { + selectedEngineType = enType; + containsSameEngineCollections = true; + } } } if (containsSameEngineCollections) { diff --git a/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java index f19d641..96b5be7 100644 --- a/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java +++ b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java @@ -58,7 +58,8 @@ public String resolve(HttpServletRequest request) { if (null != prefixedToken) { if (!prefixedToken.startsWith(prefixStart)) { throw new JwtException(String.format( - "Invalid token prefix. Expected: {}*", prefixStart)); + "Invalid token prefix. Expected: '%s'. Token: %20s...", + prefixStart, prefixedToken)); } pureToken = prefixedToken.substring(prefixedToken.lastIndexOf('/') + 1); } diff --git a/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java b/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java index 2d8f291..99c52d8 100644 --- a/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java +++ b/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java @@ -1,6 +1,7 @@ -package org.openeo.spring.api; +package org.openeo.spring.keycloak.legacy; import java.util.HashSet; +import java.util.List; import org.keycloak.authorization.client.AuthzClient; import org.keycloak.authorization.client.resource.ProtectedResource; @@ -9,14 +10,13 @@ import org.keycloak.representations.idm.authorization.ResourceRepresentation; import org.keycloak.representations.idm.authorization.ScopeRepresentation; import org.openeo.spring.model.Job; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import java.util.List; - - @Component +@ConditionalOnExpression(value = "false") // TODO what is this? public class AuthzService { static final String SCOPE_VIEW = "urn:openEO:scopes:view"; diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 4ca8e23..d3cd9ba 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -43,6 +43,8 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { "/", "/conformance", "/collections", + "/collections/**", +// "/download/**", // what is this? Web Editor requests it without token. new openEO API version? "/favicon.ico", "/file_formats", "/.well-known/openeo"}; diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 5b90945..858d586 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -9,12 +9,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.openeo.spring.api.CredentialsApiController; import org.openeo.spring.bearer.JWTTokenService; import org.springframework.beans.factory.annotation.Autowired; @@ -88,15 +88,16 @@ public void get_publicResourceNoAuth_shouldSucceedWith200(String resource) throw status().isOk()); } - @Test + @ParameterizedTest + @ValueSource(strings = {"/jobs", "/me"}) @WithMockUser(value = "satan") - public void get_protectedResourcewWithToken_shouldSucceedWith200() throws Exception { + public void get_protectedResourcewWithToken_shouldSucceedWith200(String res) throws Exception { // manually generate token Authentication auth = SecurityContextHolder.getContext().getAuthentication(); UserDetails user = (UserDetails) auth.getPrincipal(); String token = tokenService.generateToken(user); - mvc.perform(get("/jobs") + mvc.perform(get(res) .header(HttpHeaders.AUTHORIZATION, String.format("Bearer basic//%s", token)) ).andExpect( @@ -207,7 +208,8 @@ public void disabledBasicAuth_shouldReturn501() throws Exception { ); } - private static List providePublicAPIResources() { - return Arrays.asList(GlobalSecurityConfig.NOAUTH_API_RESOURCES); + private static Stream providePublicAPIResources() { + return Stream.of(GlobalSecurityConfig.NOAUTH_API_RESOURCES) + .filter(res -> !res.contains("*")); } } diff --git a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java index 5f755a1..7f3cbf9 100644 --- a/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -6,8 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.Arrays; -import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -97,7 +96,8 @@ public void disabledBasicAuth_shouldReturn501() throws Exception { jsonPath("$.links").hasJsonPath()); } - private static List providePublicAPIResources() { - return Arrays.asList(GlobalSecurityConfig.NOAUTH_API_RESOURCES); + private static Stream providePublicAPIResources() { + return Stream.of(GlobalSecurityConfig.NOAUTH_API_RESOURCES) + .filter(res -> !res.contains("*")); } } From c7e506dbd7fe2ddc4f80df0e4223bee50a110567 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Wed, 9 Aug 2023 10:37:07 +0200 Subject: [PATCH 23/27] Open /download resource. --- .../java/org/openeo/spring/security/GlobalSecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index d3cd9ba..2ec2c85 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -44,7 +44,7 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { "/conformance", "/collections", "/collections/**", -// "/download/**", // what is this? Web Editor requests it without token. new openEO API version? + "/download/**", "/favicon.ico", "/file_formats", "/.well-known/openeo"}; From d743a023ceefd0c8109b473842ad8553675e9b90 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Wed, 9 Aug 2023 12:29:39 +0200 Subject: [PATCH 24/27] Remove Keycloak secret from repo. --- .../resources/keycloak-test.properties} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/{main/resources/keycloak.properties => test/resources/keycloak-test.properties} (78%) diff --git a/src/main/resources/keycloak.properties b/src/test/resources/keycloak-test.properties similarity index 78% rename from src/main/resources/keycloak.properties rename to src/test/resources/keycloak-test.properties index a5e21fb..50734e7 100644 --- a/src/main/resources/keycloak.properties +++ b/src/test/resources/keycloak-test.properties @@ -3,16 +3,16 @@ #spring.autoconfigure.exclude = org.keycloak.adapters.springboot.KeycloakAutoConfiguration # legacy spring.security.oauth2.client.registration.keycloak.client-id=openEO -spring.security.oauth2.client.registration.keycloak.client-secret=47eca175-ac0d-4938-8b4f-f30718c29405 +spring.security.oauth2.client.registration.keycloak.client-secret={{SECRET}} spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.keycloak.scope=openid # OpenID Connect (OIDC) -spring.security.oauth2.client.provider.keycloak.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.oauth2.client.provider.keycloak.issuer-uri={{ISSURE-URI}} spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username # OAUTH2-JWT token -spring.security.oauth2.resourceserver.jwt.issuer-uri=https://edp-portal.eurac.edu/auth/realms/edp +spring.security.oauth2.resourceserver.jwt.issuer-uri={{TOKEN-ISSUER-URI}} # openEO credentials org.openeo.oidc.providers.list=classpath:oidc_providers.json From ef57d34ea13f1f4b222bebad4b4415d8fb8d45c7 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 29 Aug 2023 12:31:14 +0200 Subject: [PATCH 25/27] Remove auth restrictions from /processes/** endpoints. --- .../java/org/openeo/spring/security/GlobalSecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java index 2ec2c85..66a68ea 100644 --- a/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -42,9 +42,9 @@ public class GlobalSecurityConfig implements EnvironmentPostProcessor { public static final String[] NOAUTH_API_RESOURCES = new String[] { "/", "/conformance", - "/collections", "/collections/**", - "/download/**", + "/download/**", + "/processes/**", "/favicon.ico", "/file_formats", "/.well-known/openeo"}; From 367aaa156dd14a86566d631f2ab1491509f79f91 Mon Sep 17 00:00:00 2001 From: pcampalani Date: Tue, 29 Aug 2023 19:21:05 +0200 Subject: [PATCH 26/27] Disable Spring Boot OAuth2 client auto-configuration when Keycloak auth is disabled. --- .../KeycloakAutoconfigPostProcessor.java | 76 +++++++++++++++++++ src/main/resources/META-INF/spring.factories | 4 +- .../security/TestBasicAuthentication.java | 1 - src/test/resources/jwt.properties | 6 ++ ...test.properties => keycloak.properties.in} | 0 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/openeo/spring/keycloak/KeycloakAutoconfigPostProcessor.java create mode 100644 src/test/resources/jwt.properties rename src/test/resources/{keycloak-test.properties => keycloak.properties.in} (100%) diff --git a/src/main/java/org/openeo/spring/keycloak/KeycloakAutoconfigPostProcessor.java b/src/main/java/org/openeo/spring/keycloak/KeycloakAutoconfigPostProcessor.java new file mode 100644 index 0000000..8c2c62d --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/KeycloakAutoconfigPostProcessor.java @@ -0,0 +1,76 @@ +package org.openeo.spring.keycloak; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; + +/** + * This component disables Sprint Boot OAuth2 clients auto-configurations + * whenever Keycloak authentication is also disabled. + */ +@Order(Ordered.LOWEST_PRECEDENCE) +public class KeycloakAutoconfigPostProcessor implements EnvironmentPostProcessor { + + public static final String SPRING_EXCLUDE_PROPERTY = "spring.autoconfigure.exclude"; + public static final String SPRING_EXCLUDE_CLASS = OAuth2ClientAutoConfiguration.class.getCanonicalName(); + + private static final String PROPERTY_SOURCE_NAME = "overwriteProperties"; + private static final Logger LOGGER = LogManager.getLogger(KeycloakAutoconfigPostProcessor.class); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) { + + boolean enableKeycloakAuth = env.getProperty("spring.security.enable-keycloak", Boolean.class); + + if (!enableKeycloakAuth) { + String excludes = env.getProperty(SPRING_EXCLUDE_PROPERTY); + + if (null == excludes || excludes.isBlank()) { + excludes = SPRING_EXCLUDE_CLASS; + } else { + excludes += "," + SPRING_EXCLUDE_CLASS; + } + + // disable OAuth2 client auto-configuration, programmatically: + Map map = new HashMap<>(); + map.put(SPRING_EXCLUDE_PROPERTY, excludes); + + addOrReplace(env.getPropertySources(), map); + LOGGER.debug("Spring Boot OAuth2 client auto-configuration disabled."); + } + } + + /** Adds/overrides a property in the environment. */ + private void addOrReplace(MutablePropertySources propertySources, + Map map) { + MapPropertySource target = null; + if (propertySources.contains(PROPERTY_SOURCE_NAME)) { + PropertySource source = propertySources.get(PROPERTY_SOURCE_NAME); + if (source instanceof MapPropertySource) { + target = (MapPropertySource) source; + for (String key : map.keySet()) { + if (!target.containsProperty(key)) { + target.getSource().put(key, map.get(key)); + } + } + } + } + if (target == null) { + target = new MapPropertySource(PROPERTY_SOURCE_NAME, map); + } + if (!propertySources.contains(PROPERTY_SOURCE_NAME)) { + propertySources.addFirst(target); // first -> overrides existing + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 15f1fc9..6487baa 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1 +1,3 @@ -org.springframework.boot.env.EnvironmentPostProcessor=org.openeo.spring.security.GlobalSecurityConfig +org.springframework.boot.env.EnvironmentPostProcessor=\ + org.openeo.spring.security.GlobalSecurityConfig,\ + org.openeo.spring.keycloak.KeycloakAutoconfigPostProcessor diff --git a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java index 858d586..1f0245d 100644 --- a/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -44,7 +44,6 @@ * @see ActiveProfiles */ @WebMvcTest -//@ActiveProfiles("test") // -> src/test/resources/application-$PROFILE.properties public abstract class TestBasicAuthentication { @Autowired diff --git a/src/test/resources/jwt.properties b/src/test/resources/jwt.properties new file mode 100644 index 0000000..ab43a74 --- /dev/null +++ b/src/test/resources/jwt.properties @@ -0,0 +1,6 @@ +# Basic JWT tokens +jwt.secret=cf6d7564cf06725c99cff8b8722f58b8f33aeb7b9df22ad7c0e5e7b896fd0411edbe3de7ca76939db0840fd8cf4640d256c27302f12d5c20804d2b952da97685 +jwt.issuer=ACME Srl +jwt.type=JWT +jwt.audience=openEO +jwt.exp-minutes=60 \ No newline at end of file diff --git a/src/test/resources/keycloak-test.properties b/src/test/resources/keycloak.properties.in similarity index 100% rename from src/test/resources/keycloak-test.properties rename to src/test/resources/keycloak.properties.in From 4106c8035f8f1697de11b74dc6ee38e84cc4df19 Mon Sep 17 00:00:00 2001 From: Piero Campalani Date: Thu, 31 Aug 2023 10:03:45 +0200 Subject: [PATCH 27/27] Update README.md --- README.md | 65 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index df87bdf..a081e89 100644 --- a/README.md +++ b/README.md @@ -18,31 +18,33 @@ https://localhost:8443/ ## Configuration setup hints -This back-end is only functional if an application.properties file is added to ```/src/main/resources```. +This back-end is only functional if an `application.properties` file is added to */src/main/resources*. This file should contain at least the following: ``` -springfox.documentation.swagger.v2.path=/api-docs +# authentication +spring.security.enable-basic={true,false} +spring.security.enable-keycloak={true,false} spring.jackson.date-format=org.openeo.spring.RFC3339DateFormat spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false spring.h2.console.enabled=true spring.h2.console.settings.web-allow-others=true -spring.autoconfigure.exclude = org.keycloak.adapters.springboot.KeycloakAutoConfiguration -spring.datasource.jdbc=jdbc:h2:/path/to/openeo.db;DB_CLOSE_DELAY=-1 -spring.datasource.username=my_username -spring.datasource.initialization-mode -spring.security.filter.order=5 server.tomcat.port=8081 server.port=8443 +spring.datasource.jdbc=jdbc:h2:/path/to/openeo.db;DB_CLOSE_DELAY=-1 +spring.datasource.username=my_username +spring.datasource.initialization-mode + server.ssl.key-store-type=PKCS12 server.ssl.key-store=classpath:my_keystore.p12 server.ssl.key-store-password=my_keystore_password server.ssl.key-alias=my_alias security.require-ssl=true +spring.security.filter.order=5 org.openeo.endpoint=https://my_openeo.url org.openeo.public.endpoint=https://my_openeo_public.url @@ -76,31 +78,28 @@ org.openeo.udf.python.endpoint=http://my_openeo_python_udf_service.url org.openeo.udf.r.endpoint=http://my_openeo_R_udf_service.url org.openeo.udf.dir=/my/udf/working/directory/ org.openeo.udf.importscript=/my/udf/import/script/import_udf.sh + +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration ``` -Further files needed are for connection with keycloak: `keycloak.json` +Further files needed are for authentication management through an external Keycloak server. +The `keycloak.properties`: ``` -{ - "realm": "my_realm", - "auth-server-url": "https://my_keycloak.url/auth", - "ssl-required": "external", - "resource": "my_client_id", - "verify-token-audience": false, - "credentials": { - "secret": "my_secret" - }, - "use-resource-role-mappings": true, - "confidential-port": 0, - "policy-enforcer": { - "enforcement-mode" : "PERMISSIVE", - "claim-information-point": { - "claims": { - "claim-from-relativePath": "{request.relativePath}" - } - } - } -} +spring.security.oauth2.client.registration.keycloak.client-id=openEO +spring.security.oauth2.client.registration.keycloak.client-secret={{secret}} +spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.keycloak.scope=openid + +# OpenID Connect (OIDC) +spring.security.oauth2.client.provider.keycloak.issuer-uri=https://my_keycloak.url/auth/realms/my_realm +spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username + +# OAUTH2-JWT token +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://my_keycloak.url/auth/realms/my_realm + +# openEO credentials +org.openeo.oidc.providers.list=classpath:oidc_providers.json ``` and for the support of default client id configuration: `oidc_providers.json` @@ -141,6 +140,16 @@ and for the support of default client id configuration: `oidc_providers.json` } ``` +For internal basic authentication instead, a bearer token issuer configuration file shall also be created: ``jwt.properties`` + +``` +jwt.secret={{secret-hash}} +jwt.issuer=ACME Srl +jwt.type=JWT +jwt.audience=openEO +jwt.exp-minutes={{N}} +``` + ## Logging All logging can be controlled through **log4j2**.