Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OIDC Auth -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>


<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
Expand Down Expand Up @@ -192,6 +203,17 @@
<artifactId>jgrapht-core</artifactId>
<version>${jgrapht-core.version}</version>
</dependency>
<!-- User and Roles -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

</dependencies>

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/de/samply/converter/ConverterManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public ConverterManager(
@Autowired ApplicationContext applicationContext,
@Value(ExporterConst.CONVERTER_XML_APPLICATION_CONTEXT_PATH_SV) String converterXmlApplicationContextPath
) {
System.out.println("Context path: " + converterXmlApplicationContextPath);
System.out.println("Converter XML application context: " + applicationContext);
List<Converter> converters = new ArrayList<>();
converters.addAll(applicationContext.getBeansOfType(Converter.class).values());
converters.addAll(fetchConvertersFromApplicationContextInFile(converterXmlApplicationContextPath,
Expand Down
43 changes: 40 additions & 3 deletions src/main/java/de/samply/exporter/ExporterConst.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,32 @@

public class ExporterConst {

// Spring Values (SV)
// public final static String HEAD_SV = "${";
// public final static String BOTTOM_SV = "}";

public final static boolean LOG_FHIR_VALIDATION_DEFAULT = false;

// HTTP Headers
public final static String API_KEY_HEADER = "x-api-key";
public final static String API_KEY_HEADER = "Authorization";
public static final String API_KEY_PREFIX = "ApiKey";
public final static String SECURITY_ENABLED = "SECURITY_ENABLED";
public final static String JWKS_URI_PROPERTY = "spring.security.oauth2.client.provider.oidc.jwk-set-uri";


// Keycloak paths
public final static String FETCH_USER_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/users?email={email}";
public final static String FETCH_GROUP_ID_KEYCLOAK_PATH = "/admin/realms/{realm}/groups?search={group}";
public final static String CHANGE_USER_GROUP_KEYCLOAK_PATH = "/admin/realms/{realm}/users/{user-id}/groups/{group-id}";
public final static String FETCH_TOKEN_KEYCLOAK_PATH = "/realms/{realm}/protocol/openid-connect/token";

// Keycloak parameters
public final static String CLIENT_ID_KEYCLOAK_PARAM = "client_id";
public final static String CLIENT_SECRET_KEYCLOAK_PARAM = "client_secret";
public final static String GRANT_TYPE_KEYCLOAK_PARAM = "grant_type";
public final static String CLIENT_CREDENTIALS_KEYCLOAK_CONST = "client_credentials";
public final static String ACCES_TOKEN_KEYCLOAK_CONST = "access_token";
public final static String ID_KEYCLOAK_CONST = "id";

// Token variables
public final static String TOKEN_HEAD = "${";
Expand Down Expand Up @@ -173,10 +195,14 @@ public class ExporterConst {
public static final String TEMPLATE_GRAPH = "/template-graph";
public static final String RUNNING_QUERIES = "/running-queries";
public static final String API_DOCS = "/api-docs";
public static final String ERROR = "/error";

public static final String[] REST_PATHS_WITH_API_KEY = new String[]{CREATE_QUERY, FETCH_QUERIES,
public static final String[] REST_PATHS_WITH_AUTH = new String[]{CREATE_QUERY, FETCH_QUERIES,
FETCH_QUERY_EXECUTIONS, FETCH_QUERY_EXECUTION_ERRORS, REQUEST, ACTIVE_INQUIRIES, ARCHIVED_INQUIRIES,
ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, STATUS, LOGS, RUNNING_QUERIES, UPDATE_QUERY, API_DOCS};
ERROR_INQUIRIES, INQUIRY, ARCHIVE_QUERY, LOGS, RUNNING_QUERIES, UPDATE_QUERY};
public static final String[] REST_PATHS_NO_AUTH=new String[]{INFO, API_DOCS,
STATUS, ERROR};
public static final String[] REST_PATHS_BROWSER_AUTH=new String[]{RESPONSE, "/oauth2/**", "/login/**"};
// TODO: RESPONSE ??? Only with UUID enough?

// REST Headers
Expand Down Expand Up @@ -254,4 +280,15 @@ public class ExporterConst {
public final static String QUERY_CONTEXT_EQUAL = "=";
public final static String FHIR_SEARCH_PATH_ROOT = "ROOT";

// Filter
public final static String SECURITY_ENABLED_SV = HEAD_SV + SECURITY_ENABLED + ":true" + BOTTOM_SV;
public final static String JWKS_URI_PROPERTY_SV = HEAD_SV + JWKS_URI_PROPERTY + BOTTOM_SV;

// UUser and Roles
public final static String TEST_EMAIL = "test@project-manager.com";
public final static String TEST_BRIDGEHEAD = "bridgehead-test";
public final static String JWT_GROUPS_CLAIM = "JWT_GROUPS_CLAIM";
public final static String JWT_GROUPS_CLAIM_SV = HEAD_SV + JWT_GROUPS_CLAIM + ":groups" + BOTTOM_SV;


}
61 changes: 31 additions & 30 deletions src/main/java/de/samply/security/ApiKeyAuthenticationManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,35 @@
@Component
public class ApiKeyAuthenticationManager implements AuthenticationManager {

/*
* Security: This class provides API key support to REST for connecting different server
*/

@Value(ExporterConst.EXPORTER_API_KEY_SV)
private String apiKey;

/**
* Authenticates request based on an API key.
*
* @param authentication REST API Client authentication.
* @return Authentication with setAuthenticated set to true or false.
* @throws AuthenticationException Authentication exception.
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {

String apiKey = (String) authentication.getPrincipal();

if (!ObjectUtils.isEmpty(this.apiKey) && (ObjectUtils.isEmpty(apiKey) || !apiKey.equals(
this.apiKey))) {
throw new BadCredentialsException("Incorrect API Key");
} else {
authentication.setAuthenticated(true);
/*
* Security: This class provides API key support to REST for connecting different server
*/

@Value(ExporterConst.EXPORTER_API_KEY_SV)
private String apiKey;

/**
* Authenticates request based on an API key.
*
* @param authentication REST API Client authentication.
* @return Authentication with setAuthenticated set to true or false.
* @throws AuthenticationException Authentication exception.
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String apiKey = (String) authentication.getPrincipal();
// new Authorization header with prefix ApiKey
if (apiKey != null && apiKey.startsWith(ExporterConst.API_KEY_PREFIX)) {
apiKey = apiKey.substring(ExporterConst.API_KEY_PREFIX.length()).trim();
} else {
throw new BadCredentialsException("header does not contain expected prefix");
}
if (!ObjectUtils.isEmpty(this.apiKey) && !apiKey.equals(
this.apiKey)) {
throw new BadCredentialsException("Incorrect API Key");
} else {
authentication.setAuthenticated(true);
}
return authentication;
}

return authentication;

}

}
}
18 changes: 18 additions & 0 deletions src/main/java/de/samply/security/ApiKeyRequestMatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package de.samply.security;

import de.samply.exporter.ExporterConst;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;

public class ApiKeyRequestMatcher implements RequestMatcher {
@Override
public boolean matches(HttpServletRequest request) {
String auth = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(auth) && auth.startsWith(ExporterConst.API_KEY_PREFIX)) {
return true;
}
return false;
}
}
95 changes: 63 additions & 32 deletions src/main/java/de/samply/security/ApiKeySecurityConfiguration.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package de.samply.security;


import de.samply.exporter.ExporterConst;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.security.web.util.matcher.OrRequestMatcher;

import java.util.Arrays;

Expand All @@ -30,30 +34,37 @@ public class ApiKeySecurityConfiguration {

private ApiKeyAuthenticationManager apiKeyAuthenticationManager;


/**
* Add API key filter to Spring http security.
*
* @param httpSecurity Spring http security.
* @return Security Filter Chain based on apiKey.
* @throws Exception Exception.
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
@Bean(name = "apiKeyFilterChain")
public SecurityFilterChain apiFilterChain(HttpSecurity httpSecurity) throws Exception {
OrRequestMatcher pathMatcher = new OrRequestMatcher(
Arrays.stream(ExporterConst.REST_PATHS_WITH_AUTH)
.map(AntPathRequestMatcher::new)
.toArray(org.springframework.security.web.util.matcher.RequestMatcher[]::new)
);
AndRequestMatcher apiKeyOnPaths = new AndRequestMatcher(new ApiKeyRequestMatcher(), pathMatcher);

httpSecurity
.securityMatcher(apiKeyOnPaths)
.cors(Customizer.withDefaults())
.securityMatcher(ExporterConst.REST_PATHS_WITH_API_KEY)
.csrf(csrf -> csrf.disable())
.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilter(createApiKeyFilter())
.authorizeHttpRequests(authorize -> {
authorize.requestMatchers(new AntPathRequestMatcher(ExporterConst.API_DOCS)).permitAll();
Arrays.stream(ExporterConst.REST_PATHS_WITH_API_KEY).forEach(path -> authorize.requestMatchers(new AntPathRequestMatcher(path)).authenticated());
authorize.anyRequest().authenticated();
}
);

.addFilterBefore(createApiKeyFilter(), BearerTokenAuthenticationFilter.class)
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(apiKeyAuthEntryPoint())
.accessDeniedHandler(apiKeyAccessDeniedHandler())
)
.anonymous(anon -> anon.disable());
return httpSecurity.build();
}

Expand All @@ -65,27 +76,47 @@ public void setApiKeyAuthenticationManager(

@Bean
public ApiKeyFilter createApiKeyFilter() {

ApiKeyFilter apiKeyFilter = new ApiKeyFilter();
apiKeyFilter.setAuthenticationManager(apiKeyAuthenticationManager);
return apiKeyFilter;

}

@Bean
CorsConfigurationSource corsConfigurationSource(
@Value(ExporterConst.CROSS_ORIGINS_SV) String[] crossOrigins) {
CorsConfiguration configuration = new CorsConfiguration();
//configuration.setAllowedOrigins(fetchCrossOrigins(crossOrigins));
configuration.setAllowedOrigins(Arrays.asList(crossOrigins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT"));
configuration.setAllowedHeaders(
Arrays.asList("Authorization", "Cache-Control", "Content-Type", "Origin",
ExporterConst.API_KEY_HEADER));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
public AuthenticationEntryPoint apiKeyAuthEntryPoint() {
return (request, response, authException) -> {
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "ApiKey realm=\"Exporter\"");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
String body = """
{
"error": "unauthorized",
"error_description": "API key missing or invalid",
"status": 401,
"path": "%s",
"timestamp": "%s"
}
""".formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString());

response.getWriter().write(body);
};
}


@Bean
public AccessDeniedHandler apiKeyAccessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
String body = """
{
"error": "forbidden",
"error_description": "You do not have permission to access this resource",
"status": 403,
"path": "%s",
"timestamp": "%s"
}
""".formatted(request.getRequestURI(), java.time.OffsetDateTime.now().toString());

response.getWriter().write(body);
};
}
}
Loading
Loading