diff --git a/README.md b/README.md index ff782f3..52b9ea8 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,26 @@ 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 @@ -95,31 +96,28 @@ org.openeo.udf.candela.endpoint=http://my_openeo_candela_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` @@ -160,6 +158,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**. diff --git a/pom.xml b/pom.xml index 0ecd013..2091249 100644 --- a/pom.xml +++ b/pom.xml @@ -1,36 +1,48 @@ + 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.6.1 + + + 11 ${java.version} ${java.version} 3.0.0 - 1.4.6 - /usr/share/java/gdal.jar - 3.1.0 + 1.5.13 + + 3.4.0 5.2.0 2.17.0 2.8.0 - 20200518 + 20230618 12.0.4 9.0.37 0.2.1 - 5.5.1 - 2.7.4 + 0.11.5 false - - org.springframework.boot - spring-boot-starter-parent - 2.5.1 - + src/main/java @@ -75,20 +87,11 @@ ${project.build.directory} - + + org.springframework.boot spring-boot-starter-web @@ -115,6 +118,41 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.vaadin.external.google + android-json + + + + junit + junit + + + + + org.springframework.security + spring-security-test + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.keycloak keycloak-spring-security-adapter @@ -125,21 +163,40 @@ 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.boot + spring-boot-starter-oauth2-client + - org.springframework.security - spring-security-config - ${spring.security.version} - + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + 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 @@ -148,8 +205,10 @@ io.springfox springfox-boot-starter - 3.0.0 + ${springfox-version} + + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -190,12 +249,6 @@ json ${json.version} - - org.springframework.boot - spring-boot-devtools - runtime - true - org.hibernate hibernate-core @@ -219,18 +272,8 @@ log4j2-ecs-layout 1.2.0 - - org.springframework.boot - spring-boot-starter-test - ${spring.test.version} - test - - + @@ -242,6 +285,7 @@ + unidata-all @@ -254,4 +298,5 @@ http://oss.jfrog.org/artifactory/oss-snapshot-local/ + \ No newline at end of file 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/FaviconConfig.java b/src/main/java/org/openeo/spring/FaviconConfig.java new file mode 100644 index 0000000..f20b518 --- /dev/null +++ b/src/main/java/org/openeo/spring/FaviconConfig.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 FaviconConfig { + + @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/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 a55b56f..79deb84 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,10 +11,13 @@ 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; 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 { @@ -49,16 +53,23 @@ public int getExitCode() { } @Bean - public FilterRegistrationBean apifilter() - { + public FilterRegistrationBean openeoApiFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ApiFilter()); registrationBean.addUrlPatterns("/*"); 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() { return new WebMvcConfigurer() {}; 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 13bab62..0000000 --- a/src/main/java/org/openeo/spring/SecurityConfig.java +++ /dev/null @@ -1,240 +0,0 @@ -package org.openeo.spring; - -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; -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.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 -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/ApiUtil.java b/src/main/java/org/openeo/spring/api/ApiUtil.java index 377968d..17e05da 100644 --- a/src/main/java/org/openeo/spring/api/ApiUtil.java +++ b/src/main/java/org/openeo/spring/api/ApiUtil.java @@ -4,17 +4,56 @@ 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); 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); } } + + /** + * 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(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/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/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 ffd7fcd..1d4cd28 100644 --- a/src/main/java/org/openeo/spring/api/CredentialsApiController.java +++ b/src/main/java/org/openeo/spring/api/CredentialsApiController.java @@ -5,12 +5,18 @@ 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; 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; +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; @@ -21,20 +27,28 @@ @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 { - - private final NativeWebRequest request; - - private final Logger log = LogManager.getLogger(CredentialsApiController.class); + // TODO do we need it still? @Value("${org.openeo.oidc.providers.list}") private Resource oidcProvidersFile; - @org.springframework.beans.factory.annotation.Autowired + @Value("${spring.security.enable-basic}") + boolean enableBasicAuth; + + @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) { this.request = request; } - + @Override public Optional getRequest() { return Optional.ofNullable(request); @@ -43,12 +57,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); + if (providers.getProviders().isEmpty()) { + throw new InternalError("Providers list should not me empty."); + } + resp = ResponseEntity.ok(providers); + } else { + log.debug("OIDC authentication is disabled."); + 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()); StringBuilder builder = new StringBuilder(); @@ -56,13 +81,49 @@ 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!"); - return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); + resp = ApiUtil.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, + "The list of oidc providers is currently not available."); } - return new ResponseEntity(providers, HttpStatus.OK); + return resp; } + @GetMapping(value = "/credentials/basic", produces = { "application/json" }) + @Override + // FIXME handle errors elsewhere and keep HTTPBasicAccessToken response type? + public ResponseEntity authenticateBasic() { + ResponseEntity resp; + + if (enableBasicAuth) { +// 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."); + } + } else { + resp = ApiUtil.errorResponse(HttpStatus.NOT_IMPLEMENTED, + "Basic authentication mechanism not supported by the server."); + } + + return resp; + + /**TODO**/ + // 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/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/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 7b56d2b..793e261 100644 --- a/src/main/java/org/openeo/spring/api/JobsApiController.java +++ b/src/main/java/org/openeo/spring/api/JobsApiController.java @@ -1,6 +1,7 @@ package org.openeo.spring.api; -import java.io.BufferedInputStream; +import static org.openeo.spring.keycloak.legacy.KeycloakSecurityConfigAdapter.EURAC_ROLE; + import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; @@ -62,9 +63,12 @@ import org.json.JSONObject; import org.keycloak.representations.AccessToken; import org.openapitools.jackson.nullable.JsonNullable; +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; +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; @@ -104,8 +108,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:}") @@ -165,8 +167,11 @@ public class JobsApiController implements JobsApi { @Autowired private JobScheduler jobScheduler; - @Autowired + @Autowired(required = false) private AuthzService authzService; + + @Autowired(required = false) + private ITokenService tokenService; @Autowired private ResultApiController resultApiController; @@ -228,7 +233,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."), @@ -238,13 +244,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); - 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); @@ -293,14 +301,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); @@ -316,7 +320,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); } @@ -336,27 +340,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; @@ -418,7 +416,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)"), @@ -543,7 +542,8 @@ public boolean verify(String hostname, SSLSession session) { error.setCode("500"); error.setMessage("An error when accessing logs from elastic stac: " + errorMessage.toString()); return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR); - }else { + + } else { ByteArrayOutputStream result = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; for (int length; (length = conn.getInputStream().read(buffer)) != -1; ) { @@ -624,27 +624,15 @@ public boolean verify(String hostname, SSLSession session) { }); } } else if (responseCode > 399 && responseCode < 600) { - // TODO: rows below must become a new method/function - // TODO: evaluate if implement or not this 'else if' - /*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, + String.format("An error when accessing logs from elastic stac: %s", errorMessage)); + log.error(response.getBody()); + return response; } - conn.disconnect(); - - }catch(Exception e) { - + } catch(Exception e) { // TODO: rows below must become a new method/function log.error("An error when accessing logs from elastic stac: " + e.getMessage()); StringBuilder builder = new StringBuilder(e.getMessage()); @@ -652,14 +640,12 @@ public boolean verify(String hostname, SSLSession session) { 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); - } /** @@ -689,7 +675,8 @@ public boolean verify(String hostname, SSLSession session) { * [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)"), @@ -701,33 +688,33 @@ 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."); - authzService.deleteProtectedResource(job); - log.debug("The job " + jobId + " was successfully deleted from Keycloak."); + log.debug("The job {} was successfully deleted.", 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 { - 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; } - } /** @@ -756,7 +743,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)"), @@ -772,11 +760,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; } } @@ -814,7 +802,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."), @@ -883,7 +872,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)"), @@ -892,17 +882,19 @@ 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 = new AccessToken(); - try { - token = TokenUtil.getAccessToken(principal); - } catch (Exception e) { + String username = principal.getName(); + AccessToken token = TokenUtil.getAccessToken(principal, tokenService); + + if (null != token) { + username = token.getName(); + } else { Error error = new Error(); error.setCode("401"); error.setMessage("No acces token found, please authenticate."); log.error(error); return new ResponseEntity(error, HttpStatus.UNAUTHORIZED); } - String username = token.getPreferredUsername(); + BatchJobs batchJobs = new BatchJobs(); for (Job job : jobDAO.findWithOwner(username)) { @@ -972,7 +964,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."), @@ -988,12 +981,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 = { @@ -1028,11 +1019,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"); @@ -1041,11 +1031,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; } } @@ -1084,7 +1073,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."), @@ -1097,28 +1087,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()); @@ -1127,14 +1114,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; } - } /** @@ -1171,7 +1156,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."), @@ -1184,10 +1170,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) { @@ -1206,6 +1192,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(); @@ -1245,7 +1239,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."), @@ -1260,11 +1255,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..74f85d8 100644 --- a/src/main/java/org/openeo/spring/api/MeApiController.java +++ b/src/main/java/org/openeo/spring/api/MeApiController.java @@ -5,12 +5,18 @@ 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.bearer.TokenUtil; 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 +30,12 @@ @Controller @RequestMapping("${openapi.openEO.base-path:}") public class MeApiController implements MeApi { + + @Autowired(required = false) + UserDetailsService udService; + + @Autowired(required = false) + ITokenService tokenService; private final NativeWebRequest request; @@ -70,29 +82,54 @@ 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)"), @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()); - }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); + + UserData userData = new UserData(); + + if (principal != null) { + String username = principal.getName(); + String userId = username; + // 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(); + userId = accessToken.getId(); + } + + userData.setName(username); + userData.setUserId(userId); + + 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); // ? + log.debug("registered user {}/{}", userData.getUserId(), userData.getName()); + } else { + 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 b3542d6..b044b28 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.keycloak.legacy.KeycloakSecurityConfigAdapter.EURAC_ROLE; + import java.io.BufferedReader; import java.io.File; import java.io.IOException; @@ -31,6 +33,8 @@ 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.bearer.TokenUtil; import org.openeo.spring.components.CollectionMap; import org.openeo.spring.components.CollectionsMap; import org.openeo.spring.components.JobScheduler; @@ -63,8 +67,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 @@ -79,6 +81,9 @@ public class ResultApiController implements ResultApi { @Autowired private CollectionMap collectionMap; + + @Autowired(required = false) + private ITokenService tokenService; private final NativeWebRequest request; @@ -98,15 +103,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; } @@ -116,7 +123,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"), @@ -124,25 +132,27 @@ 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.getAccessToken(principal); + 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); Iterator keys = processGraphJSON.keys(); @@ -151,7 +161,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; } } @@ -163,11 +173,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(); @@ -305,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/api/TokenUtil.java b/src/main/java/org/openeo/spring/api/TokenUtil.java deleted file mode 100644 index 10c017f..0000000 --- a/src/main/java/org/openeo/spring/api/TokenUtil.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.openeo.spring.api; - -import java.security.Principal; - -import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; -import org.keycloak.representations.AccessToken; - -public class TokenUtil { - - public static AccessToken getAccessToken(Principal principal) { - KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) principal; - return keycloakAuthenticationToken.getAccount().getKeycloakSecurityContext().getToken(); - } -} 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/bearer/ITokenService.java b/src/main/java/org/openeo/spring/bearer/ITokenService.java new file mode 100644 index 0000000..a751fda --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/ITokenService.java @@ -0,0 +1,56 @@ +package org.openeo.spring.bearer; + +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. + * + * @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, 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)} + */ + 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/JWTAuthenticationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java new file mode 100644 index 0000000..d676eab --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthenticationFilter.java @@ -0,0 +1,115 @@ +package org.openeo.spring.bearer; + +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.boot.autoconfigure.condition.ConditionalOnProperty; +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; +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 +@ConditionalOnProperty(prefix="spring.security", value="enable-basic") +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); + + /** + * 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, + 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 valid \"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/bearer/JWTAuthorizationFilter.java b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java new file mode 100644 index 0000000..5f01d17 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/JWTAuthorizationFilter.java @@ -0,0 +1,125 @@ +package org.openeo.spring.bearer; + +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +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. + * + * 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 + 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); + + /** + * 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) + throws ServletException, IOException { + + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + 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 { + try { + BearerTokenAuthenticationToken auth = parseToken(authorizationHeader); + if (null != auth) { + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + LOGGER.error("Invalid token received: authentication unsuccessful."); + } + } catch (JwtException ex) { + throw ex; + } + } + } + } 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! + 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 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 BearerTokenAuthenticationToken parseToken(String authorizationHeader) + throws ClaimJwtException { + String prefixedToken = authorizationHeader.replace(BEARER_HEADER_PRE, ""); + String jwtToken = prefixedToken.replaceAll(TOKEN_PREFIX, ""); + + BearerTokenAuthenticationToken auth = tokenService.parseToken(jwtToken); + + if (null != auth) { + List authorities = new ArrayList<>(); + // if (userPrincipal.isAdmin()) { + // authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // FIXME String + // } + // 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 new file mode 100644 index 0000000..fb9396a --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/JWTTokenService.java @@ -0,0 +1,170 @@ +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.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.context.annotation.PropertySource; +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; + +/** + * JWT token management service. + */ +//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}") + 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; + + @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(expUnits, uom); + 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()) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(expirationDate) + .setSubject(user.getUsername()) + .signWith(key, SA) + .setIssuer(jwtIssuer) + .setAudience(jwtAudience) + .setHeaderParam("typ", jwtType) + .compact(); + + return compactTokenString; + } + + @Override + public BearerTokenAuthenticationToken parseToken(String token) throws JwtException { + + byte[] secretBytes = jwtSecret.getBytes(); + BearerTokenAuthenticationToken auth = null; + + 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); + if (null != 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) { + LOGGER.error("Illegal or expired token received.", ex); + throw ex; + } + + 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 + 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/bearer/PrefixedBearerTokenResolver.java b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java new file mode 100644 index 0000000..96b5be7 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/PrefixedBearerTokenResolver.java @@ -0,0 +1,69 @@ +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: '%s'. Token: %20s...", + prefixStart, prefixedToken)); + } + pureToken = prefixedToken.substring(prefixedToken.lastIndexOf('/') + 1); + } + + return pureToken; + } +} diff --git a/src/main/java/org/openeo/spring/bearer/TokenUtil.java b/src/main/java/org/openeo/spring/bearer/TokenUtil.java new file mode 100644 index 0000000..18cb9d0 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/TokenUtil.java @@ -0,0 +1,134 @@ +package org.openeo.spring.bearer; + +import java.security.Principal; + +import org.keycloak.representations.AccessToken; +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. + */ +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 JwtAuthenticationToken) { + token = TokenUtil.getKCAccessToken((JwtAuthenticationToken) 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) + */ + static AccessToken getKCAccessToken(JwtAuthenticationToken principal) { + if (null == principal) { + return null; + } + + 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; + } + + /** + * 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) + */ + static AccessToken getBAAccessToken(UsernamePasswordAuthenticationToken principal, + ITokenService tokenService) { + if (null == principal) { + return null; + } + if (null == tokenService) { + throw new InternalError("ITokenService required to fetch Bearer token."); + } + + AccessToken token = null; + 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; + } + + /** + * 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/package-info.java b/src/main/java/org/openeo/spring/bearer/package-info.java new file mode 100644 index 0000000..afdce87 --- /dev/null +++ b/src/main/java/org/openeo/spring/bearer/package-info.java @@ -0,0 +1,3 @@ +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/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/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/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 new file mode 100644 index 0000000..5e4b3a3 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/ExceptionTranslator.java @@ -0,0 +1,56 @@ +package org.openeo.spring.components; + +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.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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 +@Order(Ordered.HIGHEST_PRECEDENCE) +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.getMessage()); + + 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.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 new file mode 100644 index 0000000..717aff1 --- /dev/null +++ b/src/main/java/org/openeo/spring/components/FilterChainExceptionHandler.java @@ -0,0 +1,57 @@ +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.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; + +/** + * 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 + * is safely placed inside a try-catch block. + */ +@Component +public class FilterChainExceptionHandler extends OncePerRequestFilter { + + private final Logger LOGGER = LogManager.getLogger(FilterChainExceptionHandler.class); + + @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) + throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } 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/JobScheduler.java b/src/main/java/org/openeo/spring/components/JobScheduler.java index 0f60c7b..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; @@ -74,7 +71,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/components/OnOffDaoAuthenticationProvider.java b/src/main/java/org/openeo/spring/components/OnOffDaoAuthenticationProvider.java new file mode 100644 index 0000000..3f4adb4 --- /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.security.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/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/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/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java new file mode 100644 index 0000000..19815a9 --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/KeycloakLogoutHandler.java @@ -0,0 +1,52 @@ +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("Successfully 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/api/AuthzService.java b/src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java similarity index 94% rename from src/main/java/org/openeo/spring/api/AuthzService.java rename to src/main/java/org/openeo/spring/keycloak/legacy/AuthzService.java index 2d8f291..99c52d8 100644 --- a/src/main/java/org/openeo/spring/api/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/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..e9fc714 --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakConfiguration.java @@ -0,0 +1,63 @@ +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. + * + * @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 + */ +@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/legacy/KeycloakSecurityConfigAdapter.java b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakSecurityConfigAdapter.java new file mode 100644 index 0000000..08404e5 --- /dev/null +++ b/src/main/java/org/openeo/spring/keycloak/legacy/KeycloakSecurityConfigAdapter.java @@ -0,0 +1,187 @@ +package org.openeo.spring.keycloak.legacy; + +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.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/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/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..37bd464 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; @@ -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. @@ -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/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/security/BasicSecurityConfig.java b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java new file mode 100644 index 0000000..1b68cd6 --- /dev/null +++ b/src/main/java/org/openeo/spring/security/BasicSecurityConfig.java @@ -0,0 +1,209 @@ +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; + +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; +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; +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.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.logout.LogoutFilter; +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; + +@Configuration +@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; + + @Autowired + FilterChainExceptionHandler filterChainExHandler; + + @Autowired + BAuthEntrypoint authEntrypoint; + + /** Used to define a {@link Profile}. */ + public static final String PROFILE_ID = "BASIC_AUTH"; + + /** 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 baLoginFilterChain(HttpSecurity http) throws Exception { + http + .antMatcher(BASIC_AUTH_API_RESOURCE) + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated()) + .httpBasic() + .authenticationEntryPoint(authEntrypoint) + .and() + // disable session management (JSESSIONID cookies -> security risks) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + // add Bearer/JWT tokens management + .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(); + } + + /** + * Requires authenticated user on all resources. + * + * NOTE: resources to be ignored by the authorization service are + * configured in {@link #webSecurityCustomizer()}. + */ + @Bean + public SecurityFilterChain baSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .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) + .and() + .addFilterBefore(filterChainExHandler, LogoutFilter.class) + .addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class); + + LOGGER.info("Basic authorization security chain set."); + + return http.build(); + } + + /** + * Sets the resources that do not required security rules. + */ + @Bean + public WebSecurityCustomizer baWebSecurityCustomizer() { + 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 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); +// } + + /** + * 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(); +// } + + /** + * 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/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 new file mode 100644 index 0000000..66a68ea --- /dev/null +++ b/src/main/java/org/openeo/spring/security/GlobalSecurityConfig.java @@ -0,0 +1,97 @@ +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; +import org.springframework.context.annotation.Configuration; +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, + prePostEnabled = true) // -> @PreAuthorize annotations on controller methods +public class GlobalSecurityConfig implements EnvironmentPostProcessor { + + @Value("${spring.security.enable-basic}") + boolean enableBasicAuth; + + @Value("${spring.security.enable-keycloak}") + boolean enableKeycloakAuth; + + /** API resources that do not require authentication. */ + public static final String[] NOAUTH_API_RESOURCES = new String[] { + "/", + "/conformance", + "/collections/**", + "/download/**", + "/processes/**", + "/favicon.ico", + "/file_formats", + "/.well-known/openeo"}; + + 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 + * 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); + } + + 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 allowed."); + } + } + + /** + * 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/security/KeycloakSecurityConfig.java b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java new file mode 100644 index 0000000..2c041f6 --- /dev/null +++ b/src/main/java/org/openeo/spring/security/KeycloakSecurityConfig.java @@ -0,0 +1,137 @@ +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; + +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 + KeycloakLogoutHandler keycloakLogoutHandler; + + @Autowired + FilterChainExceptionHandler filterChainExHandler; + + @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 kcLoginFilterChain(HttpSecurity http) throws Exception { + http + .antMatcher(OIDC_AUTH_API_RESOURCE) + .oauth2Login(/*withDefaults()*/) + .and() + // 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(); + } + + @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 authentication security chain set."); + + return http.build(); + } + + /** + * Sets the resources that do not required security rules. + */ + @Bean + public WebSecurityCustomizer kcWebSecurityCustomizer() { + return (web) -> web + .ignoring() + .antMatchers(NOAUTH_API_RESOURCES) + .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. + * + * @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/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/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..6487baa --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + org.openeo.spring.security.GlobalSecurityConfig,\ + org.openeo.spring.keycloak.KeycloakAutoconfigPostProcessor 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/spring-ba-security.xml b/src/main/resources/spring-ba-security.xml new file mode 100644 index 0000000..2fb094b --- /dev/null +++ b/src/main/resources/spring-ba-security.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..146699b Binary files /dev/null and b/src/main/resources/static/favicon.ico differ 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..9e941b1 --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestFavicon.java @@ -0,0 +1,26 @@ +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@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"))); + } +} 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..81b7c61 --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestMe.java @@ -0,0 +1,69 @@ +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.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.web.servlet.MockMvc; + +@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..257192f --- /dev/null +++ b/src/test/java/org/openeo/spring/api/TestMe_OIDC.java @@ -0,0 +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/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/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..ff85b73 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,35 +28,25 @@ 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.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. */ // 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 new file mode 100644 index 0000000..1f0245d --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication.java @@ -0,0 +1,214 @@ +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 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; +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.ActiveProfiles; +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 + */ +@WebMvcTest +public abstract class TestBasicAuthentication { + + @Autowired + MockMvc mvc; + + @Autowired + WebApplicationContext context; + + @Autowired + JWTTokenService tokenService; + + @Test + @WithMockUser(username = "satan", password = "petrodragonic") + public void get_okBasic_shouldSucceedWith200() throws Exception { + MvcResult mvcResult = mvc.perform(get("/credentials/basic") + .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"); + } + + @ParameterizedTest + @MethodSource("providePublicAPIResources") + public void get_publicResourceNoAuth_shouldSucceedWith200(String resource) throws Exception { + mvc.perform(get(resource) + ).andExpect( + status().isOk()); + } + + @ParameterizedTest + @ValueSource(strings = {"/jobs", "/me"}) + @WithMockUser(value = "satan") + 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(res) + .header(HttpHeaders.AUTHORIZATION, + String.format("Bearer basic//%s", token)) + ).andExpect( + status().isOk()); + } + + @Test + public void get_protectedResourcewWithWrongToken_shouldReturn403() throws Exception { + mvc.perform(get("/jobs") + .header(HttpHeaders.AUTHORIZATION, "Bearer basic//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 get_wrongTokenPrefix_shouldReturn403() throws Exception { + mvc.perform(get("/jobs") + .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 + @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("/jobs") + .header(HttpHeaders.AUTHORIZATION, + String.format("Bearer basic//%s", token)) + ).andExpectAll( + status().is(403), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() + ); + } + + @Test + public void get_noAuth_shouldReturnAuthRequired401() throws Exception { + mvc.perform(get("/credentials/basic") + ).andExpectAll( + status().is(401), + header().exists(HttpHeaders.WWW_AUTHENTICATE) + // Hard to wire in custom ErrorAttributes in MockMvc TODO +// jsonPath("$.id").hasJsonPath(), +// jsonPath("$.code").exists(), +// jsonPath("$.message").exists(), +// jsonPath("$.links").hasJsonPath() + ); + } + + /** @see BasicAuthenticationFilter */ + @Test + @WithMockUser(username = "satan", password = "petrodragonic") + public void get_wrongAuth_shouldReturn401() throws Exception { + mvc.perform(get("/credentials/basic") + .header(HttpHeaders.AUTHORIZATION, "Basic _InfestTheRatsNest_=") + ).andExpectAll( + status().is(401) + // 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() + ); + } + + /** + * 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 = context.getBean(CredentialsApiController.class); + ReflectionTestUtils.setField(controller, "enableBasicAuth", false); + + mvc.perform(get("/credentials/basic") + ).andExpectAll( + status().is(501), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath() + ); + } + + 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/TestBasicAuthentication_OIDCDisabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java new file mode 100644 index 0000000..dadfe0e --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCDisabled.java @@ -0,0 +1,27 @@ +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; +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") + ).andExpectAll( + 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/TestBasicAuthentication_OIDCEnabled.java b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java new file mode 100644 index 0000000..a1286c9 --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestBasicAuthentication_OIDCEnabled.java @@ -0,0 +1,11 @@ +package org.openeo.spring.security; + +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") +@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 new file mode 100644 index 0000000..e5c6239 --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestCorsConf.java @@ -0,0 +1,65 @@ +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.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.web.servlet.MockMvc; + +@WebMvcTest +@ActiveProfiles("ba") +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_shouldReturnCorsHeaders(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("/")); + } +} 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..7f3cbf9 --- /dev/null +++ b/src/test/java/org/openeo/spring/security/TestOIDCAuthentication.java @@ -0,0 +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 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.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") +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("/jobs") + ).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("/jobs") + .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("/jobs") + .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), + jsonPath("$.id").hasJsonPath(), + jsonPath("$.code").exists(), + jsonPath("$.message").exists(), + jsonPath("$.links").hasJsonPath()); + } + + private static Stream providePublicAPIResources() { + return Stream.of(GlobalSecurityConfig.NOAUTH_API_RESOURCES) + .filter(res -> !res.contains("*")); + } +} 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..04223f6 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,74 @@ + +# authentication +spring.security.enable-basic=true +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 ? +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 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.properties.in b/src/test/resources/keycloak.properties.in new file mode 100644 index 0000000..50734e7 --- /dev/null +++ b/src/test/resources/keycloak.properties.in @@ -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={{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={{ISSURE-URI}} +spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username + +# OAUTH2-JWT token +spring.security.oauth2.resourceserver.jwt.issuer-uri={{TOKEN-ISSUER-URI}} + +# 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/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 @@ + + + + + + + + + + + + +