Skip to content

Commit f9df33e

Browse files
authored
feat!: enhance security (#177)
1 parent 736abfa commit f9df33e

File tree

41 files changed

+1369
-515
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1369
-515
lines changed

.ort.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
excludes:
3+
scopes:
4+
- pattern: "annotationProcessor"
5+
reason: "BUILD_DEPENDENCY_OF"
6+
comment: "Packages to process code annotations only."
7+
- pattern: "checkstyle"
8+
reason: "DEV_DEPENDENCY_OF"
9+
comment: "Packages for static code analysis only."
10+
- pattern: "lombok"
11+
reason: "DEV_DEPENDENCY_OF"
12+
comment: "Packages from Project Lombok only."
13+
- pattern: "test.*"
14+
reason: "TEST_DEPENDENCY_OF"
15+
comment: "Packages for testing only."
16+
resolutions:
17+
rule_violations:
18+
- message: "The declared license 'The BSD License' could not be mapped to a valid license or parsed as an SPDX expression. The license was found in package 'Maven:org.antlr:ST4:4.3.4'."
19+
reason: 'CANT_FIX_EXCEPTION'
20+
comment: "The dependency has 'The BSD License' license in github repo"
21+
- message: "No license information is available for dependency 'Maven:org.antlr:ST4:4.3.4'."
22+
reason: 'CANT_FIX_EXCEPTION'
23+
comment: "The dependency has 'The BSD License' license in github repo"
24+
- message: "The dependency 'Maven:com.github.jnr:jnr-posix:3.1.21' is licensed under the ScanCode 'copyleft' categorized license GPL-2.0-only."
25+
reason: 'CANT_FIX_EXCEPTION'
26+
comment: "The dependency has 'tri EPL/GPL/LGPL license' license in github repo"
27+
- message: "The declared license 'GNU Lesser General Public License version 3' could not be mapped to a valid license or parsed as an SPDX expression. The license was found in package 'Maven:com.github.jnr:jffi:1.3.14'."
28+
reason: 'CANT_FIX_EXCEPTION'
29+
comment: "The dependency has 'Apache-2.0 license' license in github repo"

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ dependencies {
6868
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
6969

7070
implementation 'com.azure:azure-identity:1.18.0'
71+
implementation 'com.azure:azure-identity-extensions:1.2.7'
7172

7273
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.3.0'
7374
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.3.0'
@@ -78,6 +79,7 @@ dependencies {
7879
implementation 'org.flywaydb:flyway-core:11.14.0'
7980
implementation 'org.flywaydb:flyway-database-postgresql:11.14.0'
8081
implementation 'org.flywaydb:flyway-sqlserver:11.14.0'
82+
implementation 'com.google.cloud.sql:postgres-socket-factory:1.28.0'
8183

8284
implementation 'io.modelcontextprotocol.sdk:mcp:0.15.0'
8385
implementation 'com.google.cloud.tools:jib-core:0.27.3'
@@ -125,6 +127,7 @@ dependencies {
125127
testImplementation 'io.jsonwebtoken:jjwt:0.9.1'
126128
testImplementation 'org.assertj:assertj-core:3.27.7'
127129
testImplementation 'org.springframework.boot:spring-boot-starter-test'
130+
testImplementation 'org.springframework.security:spring-security-test'
128131
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
129132
testImplementation "org.testcontainers:junit-jupiter:${testcontainers_version}"
130133
testImplementation "org.testcontainers:testcontainers:${testcontainers_version}"

docs/configuration.md

Lines changed: 45 additions & 41 deletions
Large diffs are not rendered by default.

src/main/java/com/epam/aidial/deployment/manager/configuration/datasource/DynamicPasswordHikariDataSource.java

Lines changed: 0 additions & 24 deletions
This file was deleted.
Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
11
package com.epam.aidial.deployment.manager.configuration.datasource;
22

3-
import com.azure.core.credential.TokenCredential;
4-
import com.azure.core.credential.TokenRequestContext;
5-
import com.azure.core.implementation.AccessTokenCache;
3+
import com.zaxxer.hikari.HikariConfig;
4+
import com.zaxxer.hikari.HikariDataSource;
65
import lombok.extern.slf4j.Slf4j;
76
import org.springframework.beans.factory.annotation.Value;
87
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
98
import org.springframework.boot.jdbc.DataSourceBuilder;
109
import org.springframework.context.annotation.Bean;
1110
import org.springframework.context.annotation.Configuration;
1211

13-
import java.util.function.Supplier;
1412
import javax.sql.DataSource;
1513

16-
@Configuration
1714
@Slf4j
15+
@Configuration
1816
@ConditionalOnProperty(name = "datasource.vendor", havingValue = "POSTGRES")
1917
public class PostgresConfiguration {
2018

21-
private static final String AAD_DATABASE_SCOPE = "https://ossrdbms-aad.database.windows.net/.default";
22-
private static final TokenRequestContext TOKEN_REQUEST_CONTEXT = new TokenRequestContext().addScopes(AAD_DATABASE_SCOPE);
23-
2419
@Bean
2520
@ConditionalOnProperty(value = "datasource.auth.type", havingValue = "basic")
2621
public DataSource dataSource(@Value("${postgres.datasource.url}") String url,
@@ -37,19 +32,33 @@ public DataSource dataSource(@Value("${postgres.datasource.url}") String url,
3732

3833
@Bean
3934
@ConditionalOnProperty(value = "datasource.auth.type", havingValue = "azure")
40-
public DataSource managedAzureAuthTypeDataSource(@Value("${postgres.datasource.url}") String url,
41-
@Value("${postgres.datasource.driver-class-name}") String driverClassName,
42-
@Value("${postgres.datasource.username}") String username,
43-
TokenCredential tokenCredential) {
44-
AccessTokenCache accessTokenCache = new AccessTokenCache(tokenCredential);
45-
Supplier<String> passwordProvider = () -> accessTokenCache.getTokenSync(TOKEN_REQUEST_CONTEXT, true).getToken();
46-
47-
DynamicPasswordHikariDataSource dynamicPasswordHikariDataSource = new DynamicPasswordHikariDataSource(passwordProvider);
48-
dynamicPasswordHikariDataSource.setDriverClassName(driverClassName);
49-
dynamicPasswordHikariDataSource.setJdbcUrl(url);
50-
dynamicPasswordHikariDataSource.setUsername(username);
51-
52-
return dynamicPasswordHikariDataSource;
35+
public DataSource azureAuthTypeDataSource(@Value("${postgres.datasource.url}") String url,
36+
@Value("${postgres.datasource.driver-class-name}") String driverClassName,
37+
@Value("${postgres.datasource.username}") String username) {
38+
HikariConfig hikariConfig = new HikariConfig();
39+
40+
hikariConfig.setUsername(username);
41+
hikariConfig.setDriverClassName(driverClassName);
42+
hikariConfig.setJdbcUrl(url);
43+
hikariConfig.addDataSourceProperty("authenticationPluginClassName", "com.azure.identity.extensions.jdbc.postgresql.AzurePostgresqlAuthenticationPlugin");
44+
45+
return new HikariDataSource(hikariConfig);
46+
}
47+
48+
@Bean
49+
@ConditionalOnProperty(value = "datasource.auth.type", havingValue = "gcp")
50+
public DataSource gcpAuthTypeDataSource(@Value("${postgres.datasource.url}") String url,
51+
@Value("${postgres.datasource.driver-class-name}") String driverClassName,
52+
@Value("${postgres.datasource.username}") String username) {
53+
HikariConfig hikariConfig = new HikariConfig();
54+
55+
hikariConfig.setUsername(username);
56+
hikariConfig.setDriverClassName(driverClassName);
57+
hikariConfig.setJdbcUrl(url);
58+
hikariConfig.addDataSourceProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory");
59+
hikariConfig.addDataSourceProperty("enableIamAuth", "true");
60+
61+
return new HikariDataSource(hikariConfig);
5362
}
5463

5564
}

src/main/java/com/epam/aidial/deployment/manager/service/security/SecurityClaimsExtractor.java

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
11
package com.epam.aidial.deployment.manager.service.security;
22

33
import com.epam.aidial.deployment.manager.configuration.logging.LogExecution;
4+
import com.epam.aidial.deployment.manager.web.security.UserSecurityDetails;
45
import lombok.extern.slf4j.Slf4j;
5-
import org.springframework.beans.factory.annotation.Value;
66
import org.springframework.security.core.Authentication;
77
import org.springframework.security.core.context.SecurityContext;
88
import org.springframework.security.core.context.SecurityContextHolder;
9-
import org.springframework.security.oauth2.jwt.Jwt;
10-
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
119
import org.springframework.stereotype.Service;
1210

13-
import java.util.Objects;
14-
1511
@Slf4j
1612
@Service
1713
@LogExecution
1814
public class SecurityClaimsExtractor {
1915

20-
@Value("${config.rest.security.email-claim}")
21-
private String emailClaim;
22-
2316
public String getEmail() {
2417
SecurityContext context = SecurityContextHolder.getContext();
2518
if (context == null || context.getAuthentication() == null) {
@@ -28,13 +21,9 @@ public String getEmail() {
2821
}
2922
Authentication authentication = context.getAuthentication();
3023
log.trace("Authentication: {}", authentication);
31-
if (context.getAuthentication() instanceof JwtAuthenticationToken jwtAuthenticationToken) {
32-
Jwt token = jwtAuthenticationToken.getToken();
33-
log.trace("token claims: {}", token.getClaims());
34-
Object uniqueName = token.getClaims().get(emailClaim);
35-
if (uniqueName != null) {
36-
return Objects.toString(uniqueName);
37-
}
24+
25+
if (authentication.getDetails() instanceof UserSecurityDetails(String email)) {
26+
return email;
3827
}
3928
return null;
4029
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.epam.aidial.deployment.manager.web.security;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.apache.commons.collections4.ListUtils;
6+
import org.springframework.core.ParameterizedTypeReference;
7+
import org.springframework.http.HttpHeaders;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.RequestEntity;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.GrantedAuthority;
12+
import org.springframework.security.oauth2.core.ClaimAccessor;
13+
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
14+
import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal;
15+
import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException;
16+
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
17+
import org.springframework.web.client.RestTemplate;
18+
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Set;
23+
24+
@Slf4j
25+
@RequiredArgsConstructor
26+
public class DefaultOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
27+
28+
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { };
29+
30+
private final RestTemplate restTemplate;
31+
private final OpaqueTokenProviderConfig config;
32+
private final MultiPathGrantedAuthoritiesConverter<ClaimAccessor> multiPathGrantedAuthoritiesConverter;
33+
private final String principalClaim;
34+
private final Set<String> emailClaims;
35+
36+
@Override
37+
public OAuth2AuthenticatedPrincipal introspect(String token) {
38+
Map<String, Object> attributes = introspectToken(token);
39+
40+
List<GrantedAuthority> grantedAuthorities = extractAuthorities(token, attributes);
41+
42+
Map<String, Object> newAttributes = new HashMap<>(attributes);
43+
newAttributes.put(OpaqueTokenProviderConfig.IDP_CLAIM, config.getName());
44+
45+
return new OAuth2IntrospectionAuthenticatedPrincipal(
46+
(String) attributes.get(principalClaim), newAttributes, grantedAuthorities);
47+
}
48+
49+
private Map<String, Object> introspectToken(String token) {
50+
HttpHeaders headers = new HttpHeaders();
51+
headers.setBearerAuth(token);
52+
53+
RequestEntity<Void> requestEntity = RequestEntity.get(config.getUserInfoEndpoint())
54+
.headers(headers)
55+
.build();
56+
57+
ResponseEntity<Map<String, Object>> responseEntity = makeRequest(requestEntity);
58+
59+
if (responseEntity.getStatusCode() != HttpStatus.OK) {
60+
log.debug("Introspection endpoint responded with {}. Provider: {}", responseEntity, config);
61+
throw new OAuth2IntrospectionException("Introspection endpoint responded with " + responseEntity.getStatusCode());
62+
}
63+
64+
return responseEntity.getBody();
65+
}
66+
67+
private ResponseEntity<Map<String, Object>> makeRequest(RequestEntity<?> requestEntity) {
68+
try {
69+
return restTemplate.exchange(requestEntity, STRING_OBJECT_MAP);
70+
} catch (Exception ex) {
71+
log.debug("Token introspection request failed for provider {}", config, ex);
72+
throw new OAuth2IntrospectionException(ex.getMessage(), ex);
73+
}
74+
}
75+
76+
private List<GrantedAuthority> extractAuthorities(String token, Map<String, Object> attributes) {
77+
List<String> roleClaims = config.getRoleClaims();
78+
if (isCustomizedRoleClaimsExtraction(roleClaims)) {
79+
var converterName = roleClaims.getFirst();
80+
var converter = OpaqueTokenCustomGrantedAuthoritiesConverters.CONVERTERS.get(converterName);
81+
if (converter == null) {
82+
log.debug("Unable to find custom granted authorities converter for provider {}", config);
83+
throw new OAuth2IntrospectionException("Unable to find custom granted authorities converter: " + converterName);
84+
}
85+
var authorityExtractionContext = new OpaqueAuthorityExtractionContext(restTemplate, token, attributes, emailClaims);
86+
return converter.apply(authorityExtractionContext);
87+
} else {
88+
return multiPathGrantedAuthoritiesConverter.convert(() -> attributes);
89+
}
90+
}
91+
92+
private boolean isCustomizedRoleClaimsExtraction(List<String> roleClaims) {
93+
List<String> safeRoleClaims = ListUtils.emptyIfNull(roleClaims);
94+
return safeRoleClaims.size() == 1 && safeRoleClaims.getFirst().startsWith("fn:");
95+
}
96+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.epam.aidial.deployment.manager.web.security;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.apache.commons.collections4.CollectionUtils;
5+
import org.apache.commons.lang3.StringUtils;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.net.MalformedURLException;
11+
import java.net.URL;
12+
import java.util.HashSet;
13+
import java.util.LinkedHashSet;
14+
import java.util.List;
15+
import java.util.Set;
16+
17+
@Slf4j
18+
@Component
19+
@ConditionalOnProperty(value = "config.rest.security.mode", havingValue = "oidc", matchIfMissing = true)
20+
public class IdentityProviderUtils {
21+
22+
private static final String V1_ISSUER_FORMAT = "https://%s/%s/";
23+
private static final String V2_ISSUER_FORMAT = "https://%s/%s/v2.0";
24+
25+
private final Set<String> defaultAllowedRoles;
26+
private final String defaultEmailClaim;
27+
private final String defaultPrincipalClaim;
28+
private final boolean requireEmail;
29+
30+
public IdentityProviderUtils(
31+
@Value("${config.rest.security.default.allowedRoles}") Set<String> defaultAllowedRoles,
32+
@Value("${config.rest.security.default.email-claim}") String defaultEmailClaim,
33+
@Value("${config.rest.security.default.principal-claim}") String defaultPrincipalClaim,
34+
@Value("${config.rest.security.require-email}") boolean requireEmail) {
35+
this.defaultAllowedRoles = Set.copyOf(defaultAllowedRoles);
36+
this.defaultEmailClaim = defaultEmailClaim;
37+
this.defaultPrincipalClaim = defaultPrincipalClaim;
38+
this.requireEmail = requireEmail;
39+
}
40+
41+
public Set<String> getAcceptedIssuers(JwtProviderConfig config) {
42+
final HashSet<String> acceptedIssuers = new HashSet<>();
43+
var issuer = config.getIssuer();
44+
if (isValidUrlWithProtocol(issuer)) {
45+
acceptedIssuers.add(issuer);
46+
} else if (!CollectionUtils.isEmpty(config.getAliases())) {
47+
// Only for Azure provider
48+
for (final var alias : config.getAliases()) {
49+
final var issuerV1Format = String.format(V1_ISSUER_FORMAT, alias, issuer);
50+
final var issuerV2Format = String.format(V2_ISSUER_FORMAT, alias, issuer);
51+
acceptedIssuers.add(issuerV1Format);
52+
acceptedIssuers.add(issuerV2Format);
53+
}
54+
}
55+
return acceptedIssuers;
56+
}
57+
58+
private boolean isValidUrlWithProtocol(final String urlString) {
59+
if (StringUtils.isBlank(urlString)) {
60+
return false;
61+
}
62+
try {
63+
final var protocol = new URL(urlString).getProtocol();
64+
return protocol != null && !protocol.isEmpty();
65+
} catch (final MalformedURLException e) {
66+
log.debug("Invalid url format for url: {}", urlString, e);
67+
return false;
68+
}
69+
}
70+
71+
public Set<String> getAllowedRoles(Set<String> allowedRoles) {
72+
Set<String> acceptedRoles = new HashSet<>(defaultAllowedRoles);
73+
if (allowedRoles != null) {
74+
acceptedRoles.addAll(allowedRoles);
75+
}
76+
return Set.copyOf(acceptedRoles);
77+
}
78+
79+
public Set<String> getEmailClaims(List<String> emailClaims) {
80+
Set<String> result = new LinkedHashSet<>();
81+
82+
if (!CollectionUtils.isEmpty(emailClaims)) {
83+
result.addAll(emailClaims);
84+
} else if (StringUtils.isNotBlank(defaultEmailClaim)) {
85+
result.add(defaultEmailClaim);
86+
}
87+
88+
return result;
89+
}
90+
91+
public String getPrincipalClaim(String principalClaim) {
92+
return StringUtils.defaultIfBlank(principalClaim, defaultPrincipalClaim);
93+
}
94+
95+
public boolean isEmailRequired() {
96+
return requireEmail;
97+
}
98+
}

0 commit comments

Comments
 (0)