mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-02 19:48:17 +00:00
Backend: Enforce principal domain in the JWT filter (#5155)
This commit is contained in:
parent
c1fa31eacc
commit
059acfc529
@ -210,15 +210,15 @@ public class CatalogApplication extends Application<CatalogApplicationConfig> {
|
||||
filter =
|
||||
Class.forName(filterClazzName)
|
||||
.asSubclass(ContainerRequestFilter.class)
|
||||
.getConstructor(AuthenticationConfiguration.class)
|
||||
.newInstance(authenticationConfiguration);
|
||||
.getConstructor(AuthenticationConfiguration.class, AuthorizerConfiguration.class)
|
||||
.newInstance(authenticationConfiguration, authorizerConf);
|
||||
LOG.info("Registering ContainerRequestFilter: {}", filter.getClass().getCanonicalName());
|
||||
environment.jersey().register(filter);
|
||||
}
|
||||
} else {
|
||||
LOG.info("Authorizer config not set, setting noop authorizer");
|
||||
authorizer = new NoopAuthorizer();
|
||||
ContainerRequestFilter filter = new NoopFilter(authenticationConfiguration);
|
||||
ContainerRequestFilter filter = new NoopFilter(authenticationConfiguration, null);
|
||||
environment.jersey().register(filter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ public class AuthenticationConfiguration {
|
||||
@Getter @Setter private String authority;
|
||||
@Getter @Setter private String clientId;
|
||||
@Getter @Setter private String callbackUrl;
|
||||
@Getter @Setter private List<String> jwtPrincipalClaims = List.of("email", "preferred_username", "sub");
|
||||
@Getter @Setter private List<String> jwtPrincipalClaims;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
@ -24,6 +24,7 @@ public class AuthorizerConfiguration {
|
||||
@NotEmpty @Getter @Setter private Set<String> adminPrincipals;
|
||||
@NotEmpty @Getter @Setter private Set<String> botPrincipals;
|
||||
@NotEmpty @Getter @Setter private String principalDomain;
|
||||
@NotEmpty @Getter @Setter private Boolean enforcePrincipalDomain;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
@ -32,7 +32,7 @@ public class CatalogOpenIdAuthorizationRequestFilter implements ContainerRequest
|
||||
@SuppressWarnings("unused")
|
||||
private CatalogOpenIdAuthorizationRequestFilter() {}
|
||||
|
||||
public CatalogOpenIdAuthorizationRequestFilter(AuthenticationConfiguration config) {}
|
||||
public CatalogOpenIdAuthorizationRequestFilter(AuthenticationConfiguration config, AuthorizerConfiguration conf) {}
|
||||
|
||||
public void filter(ContainerRequestContext containerRequestContext) {
|
||||
if (isHealthEndpoint(containerRequestContext)) {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
package org.openmetadata.catalog.security;
|
||||
|
||||
import static org.openmetadata.catalog.Entity.FIELD_OWNER;
|
||||
import static org.openmetadata.catalog.security.SecurityUtil.DEFAULT_PRINCIPAL_DOMAIN;
|
||||
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -68,11 +69,12 @@ public class DefaultAuthorizer implements Authorizer {
|
||||
}
|
||||
addOrUpdateUser(user);
|
||||
} catch (EntityNotFoundException | IOException ex) {
|
||||
String domain = principalDomain.isEmpty() ? DEFAULT_PRINCIPAL_DOMAIN : principalDomain;
|
||||
User user =
|
||||
new User()
|
||||
.withId(UUID.randomUUID())
|
||||
.withName(adminUser)
|
||||
.withEmail(adminUser + "@" + principalDomain)
|
||||
.withEmail(adminUser + "@" + domain)
|
||||
.withIsAdmin(true)
|
||||
.withUpdatedBy(adminUser)
|
||||
.withUpdatedAt(System.currentTimeMillis());
|
||||
@ -92,11 +94,12 @@ public class DefaultAuthorizer implements Authorizer {
|
||||
}
|
||||
addOrUpdateUser(user);
|
||||
} catch (EntityNotFoundException | IOException ex) {
|
||||
String domain = principalDomain.isEmpty() ? DEFAULT_PRINCIPAL_DOMAIN : principalDomain;
|
||||
User user =
|
||||
new User()
|
||||
.withId(UUID.randomUUID())
|
||||
.withName(botUser)
|
||||
.withEmail(botUser + "@" + principalDomain)
|
||||
.withEmail(botUser + "@" + domain)
|
||||
.withIsBot(true)
|
||||
.withUpdatedBy(botUser)
|
||||
.withUpdatedAt(System.currentTimeMillis());
|
||||
|
||||
@ -18,6 +18,7 @@ import com.auth0.jwk.JwkProvider;
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.exceptions.JWTDecodeException;
|
||||
import com.auth0.jwt.interfaces.Claim;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
@ -28,7 +29,9 @@ import java.net.URL;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
import java.util.TreeMap;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.container.ContainerRequestFilter;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
@ -37,6 +40,7 @@ import javax.ws.rs.core.UriInfo;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang.StringUtils;
|
||||
import org.openmetadata.catalog.Entity;
|
||||
import org.openmetadata.catalog.entity.teams.AuthenticationMechanism;
|
||||
import org.openmetadata.catalog.entity.teams.User;
|
||||
@ -55,12 +59,15 @@ public class JwtFilter implements ContainerRequestFilter {
|
||||
|
||||
private List<String> jwtPrincipalClaims;
|
||||
private JwkProvider jwkProvider;
|
||||
private String principalDomain;
|
||||
private boolean enforcePrincipalDomain;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private JwtFilter() {}
|
||||
|
||||
@SneakyThrows
|
||||
public JwtFilter(AuthenticationConfiguration authenticationConfiguration) {
|
||||
public JwtFilter(
|
||||
AuthenticationConfiguration authenticationConfiguration, AuthorizerConfiguration authorizerConfiguration) {
|
||||
this.jwtPrincipalClaims = authenticationConfiguration.getJwtPrincipalClaims();
|
||||
|
||||
ImmutableList.Builder<URL> publicKeyUrlsBuilder = ImmutableList.builder();
|
||||
@ -68,12 +75,20 @@ public class JwtFilter implements ContainerRequestFilter {
|
||||
publicKeyUrlsBuilder.add(new URL(publicKeyUrlStr));
|
||||
}
|
||||
this.jwkProvider = new MultiUrlJwkProvider(publicKeyUrlsBuilder.build());
|
||||
this.principalDomain = authorizerConfiguration.getPrincipalDomain();
|
||||
this.enforcePrincipalDomain = authorizerConfiguration.getEnforcePrincipalDomain();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
JwtFilter(JwkProvider jwkProvider, List<String> jwtPrincipalClaims) {
|
||||
JwtFilter(
|
||||
JwkProvider jwkProvider,
|
||||
List<String> jwtPrincipalClaims,
|
||||
String principalDomain,
|
||||
boolean enforcePrincipalDomain) {
|
||||
this.jwkProvider = jwkProvider;
|
||||
this.jwtPrincipalClaims = jwtPrincipalClaims;
|
||||
this.principalDomain = principalDomain;
|
||||
this.enforcePrincipalDomain = enforcePrincipalDomain;
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@ -114,26 +129,38 @@ public class JwtFilter implements ContainerRequestFilter {
|
||||
}
|
||||
|
||||
// Get username from JWT token
|
||||
String userName =
|
||||
Map<String, Claim> claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
claims.putAll(jwt.getClaims());
|
||||
String jwtClaim =
|
||||
jwtPrincipalClaims.stream()
|
||||
.filter(jwt.getClaims()::containsKey)
|
||||
.filter(claims::containsKey)
|
||||
.findFirst()
|
||||
.map(jwt::getClaim)
|
||||
.map(claims::get)
|
||||
.map(claim -> claim.as(TextNode.class).asText())
|
||||
.map(
|
||||
authorizedClaim -> {
|
||||
if (authorizedClaim.contains("@")) {
|
||||
return authorizedClaim.split("@")[0];
|
||||
} else {
|
||||
return authorizedClaim;
|
||||
}
|
||||
})
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new AuthenticationException(
|
||||
"Invalid JWT token, none of the following claims are present " + jwtPrincipalClaims));
|
||||
String userName;
|
||||
String domain;
|
||||
if (jwtClaim.contains("@")) {
|
||||
userName = jwtClaim.split("@")[0];
|
||||
domain = jwtClaim.split("@")[1];
|
||||
} else {
|
||||
userName = jwtClaim;
|
||||
domain = StringUtils.EMPTY;
|
||||
}
|
||||
|
||||
// validate principal domain
|
||||
if (enforcePrincipalDomain) {
|
||||
if (!domain.equals(principalDomain)) {
|
||||
throw new AuthenticationException(
|
||||
String.format("Not Authorized! Email does not match the principal domain %s", principalDomain));
|
||||
}
|
||||
}
|
||||
|
||||
// validate bot token
|
||||
if (jwt.getClaims().containsKey(BOT_CLAIM) && jwt.getClaims().get(BOT_CLAIM).asBoolean()) {
|
||||
if (claims.containsKey(BOT_CLAIM) && claims.get(BOT_CLAIM).asBoolean()) {
|
||||
validateBotToken(tokenFromHeader, userName);
|
||||
}
|
||||
// Setting Security Context
|
||||
|
||||
@ -25,7 +25,8 @@ import org.openmetadata.catalog.security.auth.CatalogSecurityContext;
|
||||
public class NoopFilter implements ContainerRequestFilter {
|
||||
@Context private UriInfo uriInfo;
|
||||
|
||||
public NoopFilter(AuthenticationConfiguration authenticationConfiguration) {}
|
||||
public NoopFilter(
|
||||
AuthenticationConfiguration authenticationConfiguration, AuthorizerConfiguration authorizerConfiguration) {}
|
||||
|
||||
public void filter(ContainerRequestContext containerRequestContext) {
|
||||
CatalogPrincipal catalogPrincipal = new CatalogPrincipal("anonymous");
|
||||
|
||||
@ -34,6 +34,7 @@ public final class SecurityUtil {
|
||||
public static final int BOT = 2;
|
||||
public static final int OWNER = 4;
|
||||
public static final int PERMISSIONS = 8;
|
||||
public static final String DEFAULT_PRINCIPAL_DOMAIN = "openmetadata.org";
|
||||
|
||||
private SecurityUtil() {}
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ import org.mockito.ArgumentCaptor;
|
||||
class JwtFilterTest {
|
||||
|
||||
private static JwtFilter jwtFilter;
|
||||
private static JwkProvider jwkProvider;
|
||||
|
||||
private static Algorithm algorithm;
|
||||
private static UriInfo mockRequestURIInfo;
|
||||
@ -65,7 +66,7 @@ class JwtFilterTest {
|
||||
// This is used to verify the JWT
|
||||
Jwk mockJwk = mock(Jwk.class);
|
||||
when(mockJwk.getPublicKey()).thenReturn(keyPair.getPublic());
|
||||
JwkProvider jwkProvider = mock(JwkProvider.class);
|
||||
jwkProvider = mock(JwkProvider.class);
|
||||
when(jwkProvider.get(algorithm.getSigningKeyId())).thenReturn(mockJwk);
|
||||
|
||||
// This is needed by JwtFilter for some metadata, not very important
|
||||
@ -75,7 +76,44 @@ class JwtFilterTest {
|
||||
when(mockRequestURIInfo.getRequestUri()).thenReturn(uri);
|
||||
|
||||
List<String> principalClaims = List.of("sub", "email");
|
||||
jwtFilter = new JwtFilter(jwkProvider, principalClaims);
|
||||
String domain = "openmetadata.org";
|
||||
boolean enforcePrincipalDomain = false;
|
||||
jwtFilter = new JwtFilter(jwkProvider, principalClaims, domain, enforcePrincipalDomain);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPrincipalDomainEnforcement() {
|
||||
List<String> principalClaims = List.of("EMAIL", "sub");
|
||||
String domain = "openmetadata.org";
|
||||
boolean enforcePrincipalDomain = true;
|
||||
jwtFilter = new JwtFilter(jwkProvider, principalClaims, domain, enforcePrincipalDomain);
|
||||
|
||||
// success case
|
||||
String jwt =
|
||||
JWT.create()
|
||||
.withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS)))
|
||||
.withClaim("email", "sam@openmetadata.org")
|
||||
.sign(algorithm);
|
||||
|
||||
ContainerRequestContext context = createRequestContextWithJwt(jwt);
|
||||
|
||||
jwtFilter.filter(context);
|
||||
|
||||
ArgumentCaptor<SecurityContext> securityContextArgument = ArgumentCaptor.forClass(SecurityContext.class);
|
||||
verify(context, times(1)).setSecurityContext(securityContextArgument.capture());
|
||||
|
||||
assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName());
|
||||
|
||||
// error case
|
||||
jwt =
|
||||
JWT.create()
|
||||
.withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS)))
|
||||
.withClaim("email", "sam@gmail.com")
|
||||
.sign(algorithm);
|
||||
ContainerRequestContext newContext = createRequestContextWithJwt(jwt);
|
||||
|
||||
Exception exception = assertThrows(AuthenticationException.class, () -> jwtFilter.filter(newContext));
|
||||
assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("email does not match the principal domain"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -131,7 +131,8 @@ authorizerConfiguration:
|
||||
containerRequestFilter: ${AUTHORIZER_REQUEST_FILTER:-org.openmetadata.catalog.security.NoopFilter}
|
||||
adminPrincipals: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]}
|
||||
botPrincipals: ${AUTHORIZER_INGESTION_PRINCIPALS:-[ingestion-bot]}
|
||||
principalDomain: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""}
|
||||
principalDomain: ${AUTHORIZER_PRINCIPAL_DOMAIN:-"openmetadata.org"}
|
||||
enforcePrincipalDomain: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false}
|
||||
|
||||
authenticationConfiguration:
|
||||
provider: ${AUTHENTICATION_PROVIDER:-no-auth}
|
||||
@ -141,6 +142,7 @@ authenticationConfiguration:
|
||||
authority: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com}
|
||||
clientId: ${AUTHENTICATION_CLIENT_ID:-""}
|
||||
callbackUrl: ${AUTHENTICATION_CALLBACK_URL:-""}
|
||||
jwtPrincipalClaims: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]}
|
||||
|
||||
jwtTokenConfiguration:
|
||||
rsapublicKeyFilePath: ${RSA_PUBLIC_KEY_FILE_PATH:-""}
|
||||
|
||||
@ -64,12 +64,14 @@ services:
|
||||
AUTHORIZER_ADMIN_PRINCIPALS: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]}
|
||||
AUTHORIZER_INGESTION_PRINCIPALS: ${AUTHORIZER_INGESTION_PRINCIPALS:-[ingestion-bot]}
|
||||
AUTHORIZER_PRINCIPAL_DOMAIN: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""}
|
||||
AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false}
|
||||
AUTHENTICATION_PROVIDER: ${AUTHENTICATION_PROVIDER:-no-auth}
|
||||
CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""}
|
||||
AUTHENTICATION_PUBLIC_KEYS: ${AUTHENTICATION_PUBLIC_KEY:-[https://www.googleapis.com/oauth2/v3/certs]}
|
||||
AUTHENTICATION_AUTHORITY: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com}
|
||||
AUTHENTICATION_CLIENT_ID: ${AUTHENTICATION_CLIENT_ID:-""}
|
||||
AUTHENTICATION_CALLBACK_URL: ${AUTHENTICATION_CALLBACK_URL:-""}
|
||||
AUTHENTICATION_JWT_PRINCIPAL_CLAIMS: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]}
|
||||
# OpenMetadata Server Airflow Configuration
|
||||
AIRFLOW_HOST: ${AIRFLOW_HOST:-http://ingestion:8080}
|
||||
SERVER_HOST_API_URL: ${SERVER_HOST_API_URL:-http://localhost:8585/api}
|
||||
|
||||
@ -53,12 +53,14 @@ services:
|
||||
AUTHORIZER_ADMIN_PRINCIPALS: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]}
|
||||
AUTHORIZER_INGESTION_PRINCIPALS: ${AUTHORIZER_INGESTION_PRINCIPAL:-[ingestion-bot]}
|
||||
AUTHORIZER_PRINCIPAL_DOMAIN: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""}
|
||||
AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false}
|
||||
AUTHENTICATION_PROVIDER: ${AUTHENTICATION_PROVIDER:-no-auth}
|
||||
CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""}
|
||||
AUTHENTICATION_PUBLIC_KEYS: ${AUTHENTICATION_PUBLIC_KEY:-[https://www.googleapis.com/oauth2/v3/certs]}
|
||||
AUTHENTICATION_AUTHORITY: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com}
|
||||
AUTHENTICATION_CLIENT_ID: ${AUTHENTICATION_CLIENT_ID:-""}
|
||||
AUTHENTICATION_CALLBACK_URL: ${AUTHENTICATION_CALLBACK_URL:-""}
|
||||
AUTHENTICATION_JWT_PRINCIPAL_CLAIMS: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]}
|
||||
# OpenMetadata Server Airflow Configuration
|
||||
AIRFLOW_HOST: ${AIRFLOW_HOST:-http://ingestion:8080}
|
||||
SERVER_HOST_API_URL: ${SERVER_HOST_API_URL:-http://localhost:8585/api}
|
||||
|
||||
@ -316,6 +316,7 @@ export const AuthProvider = ({
|
||||
if (error.response) {
|
||||
const { status } = error.response;
|
||||
if (status === ClientErrors.UNAUTHORIZED) {
|
||||
showErrorToast(error);
|
||||
resetUserDetails(true);
|
||||
} else if (status === ClientErrors.FORBIDDEN) {
|
||||
showErrorToast(jsonData['api-error-messages']['forbidden-error']);
|
||||
|
||||
@ -12,11 +12,25 @@
|
||||
*/
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import { isString } from 'lodash';
|
||||
import { isEmpty, isString } from 'lodash';
|
||||
import { toast } from 'react-toastify';
|
||||
import jsonData from '../jsons/en';
|
||||
import { getErrorText } from './StringsUtils';
|
||||
|
||||
export const hashCode = (str: string) => {
|
||||
let hash = 0,
|
||||
i,
|
||||
chr;
|
||||
if (isEmpty(str)) return hash;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display an error toast message.
|
||||
* @param error error text or AxiosError object
|
||||
@ -39,11 +53,17 @@ export const showErrorToast = (
|
||||
errorMessage = getErrorText(error, fallback);
|
||||
// do not show error toasts for 401
|
||||
// since they will be intercepted and the user will be redirected to the signin page
|
||||
if (error && error.response?.status === 401) {
|
||||
// except for principal domain mismatch errors
|
||||
if (
|
||||
error &&
|
||||
error.response?.status === 401 &&
|
||||
!errorMessage.includes('principal domain')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast.error(errorMessage, {
|
||||
toastId: hashCode(errorMessage),
|
||||
autoClose: autoCloseTimer,
|
||||
});
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user