feat(react): SSO support simple OIDC authentication (#2190)

Co-authored-by: John Joyce <john@acryl.io>
This commit is contained in:
John Joyce 2021-03-11 13:38:35 -08:00 committed by GitHub
parent e659f749f2
commit 08616cc610
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1085 additions and 248 deletions

View File

@ -84,14 +84,18 @@ project.ext.externalDependency = [
'neo4jJavaDriver': 'org.neo4j.driver:neo4j-java-driver:4.0.1',
'parseqTest': 'com.linkedin.parseq:parseq:3.0.7:test',
'picocli': 'info.picocli:picocli:4.5.0',
'playCache': 'com.typesafe.play:play-cache_2.11:2.6.18',
'playDocs': 'com.typesafe.play:play-docs_2.11:2.6.18',
'playGuice': 'com.typesafe.play:play-guice_2.11:2.6.18',
'playJavaJdbc': 'com.typesafe.play:play-java-jdbc_2.11:2.6.18',
'playTest': 'com.typesafe.play:play-test_2.11:2.6.18',
'pac4j': 'org.pac4j:pac4j-oidc:3.6.0',
'playPac4j': 'org.pac4j:play-pac4j_2.11:7.0.0',
'postgresql': 'org.postgresql:postgresql:42.2.14',
'reflections': 'org.reflections:reflections:0.9.11',
'rythmEngine': 'org.rythmengine:rythm-engine:1.3.0',
'servletApi': 'javax.servlet:javax.servlet-api:3.1.0',
'shiroCore': 'org.apache.shiro:shiro-core:1.7.1',
'springBeans': 'org.springframework:spring-beans:5.2.3.RELEASE',
'springContext': 'org.springframework:spring-context:5.2.3.RELEASE',
'springCore': 'org.springframework:spring-core:5.2.3.RELEASE',

View File

@ -18,6 +18,13 @@ However, if you only want to build `DataHub Frontend` specifically:
./gradlew :datahub-frontend:build
```
### Building React App
To build datahub-frontend to serve the React app, build with the additional "enableReact" property:
```
./gradlew :datahub-frontend:build -PenableReact=true
```
## Dependencies
Before starting `DataHub Frontend`, you need to make sure that [DataHub GMS](../gms) and
all its dependencies have already started and running.
@ -51,6 +58,7 @@ the application directly from command line after a successful [build](#build):
cd datahub-frontend/run && ./run-local-frontend
```
### Serving React App
If you are running the React app locally via `yarn start`, it will be forwarding graphql requests to port `9002`. In order to use `./run-local-frontend` with the React app, change the PORT value in [./run/frontend.env](./run/frontend.env) to `9002` and restart `./run-local-frontend`
## Checking out DataHub UI
@ -321,7 +329,10 @@ WHZ-Authentication {
};
```
Note that the special keyword `USERNAME` will be substituted by the actual username.
### Authentication in React
The React app supports both JAAS as described above and separately OIDC authentication. To learn about configuring OIDC for React,
see the [OIDC in React](../docs/how/configure-oidc-react.md) document.
### API Debugging
Most DataHub frontend API endpoints are protected using [Play Authentication](https://www.playframework.com/documentation/2.1.0/JavaGuide4), which means it requires authentication information stored in the cookie for the request to go through. This makes debugging using curl difficult. One option is to first make a curl call against the `/authenticate` endpoint and stores the authentication info in a cookie file like this

View File

@ -1,78 +0,0 @@
package graphql.resolvers.mutation;
import com.linkedin.common.urn.CorpuserUrn;
import com.linkedin.datahub.graphql.exception.AuthenticationException;
import com.linkedin.datahub.graphql.exception.ValidationException;
import com.linkedin.datahub.graphql.generated.CorpUser;
import com.linkedin.datahub.graphql.types.corpuser.CorpUserType;
import graphql.PlayQueryContext;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import org.apache.commons.lang3.StringUtils;
import security.AuthUtil;
import security.AuthenticationManager;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.CompletableFuture;
import static security.AuthConstants.*;
/**
* Resolver responsible for authenticating a user
*/
public class LogInResolver implements DataFetcher<CompletableFuture<CorpUser>> {
private final CorpUserType _corpUserType;
public LogInResolver(final CorpUserType corpUserType) {
_corpUserType = corpUserType;
}
@Override
public CompletableFuture<CorpUser> get(DataFetchingEnvironment environment) throws Exception {
/*
Extract arguments
*/
final String username = environment.getArgument(USER_NAME);
final String password = environment.getArgument(PASSWORD);
if (StringUtils.isBlank(username)) {
throw new ValidationException("username must not be empty");
}
PlayQueryContext context = environment.getContext();
context.getSession().clear();
// Create a uuid string for this session if one doesn't already exist
String uuid = context.getSession().get(UUID);
if (uuid == null) {
uuid = java.util.UUID.randomUUID().toString();
context.getSession().put(UUID, uuid);
}
try {
AuthenticationManager.authenticateUser(username, password);
} catch (javax.naming.AuthenticationException e) {
throw new AuthenticationException("Failed to authenticate user", e);
}
context.getSession().put(USER, username);
String secretKey = context.getAppConfig().getString(SECRET_KEY_PROPERTY);
try {
// store hashed username within PLAY_SESSION cookie
String hashedUserName = AuthUtil.generateHash(username, secretKey.getBytes());
context.getSession().put(AUTH_TOKEN, hashedUserName);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("Failed to hash username", e);
}
/*
Fetch the latest version of the logged in user.
*/
final String urn = new CorpuserUrn(username).toString();
return environment.getDataLoaderRegistry().getDataLoader(_corpUserType.name()).load(urn).thenApply(corpUserObj -> (CorpUser) corpUserObj);
}
}

View File

@ -0,0 +1,165 @@
package react.auth;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.linkedin.common.urn.CorpuserUrn;
import org.pac4j.core.client.Client;
import org.pac4j.core.client.Clients;
import org.pac4j.core.config.Config;
import org.pac4j.core.context.session.SessionStore;
import org.pac4j.core.engine.DefaultCallbackLogic;
import org.pac4j.core.http.adapter.HttpActionAdapter;
import org.pac4j.core.http.callback.PathParameterCallbackUrlResolver;
import org.pac4j.core.profile.CommonProfile;
import org.pac4j.core.profile.ProfileManager;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.pac4j.play.CallbackController;
import org.pac4j.play.PlayWebContext;
import org.pac4j.play.http.PlayHttpActionAdapter;
import org.pac4j.play.store.PlayCookieSessionStore;
import org.pac4j.play.store.PlaySessionStore;
import play.Environment;
import play.mvc.Result;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static react.auth.AuthUtils.*;
/**
* Responsible for configuring, validating, and providing authentication related components.
*/
public class AuthModule extends AbstractModule {
private static final String AUTH_BASE_URL_CONFIG_PATH = "auth.baseUrl";
private static final String AUTH_BASE_CALLBACK_PATH_CONFIG_PATH = "auth.baseCallbackPath";
private static final String AUTH_SUCCESS_REDIRECT_PATH_CONFIG_PATH = "auth.successRedirectPath";
private static final String DEFAULT_BASE_CALLBACK_PATH = "/callback";
private static final String DEFAULT_SUCCESS_REDIRECT_PATH = "/";
private String _authBaseUrl;
private String _authBaseCallbackPath;
private String _authSuccessRedirectPath;
private final com.typesafe.config.Config _configs;
/**
* OIDC-specific configurations.
*/
private final OidcConfigs _oidcConfigs;
public AuthModule(final Environment environment, final com.typesafe.config.Config configs) {
_configs = configs;
_oidcConfigs = new OidcConfigs(configs);
if (isIndirectAuthEnabled()) {
_authBaseUrl = configs.getString(AUTH_BASE_URL_CONFIG_PATH);
_authBaseCallbackPath = configs.hasPath(AUTH_BASE_CALLBACK_PATH_CONFIG_PATH)
? configs.getString(AUTH_BASE_CALLBACK_PATH_CONFIG_PATH)
: DEFAULT_BASE_CALLBACK_PATH;
_authSuccessRedirectPath = configs.hasPath(AUTH_SUCCESS_REDIRECT_PATH_CONFIG_PATH)
? configs.getString(AUTH_SUCCESS_REDIRECT_PATH_CONFIG_PATH)
: DEFAULT_SUCCESS_REDIRECT_PATH;
}
}
@Override
protected void configure() {
final PlayCookieSessionStore playCacheCookieStore = new PlayCookieSessionStore();
bind(SessionStore.class).toInstance(playCacheCookieStore);
bind(PlaySessionStore.class).toInstance(playCacheCookieStore);
final CallbackController callbackController = new CallbackController() {};
callbackController.setDefaultUrl(_authSuccessRedirectPath);
callbackController.setCallbackLogic(new DefaultCallbackLogic<Result, PlayWebContext>() {
@Override
public Result perform(final PlayWebContext context, final Config config, final HttpActionAdapter<Result, PlayWebContext> httpActionAdapter,
final String inputDefaultUrl, final Boolean inputSaveInSession, final Boolean inputMultiProfile,
final Boolean inputRenewSession, final String client) {
final Result result = super.perform(context, config, httpActionAdapter, inputDefaultUrl, inputSaveInSession, inputMultiProfile, inputRenewSession, client);
if (_oidcConfigs.getClientName().equals(client)) {
return handleOidcCallback(result, context, getProfileManager(context, config));
}
throw new RuntimeException(String.format("Unrecognized client with name %s provided to callback URL.", client));
}
});
// Make OIDC the default SSO client.
if (_oidcConfigs.isOidcEnabled()) {
callbackController.setDefaultClient(_oidcConfigs.getClientName());
}
bind(CallbackController.class).toInstance(callbackController);
}
@Provides @Singleton
protected Config provideConfig() {
if (isIndirectAuthEnabled()) {
final Clients clients = new Clients(_authBaseUrl + _authBaseCallbackPath);
final List<Client> clientList = new ArrayList<>();
if (_oidcConfigs.isOidcEnabled()) {
final OidcConfiguration oidcConfiguration = new OidcConfiguration();
oidcConfiguration.setClientId(_oidcConfigs.getClientId());
oidcConfiguration.setSecret(_oidcConfigs.getClientSecret());
oidcConfiguration.setDiscoveryURI(_oidcConfigs.getDiscoveryUri());
oidcConfiguration.setScope(_oidcConfigs.getScope());
final OidcClient oidcClient = new OidcClient(oidcConfiguration);
oidcClient.setName(_oidcConfigs.getClientName());
oidcClient.setCallbackUrlResolver(new PathParameterCallbackUrlResolver());
clientList.add(oidcClient);
}
clients.setClients(clientList);
final Config config = new Config(clients);
config.setHttpActionAdapter(new PlayHttpActionAdapter());
return config;
}
return new Config();
}
private Result handleOidcCallback(final Result result, final PlayWebContext context, ProfileManager<CommonProfile> profileManager) {
if (profileManager.isAuthenticated() && profileManager.get(true).isPresent()) {
final CommonProfile profile = profileManager.get(true).get();
if (!profile.containsAttribute(_oidcConfigs.getUserNameClaim())) {
throw new RuntimeException(
String.format(
"Failed to resolve user name claim from profile provided by Identity Provider. Missing attribute '%s'",
_oidcConfigs.getUserNameClaim()
));
}
final String userNameClaim = (String) profile.getAttribute(_oidcConfigs.getUserNameClaim());
final Pattern pattern = Pattern.compile(_oidcConfigs.getUserNameClaimRegex());
final Matcher matcher = pattern.matcher(userNameClaim);
if (matcher.find()) {
final String userName = matcher.group();
final String actorUrn = new CorpuserUrn(userName).toString();
context.getJavaSession().put(ACTOR, actorUrn);
return result.withCookies(createActorCookie(actorUrn, _configs.hasPath(SESSION_TTL_CONFIG_PATH)
? _configs.getInt(SESSION_TTL_CONFIG_PATH)
: DEFAULT_SESSION_TTL_DAYS));
} else {
throw new RuntimeException(
String.format("Failed to extract DataHub username from username claim %s using regex %s",
userNameClaim,
_oidcConfigs.getUserNameClaimRegex()));
}
}
throw new RuntimeException(String.format("Failed to authenticate current user. Cannot find valid identity provider profile in session"));
}
/**
* Returns true if indirect authentication is enabled (callback-based SSO), false otherwise.
* Currently, only OIDC is supported.
*/
private boolean isIndirectAuthEnabled() {
return _oidcConfigs.isOidcEnabled();
}
}

View File

@ -0,0 +1,47 @@
package react.auth;
import com.linkedin.common.urn.CorpuserUrn;
import play.mvc.Http;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
public class AuthUtils {
public static final String SESSION_TTL_CONFIG_PATH = "auth.session.ttlInDays";
public static final Integer DEFAULT_SESSION_TTL_DAYS = 30;
public static final CorpuserUrn DEFAULT_ACTOR_URN = new CorpuserUrn("datahub");
public static final String LOGIN_ROUTE = "/login";
public static final String USER_NAME = "username";
public static final String PASSWORD = "password";
public static final String ACTOR = "actor";
/**
* Returns true if a request is authenticated, false otherwise.
*
* Note that we depend on the presence of 2 cookies, one accessible to the browser and one not,
* as well as their agreement to determine authentication status.
*/
public static boolean isAuthenticated(final Http.Context ctx) {
return ctx.session().containsKey(ACTOR)
&& ctx.request().cookie(ACTOR) != null
&& ctx.session().get(ACTOR).equals(ctx.request().cookie(ACTOR).value());
}
/**
* Creates a client authentication cookie (actor cookie) with a specified TTL in days.
*
* @param actorUrn the urn of the authenticated actor, e.g. "urn:li:corpuser:datahub"
* @param ttlInDays the number of days until the actor cookie expires after being set
*/
public static Http.Cookie createActorCookie(final String actorUrn, final Integer ttlInDays) {
return Http.Cookie.builder(ACTOR, actorUrn)
.withHttpOnly(false)
.withMaxAge(Duration.of(ttlInDays, ChronoUnit.DAYS))
.build();
}
private AuthUtils() { }
}

View File

@ -0,0 +1,27 @@
package react.auth;
import play.mvc.Http;
import play.mvc.Result;
import play.mvc.Security;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import static react.auth.AuthUtils.ACTOR;
/**
* Implementation of base Play Authentication used to determine if a request to a route should be
* authenticated.
*/
public class Authenticator extends Security.Authenticator {
@Override
public String getUsername(@Nonnull Http.Context ctx) {
return AuthUtils.isAuthenticated(ctx) ? ctx.session().get(ACTOR) : null;
}
@Override
@Nonnull
public Result onUnauthorized(@Nullable Http.Context ctx) {
return unauthorized();
}
}

View File

@ -0,0 +1,24 @@
package react.auth;
/**
* Currently, this config enables or disable custom Java Authentication and Authorization Service
* authentication that has traditionally existed in DH.
*/
public class JAASConfigs {
public static final String JAAS_ENABLED_CONFIG_PATH = "auth.jaas.enabled";
private Boolean _isEnabled = true;
public JAASConfigs(final com.typesafe.config.Config configs) {
if (configs.hasPath(JAAS_ENABLED_CONFIG_PATH)
&& Boolean.FALSE.equals(
Boolean.parseBoolean(configs.getValue(JAAS_ENABLED_CONFIG_PATH).toString()))) {
_isEnabled = false;
}
}
public boolean isJAASEnabled() {
return _isEnabled;
}
}

View File

@ -0,0 +1,124 @@
package react.auth;
/**
* Class responsible for extracting and validating OIDC related configurations.
*/
public class OidcConfigs {
public static final String OIDC_ENABLED_CONFIG_PATH = "auth.oidc.enabled";
/**
* Required configs
*/
public static final String OIDC_CLIENT_ID_CONFIG_PATH = "auth.oidc.clientId";
public static final String OIDC_CLIENT_SECRET_CONFIG_PATH = "auth.oidc.clientSecret";
public static final String OIDC_DISCOVERY_URI_CONFIG_PATH = "auth.oidc.discoveryUri";
/**
* Optional configs
*/
public static final String OIDC_USERNAME_CLAIM_CONFIG_PATH = "auth.oidc.userNameClaim";
public static final String OIDC_USERNAME_CLAIM_REGEX_CONFIG_PATH = "auth.oidc.userNameClaimRegex";
public static final String OIDC_SCOPE_CONFIG_PATH = "auth.oidc.scope";
public static final String OIDC_CLIENT_NAME_CONFIG_PATH = "auth.oidc.clientName";
/**
* Default values
*/
private static final String DEFAULT_OIDC_USERNAME_CLAIM = "preferred_username";
private static final String DEFAULT_OIDC_USERNAME_CLAIM_REGEX = "(.*)";
private static final String DEFAULT_OIDC_SCOPE = "openid profile email";
private static final String DEFAULT_OIDC_CLIENT_NAME = "oidc";
private String _clientId;
private String _clientSecret;
private String _discoveryUri;
private String _userNameClaim;
private String _userNameClaimRegex;
private String _scope;
private String _clientName;
private Boolean _isEnabled = false;
public OidcConfigs(final com.typesafe.config.Config configs) {
if (configs.hasPath(OIDC_ENABLED_CONFIG_PATH)
&& Boolean.TRUE.equals(
Boolean.parseBoolean(configs.getString(OIDC_ENABLED_CONFIG_PATH)))) {
_isEnabled = true;
_clientId = getRequired(
configs,
OIDC_CLIENT_ID_CONFIG_PATH);
_clientSecret = getRequired(
configs,
OIDC_CLIENT_SECRET_CONFIG_PATH);
_discoveryUri = getRequired(
configs,
OIDC_DISCOVERY_URI_CONFIG_PATH);
_userNameClaim = getOptional(
configs,
OIDC_USERNAME_CLAIM_CONFIG_PATH,
DEFAULT_OIDC_USERNAME_CLAIM);
_userNameClaimRegex = getOptional(
configs,
OIDC_USERNAME_CLAIM_REGEX_CONFIG_PATH,
DEFAULT_OIDC_USERNAME_CLAIM_REGEX);
_scope = getOptional(
configs,
OIDC_SCOPE_CONFIG_PATH,
DEFAULT_OIDC_SCOPE);
_clientName = getOptional(
configs,
OIDC_CLIENT_NAME_CONFIG_PATH,
DEFAULT_OIDC_CLIENT_NAME);
}
}
public boolean isOidcEnabled() {
return _isEnabled;
}
public String getClientId() {
return _clientId;
}
public String getClientSecret() {
return _clientSecret;
}
public String getDiscoveryUri() {
return _discoveryUri;
}
public String getUserNameClaim() {
return _userNameClaim;
}
public String getUserNameClaimRegex() {
return _userNameClaimRegex;
}
public String getScope() {
return _scope;
}
public String getClientName() {
return _clientName;
}
private String getRequired(final com.typesafe.config.Config configs, final String path) {
if (!configs.hasPath(path)) {
throw new IllegalArgumentException(
String.format("Missing required OIDC config with path %s", path));
}
return configs.getString(path);
}
private String getOptional(final com.typesafe.config.Config configs,
final String path,
final String defaultVal) {
if (!configs.hasPath(path)) {
return defaultVal;
}
return configs.getString(path);
}
}

View File

@ -0,0 +1,122 @@
package react.controllers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.linkedin.common.urn.CorpuserUrn;
import com.linkedin.datahub.graphql.exception.ValidationException;
import com.typesafe.config.Config;
import org.apache.commons.lang3.StringUtils;
import org.pac4j.core.client.Client;
import org.pac4j.core.context.session.SessionStore;
import org.pac4j.core.exception.HttpAction;
import org.pac4j.play.PlayWebContext;
import org.pac4j.play.http.PlayHttpActionAdapter;
import play.Logger;
import play.libs.Json;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import react.auth.AuthUtils;
import react.auth.JAASConfigs;
import react.auth.OidcConfigs;
import security.AuthenticationManager;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import javax.naming.NamingException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import static react.auth.AuthUtils.*;
public class AuthenticationController extends Controller {
private final Config _configs;
private final OidcConfigs _oidcConfigs;
private final JAASConfigs _jaasConfigs;
@Inject
private org.pac4j.core.config.Config _ssoConfig;
@Inject
private SessionStore _playSessionStore;
@Inject
public AuthenticationController(@Nonnull Config configs) {
_configs = configs;
_oidcConfigs = new OidcConfigs(configs);
_jaasConfigs = new JAASConfigs(configs);
}
/**
* Route used to perform authentication, or redirect to log in if authentication fails.
*
* If indirect SSO (eg. oidc) is configured, this route will redirect to the identity provider (Indirect auth).
* If not, we will fallback to the default username / password login experience (Direct auth).
*/
@Nonnull
public Result authenticate() {
if (AuthUtils.isAuthenticated(ctx())) {
return redirect("/");
}
// 1. If indirect auth is enabled, redirect to IdP
if (_oidcConfigs.isOidcEnabled()) {
final PlayWebContext playWebContext = new PlayWebContext(ctx(), _playSessionStore);
final Client client = _ssoConfig.getClients().findClient(_oidcConfigs.getClientName());
final HttpAction action = client.redirect(playWebContext);
return new PlayHttpActionAdapter().adapt(action.getCode(), playWebContext);
}
// 2. If JAAS auth is enabled, fallback to it
if (_jaasConfigs.isJAASEnabled()) {
return redirect(LOGIN_ROUTE);
}
// 3. If no auth enabled, fallback to using default user account & redirect.
session().put(ACTOR, DEFAULT_ACTOR_URN.toString());
return redirect("/").withCookies(createActorCookie(DEFAULT_ACTOR_URN.toString(), _configs.hasPath(SESSION_TTL_CONFIG_PATH)
? _configs.getInt(SESSION_TTL_CONFIG_PATH)
: DEFAULT_SESSION_TTL_DAYS));
}
/**
* Log in a user based on a username + password.
*
* TODO: Implement built-in support for LDAP auth. Currently dummy jaas authentication is the default.
*/
@Nonnull
public Result logIn() {
if (!_jaasConfigs.isJAASEnabled()) {
final ObjectNode error = Json.newObject();
error.put("message", "JAAS authentication is not enabled on the server.");
return badRequest(error);
}
final JsonNode json = request().body().asJson();
final String username = json.findPath(USER_NAME).textValue();
final String password = json.findPath(PASSWORD).textValue();
if (StringUtils.isBlank(username)) {
throw new ValidationException("username must not be empty");
}
ctx().session().clear();
try {
AuthenticationManager.authenticateUser(username, password);
} catch (NamingException e) {
Logger.warn("Authentication error", e);
return badRequest("Invalid Credential");
}
final String actorUrn = new CorpuserUrn(username).toString();
ctx().session().put(ACTOR, actorUrn);
return ok().withCookies(Http.Cookie.builder(ACTOR, actorUrn)
.withHttpOnly(false)
.withMaxAge(Duration.of(30, ChronoUnit.DAYS))
.build());
}
}

View File

@ -1,4 +1,4 @@
package controllers.api.v2;
package react.controllers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
@ -6,28 +6,19 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.linkedin.datahub.graphql.GmsGraphQLEngine;
import com.linkedin.datahub.graphql.GraphQLEngine;
import com.typesafe.config.Config;
import react.auth.Authenticator;
import graphql.ExecutionResult;
import graphql.PlayQueryContext;
import graphql.resolvers.mutation.LogInResolver;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import react.graphql.PlayQueryContext;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import org.apache.commons.io.IOUtils;
import play.api.Environment;
import play.mvc.Controller;
import play.mvc.Result;
import play.mvc.Security;
public class GraphQLController extends Controller {
private static final String FRONTEND_SCHEMA_NAME = "datahub-frontend.graphql";
private static final String MUTATION_TYPE = "Mutation";
private static final String LOG_IN_MUTATION = "logIn";
private static final String QUERY = "query";
private static final String VARIABLES = "variables";
@ -36,33 +27,14 @@ public class GraphQLController extends Controller {
@Inject
public GraphQLController(@Nonnull Environment environment, @Nonnull Config config) {
/*
* Fetch path to custom GraphQL Type System Definition
*/
String schemaString;
try {
final InputStream is = environment.resourceAsStream(FRONTEND_SCHEMA_NAME).get();
schemaString = IOUtils.toString(is, StandardCharsets.UTF_8);
is.close();
} catch (IOException e) {
throw new RuntimeException("Failed to find GraphQL Schema with name " + FRONTEND_SCHEMA_NAME, e);
}
/*
* Instantiate engine
*/
_engine = GmsGraphQLEngine.builder()
.addSchema(schemaString)
.configureRuntimeWiring(builder ->
builder.type(MUTATION_TYPE,
typeWiring -> typeWiring.dataFetcher(
LOG_IN_MUTATION,
new LogInResolver(GmsGraphQLEngine.CORP_USER_TYPE))))
.build();
_engine = GmsGraphQLEngine.get();
_config = config;
}
@Security.Authenticated(Authenticator.class)
@Nonnull
public Result execute() throws Exception {
@ -91,7 +63,7 @@ public class GraphQLController extends Controller {
/*
* Init QueryContext
*/
PlayQueryContext context = new PlayQueryContext(session(), _config);
PlayQueryContext context = new PlayQueryContext(ctx(), _config);
/*
* Execute GraphQL Query

View File

@ -1,27 +1,25 @@
package graphql;
package react.graphql;
import com.typesafe.config.Config;
import play.mvc.Http;
import com.linkedin.datahub.graphql.QueryContext;
import static security.AuthConstants.AUTH_TOKEN;
import static security.AuthConstants.USER;
import static react.auth.AuthUtils.*;
/**
* Provides session context to components of the GraphQL Engine at runtime.
*/
public class PlayQueryContext implements QueryContext {
private final Http.Session _session;
private final Http.Context _context;
private final Config _appConfig;
public PlayQueryContext(Http.Session session) {
this(session, null);
public PlayQueryContext(Http.Context context) {
this(context, null);
}
public PlayQueryContext(Http.Session session, Config appConfig) {
_session = session;
public PlayQueryContext(Http.Context context, Config appConfig) {
_context = context;
_appConfig = appConfig;
}
@ -30,7 +28,7 @@ public class PlayQueryContext implements QueryContext {
*/
@Override
public boolean isAuthenticated() {
return getSession().containsKey(AUTH_TOKEN); // TODO: Compute this once by validating the signed auth token.
return _context.session().containsKey(ACTOR);
}
/**
@ -38,14 +36,21 @@ public class PlayQueryContext implements QueryContext {
*/
@Override
public String getActor() {
return _session.get(USER);
return _context.session().get(ACTOR);
}
/**
* Retrieves the {@link Http.Session} object associated with the current user.
*/
public Http.Session getSession() {
return _session;
return _context.session();
}
/**
* Retrieves the {@link Http.Context} object associated with the current request.
*/
public Http.Context getPlayContext() {
return _context;
}
/**
@ -54,19 +59,4 @@ public class PlayQueryContext implements QueryContext {
public Config getAppConfig() {
return _appConfig;
}
/**
* Retrieves the user name associated with the current user.
*/
public String getUserName() {
return _session.get(USER);
}
/**
* Retrieves the hashed auth token associated with the current user.
*/
public String getAuthToken() {
return _session.get(AUTH_TOKEN);
}
}

View File

@ -1,16 +0,0 @@
package security;
public class AuthConstants {
public static final String SECRET_KEY_PROPERTY = "play.http.secret.key";
public static final String USER_NAME = "username";
public static final String PASSWORD = "password";
public static final String USER = "user";
public static final String UUID = "uuid";
public static final String AUTH_TOKEN = "auth_token";
private AuthConstants() { }
}

View File

@ -24,6 +24,7 @@ play.http.parser.maxMemoryBuffer = ${DATAHUB_PLAY_MEM_BUFFER_SIZE}
# TODO: Disable legacy URL encoding eventually
play.modules.disabled += "play.api.mvc.CookiesModule"
play.modules.enabled += "play.api.mvc.LegacyCookiesModule"
play.modules.enabled += "react.auth.AuthModule"
# Database configuration
# ~~~~~
@ -95,4 +96,41 @@ ui.show.advanced.search = true
ui.show.institutional.memory = true
ui.new.browse.dataset = true
ui.new.browse.dataset = true
# React App Authentication
# ~~~~~
# React currently supports OIDC SSO + self-configured JAAS (same as Ember) for authentication. Below you can find the supported configurations for
# each mechanism.
#
# Required OIDC Configuration Values:
#
auth.oidc.enabled = ${?AUTH_OIDC_ENABLED} # Enable OIDC authentication, disabled by default
auth.oidc.clientId = ${?AUTH_OIDC_CLIENT_ID} # Unique client id issued by the identity provider
auth.oidc.clientSecret = ${?AUTH_OIDC_CLIENT_SECRET} # Unique client secret issued by the identity provider.
auth.oidc.discoveryUri = ${?AUTH_OIDC_DISCOVERY_URI} # The IdP OIDC discovery url.
auth.baseUrl = ${?AUTH_OIDC_BASE_URL} # The base URL associated with your DataHub deployment.
#
# Optional OIDC Configuration Values:
#
auth.oidc.userNameClaim = ${?AUTH_OIDC_USER_NAME_CLAIM} # The attribute / claim used to derive the DataHub username. Defaults to "preferred_username".
auth.oidc.userNameClaimRegex = ${?AUTH_OIDC_USER_NAME_CLAIM_REGEX} # The regex used to parse the DataHub username from the user name claim. Defaults to (.*) (all)
auth.oidc.scope = ${?AUTH_OIDC_SCOPE} # String representing the requested scope from the IdP. Defaults to "oidc email profile"
#
# By default, the callback URL that should be registered with the identity provider is computed as {$baseUrl}/callback/oidc.
# For example, the default callback URL for a local deployment of DataHub would be "http://localhost:9002/callback/oidc".
# This callback URL should be registered with the identity provider during configuration.
#
#
# JAAS Optional Configuration Values
#
# In addition to OIDC, JAAS authentication with a dummy login module is enabled by default. Currently, this accepts
# any username / password combination as valid credentials. To disable this entry point altogether, specify the following config:
#
auth.jaas.enabled = ${?AUTH_JAAS_ENABLED}
#
# To disable all authentication to the app, and proxy all users through a master "datahub" account, make sure that both
# jaas and oidc auth are disabled:
#
# auth.jaas.enabled = false
# auth.oidc.enabled = false # (or simply omit oidc configurations)

View File

@ -1,5 +0,0 @@
# This will host GQL schema extensions specific to the frontend.
extend type Mutation {
logIn(username: String!, password: String!): CorpUser
}

View File

@ -37,8 +37,17 @@ GET /api/v2/datasets/:urn/snapshot co
GET /api/v2/datasets/:urn/upstreams controllers.api.v2.Dataset.getDatasetUpstreams(urn: String)
GET /api/v2/list/platforms controllers.api.v2.Dataset.getDataPlatforms
GET /api/v2/search controllers.api.v2.Search.search()
POST /api/v2/graphql controllers.api.v2.GraphQLController.execute()
# Routes used exclusively by the React application.
# Authentication in React
GET /authenticate react.controllers.AuthenticationController.authenticate()
POST /logIn react.controllers.AuthenticationController.logIn()
GET /callback/oidc @org.pac4j.play.CallbackController.callback()
POST /callback/oidc @org.pac4j.play.CallbackController.callback()
# Data fetching in React
POST /api/v2/graphql react.controllers.GraphQLController.execute()
GET /api/*path controllers.Application.apiNotFound(path)
POST /api/*path controllers.Application.apiNotFound(path)

View File

@ -29,6 +29,11 @@ dependencies {
play externalDependency.jerseyCore
play externalDependency.jerseyGuava
play externalDependency.pac4j
play externalDependency.playPac4j
play externalDependency.shiroCore
play externalDependency.playCache
playTest externalDependency.mockito
playTest externalDependency.playTest

View File

@ -1,7 +1,6 @@
overwrite: true
schema:
- '../datahub-graphql-core/src/main/resources/gms.graphql'
- '../datahub-frontend/conf/datahub-frontend.graphql'
config:
scalars:
Long: number

View File

@ -21,6 +21,9 @@
"antd": "^4.9.4",
"craco-antd": "^1.19.0",
"dotenv": "^8.2.0",
"apollo-link": "^1.2.14",
"apollo-link-error": "^1.1.13",
"apollo-link-http": "^1.5.17",
"graphql": "^15.4.0",
"history": "^5.0.0",
"js-cookie": "^2.2.1",

View File

@ -1,23 +1,26 @@
import React, { useEffect, useMemo, useState } from 'react';
import Cookies from 'js-cookie';
import { BrowserRouter as Router } from 'react-router-dom';
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
import { ApolloClient, ApolloProvider, createHttpLink, InMemoryCache, ServerError } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { MockedProvider } from '@apollo/client/testing';
import { ThemeProvider } from 'styled-components';
import './App.less';
import { Routes } from './app/Routes';
import { mocks } from './Mocks';
import EntityRegistry from './app/entity/EntityRegistry';
import { DashboardEntity } from './app/entity/dashboard/DashboardEntity';
import { ChartEntity } from './app/entity/chart/ChartEntity';
import { UserEntity } from './app/entity/user/User';
import { DatasetEntity } from './app/entity/dataset/DatasetEntity';
import { TagEntity } from './app/entity/tag/Tag';
import EntityRegistry from './app/entity/EntityRegistry';
import { EntityRegistryContext } from './entityRegistryContext';
import { Theme } from './conf/theme/types';
import defaultThemeConfig from './conf/theme/theme_light.config.json';
import { PageRoutes } from './conf/Global';
import { isLoggedInVar } from './app/auth/checkAuthStatus';
import { GlobalCfg } from './conf';
// Enable to use the Apollo MockProvider instead of a real HTTP client
const MOCK_MODE = false;
@ -25,8 +28,21 @@ const MOCK_MODE = false;
/*
Construct Apollo Client
*/
const httpLink = createHttpLink({ uri: '/api/v2/graphql' });
const errorLink = onError(({ networkError }) => {
if (networkError) {
const serverError = networkError as ServerError;
if (serverError.statusCode === 401) {
isLoggedInVar(false);
Cookies.remove(GlobalCfg.CLIENT_AUTH_COOKIE);
window.location.replace(PageRoutes.AUTHENTICATE);
}
}
});
const client = new ApolloClient({
uri: '/api/v2/graphql',
link: errorLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Dataset: {

View File

@ -5,7 +5,6 @@ import {
GetSearchResultsDocument,
GetSearchResultsQuery,
} from './graphql/search.generated';
import { LoginDocument } from './graphql/auth.generated';
import { GetUserDocument } from './graphql/user.generated';
import { Dataset, EntityType, PlatformType } from './types.generated';
import { GetTagDocument } from './graphql/tag.generated';
@ -266,22 +265,6 @@ const sampleTag = {
Define mock data to be returned by Apollo MockProvider.
*/
export const mocks = [
{
request: {
query: LoginDocument,
variables: {
username: 'datahub',
password: 'datahub',
},
},
result: {
data: {
login: {
...user1,
},
},
},
},
{
request: {
query: GetDatasetDocument,

View File

@ -4,12 +4,12 @@ import { useReactiveVar } from '@apollo/client';
import { BrowseResultsPage } from './browse/BrowseResultsPage';
import { LogIn } from './auth/LogIn';
import { NoPageFound } from './shared/NoPageFound';
import { isLoggedInVar } from './auth/checkAuthStatus';
import { EntityPage } from './entity/EntityPage';
import { PageRoutes } from '../conf/Global';
import { useEntityRegistry } from './useEntityRegistry';
import { HomePage } from './home/HomePage';
import { SearchPage } from './search/SearchPage';
import { isLoggedInVar } from './auth/checkAuthStatus';
const ProtectedRoute = ({
isLoggedIn,
@ -18,7 +18,8 @@ const ProtectedRoute = ({
isLoggedIn: boolean;
} & RouteProps) => {
if (!isLoggedIn) {
return <Redirect to={PageRoutes.LOG_IN} />;
window.location.replace(PageRoutes.AUTHENTICATE);
return null;
}
return <Route {...props} />;
};
@ -34,6 +35,7 @@ export const Routes = (): JSX.Element => {
<div>
<Switch>
<ProtectedRoute isLoggedIn={isLoggedIn} exact path="/" render={() => <HomePage />} />
<Route path={PageRoutes.LOG_IN} component={LogIn} />
{entityRegistry.getEntities().map((entity) => (
<ProtectedRoute

View File

@ -1,13 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { Input, Button, Form, message, Image } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { ApolloError, useReactiveVar } from '@apollo/client';
import Cookies from 'js-cookie';
import { useReactiveVar } from '@apollo/client';
import { useTheme } from 'styled-components';
import { Redirect } from 'react-router';
import styles from './login.module.css';
import { useLoginMutation } from '../../graphql/auth.generated';
import { Message } from '../shared/Message';
import { isLoggedInVar } from './checkAuthStatus';
@ -20,29 +17,32 @@ export type LogInProps = Record<string, never>;
export const LogIn: React.VFC<LogInProps> = () => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
const themeConfig = useTheme();
const [loginMutation, { loading }] = useLoginMutation({});
const handleLogin = useCallback(
(values: FormValues) => {
loginMutation({
variables: {
username: values.username,
password: values.password,
},
const themeConfig = useTheme();
const [loading, setLoading] = useState(false);
const handleLogin = useCallback((values: FormValues) => {
setLoading(true);
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: values.username, password: values.password }),
};
fetch('/logIn', requestOptions)
.then(async (response) => {
if (!response.ok) {
const data = await response.json();
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
isLoggedInVar(true);
return Promise.resolve();
})
.then((res) => {
Cookies.set('PLAY_SESSION', 'DUMMY_VALUE'); // TODO: Validate that this works in non mock mode.
Cookies.set('IS_LOGGED_IN', 'true');
localStorage.setItem('userUrn', res.data?.logIn?.urn || '');
isLoggedInVar(true);
})
.catch((e: ApolloError) => {
message.error(e.message);
});
},
[loginMutation],
);
.catch((error) => {
message.error(`Failed to log in! ${error}`);
})
.finally(() => setLoading(false));
}, []);
if (isLoggedIn) {
return <Redirect to="/" />;

View File

@ -1,12 +1,9 @@
import Cookies from 'js-cookie';
import { makeVar } from '@apollo/client';
import { GlobalCfg } from '../../conf';
export const checkAuthStatus = (): boolean => {
// Check if we have a valid token.
// TODO: perhaps there's a more robust way to detect this?
// e.g. what happens if the PLAY_SESSION cookie is stuck but the session is
// invalid or expired?
return !!Cookies.get('IS_LOGGED_IN');
return !!Cookies.get(GlobalCfg.CLIENT_AUTH_COOKIE);
};
export const isLoggedInVar = makeVar(checkAuthStatus());

View File

@ -60,7 +60,11 @@ export const HomePageHeader = () => {
<Background direction="vertical">
<Row justify="space-between" style={styles.navBar}>
<WelcomeText>
Welcome back, <b>{data?.corpUser?.info?.firstName || data?.corpUser?.username}</b>.
{data && (
<>
Welcome back, <b>{data?.corpUser?.info?.firstName || data?.corpUser?.username}</b>.
</>
)}
</WelcomeText>
<ManageAccount
urn={data?.corpUser?.urn || ''}

View File

@ -16,17 +16,6 @@ describe('HomePage', () => {
);
});
it('renders greeting message', async () => {
const { getByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<TestPageContainer>
<HomePage />
</TestPageContainer>
</MockedProvider>,
);
await waitFor(() => expect(getByText('Welcome back, .')).toBeInTheDocument());
});
it('renders browsable entities', async () => {
const { getByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>

View File

@ -6,6 +6,7 @@ import defaultAvatar from '../../images/default_avatar.png';
import { EntityType } from '../../types.generated';
import { useEntityRegistry } from '../useEntityRegistry';
import { isLoggedInVar } from '../auth/checkAuthStatus';
import { GlobalCfg } from '../../conf';
interface Props {
urn: string;
@ -20,9 +21,8 @@ export const ManageAccount = ({ urn: _urn, pictureLink: _pictureLink }: Props) =
const entityRegistry = useEntityRegistry();
const handleLogout = () => {
Cookies.remove('IS_LOGGED_IN');
localStorage.removeItem('userUrn');
isLoggedInVar(false);
Cookies.remove(GlobalCfg.CLIENT_AUTH_COOKIE);
};
const menu = (

View File

@ -1,6 +1,16 @@
import React from 'react';
import { useReactiveVar } from '@apollo/client';
import { Redirect } from 'react-router';
import { isLoggedInVar } from '../auth/checkAuthStatus';
import { PageRoutes } from '../../conf/Global';
export const NoPageFound = () => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
if (!isLoggedIn) {
return <Redirect to={PageRoutes.LOG_IN} />;
}
return (
<div>
<p>Page Not Found!</p>

View File

@ -1,8 +1,10 @@
import Cookies from 'js-cookie';
import { CLIENT_AUTH_COOKIE } from '../conf/Global';
import { useGetUserQuery } from '../graphql/user.generated';
/**
* Fetch a CorpUser object corresponding to the currently authenticated user.
*/
export function useGetAuthenticatedUser() {
return useGetUserQuery({ variables: { urn: localStorage.getItem('userUrn') as string } });
return useGetUserQuery({ variables: { urn: Cookies.get(CLIENT_AUTH_COOKIE) || '' } });
}

View File

@ -2,6 +2,10 @@
Default top-level page route names (excludes entity pages)
*/
export enum PageRoutes {
/**
* Server-side authentication route
*/
AUTHENTICATE = '/authenticate',
LOG_IN = '/login',
SEARCH_RESULTS = '/search/:type?',
SEARCH = '/search',
@ -10,3 +14,8 @@ export enum PageRoutes {
DATASETS = '/datasets',
ASSETS = '/assets',
}
/**
* Name of the auth cookie checked on client side (contains the currently authenticated user urn).
*/
export const CLIENT_AUTH_COOKIE = 'actor';

View File

@ -1,10 +0,0 @@
mutation login($username: String!, $password: String!) {
logIn(username: $username, password: $password) {
urn
username
info {
displayName
email
}
}
}

View File

@ -25,6 +25,7 @@ export function getTestEntityRegistry() {
export default ({ children, initialEntries }: Props) => {
const entityRegistry = useMemo(() => getTestEntityRegistry(), []);
return (
<ThemeProvider theme={defaultThemeConfig}>
<MemoryRouter initialEntries={initialEntries}>

View File

@ -3737,6 +3737,13 @@
dependencies:
tslib "^1.14.1"
"@wry/equality@^0.1.2":
version "0.1.11"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790"
integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA==
dependencies:
tslib "^1.9.3"
"@wry/equality@^0.3.0":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.3.1.tgz"
@ -4020,6 +4027,53 @@ anymatch@^3.0.3, anymatch@~3.1.1:
normalize-path "^3.0.0"
picomatch "^2.0.4"
apollo-link-error@^1.1.13:
version "1.1.13"
resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.13.tgz#c1a1bb876ffe380802c8df0506a32c33aad284cd"
integrity sha512-jAZOOahJU6bwSqb2ZyskEK1XdgUY9nkmeclCrW7Gddh1uasHVqmoYc4CKdb0/H0Y1J9lvaXKle2Wsw/Zx1AyUg==
dependencies:
apollo-link "^1.2.14"
apollo-link-http-common "^0.2.16"
tslib "^1.9.3"
apollo-link-http-common@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz#756749dafc732792c8ca0923f9a40564b7c59ecc"
integrity sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg==
dependencies:
apollo-link "^1.2.14"
ts-invariant "^0.4.0"
tslib "^1.9.3"
apollo-link-http@^1.5.17:
version "1.5.17"
resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.17.tgz#499e9f1711bf694497f02c51af12d82de5d8d8ba"
integrity sha512-uWcqAotbwDEU/9+Dm9e1/clO7hTB2kQ/94JYcGouBVLjoKmTeJTUPQKcJGpPwUjZcSqgYicbFqQSoJIW0yrFvg==
dependencies:
apollo-link "^1.2.14"
apollo-link-http-common "^0.2.16"
tslib "^1.9.3"
apollo-link@^1.2.14:
version "1.2.14"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9"
integrity sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg==
dependencies:
apollo-utilities "^1.3.0"
ts-invariant "^0.4.0"
tslib "^1.9.3"
zen-observable-ts "^0.8.21"
apollo-utilities@^1.3.0:
version "1.3.4"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.4.tgz#6129e438e8be201b6c55b0f13ce49d2c7175c9cf"
integrity sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig==
dependencies:
"@wry/equality" "^0.1.2"
fast-json-stable-stringify "^2.0.0"
ts-invariant "^0.4.0"
tslib "^1.10.0"
app-root-dir@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz"
@ -16002,6 +16056,13 @@ ts-essentials@^2.0.3:
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz"
integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==
ts-invariant@^0.4.0:
version "0.4.4"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==
dependencies:
tslib "^1.9.3"
ts-invariant@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.6.0.tgz"
@ -17227,7 +17288,15 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zen-observable@^0.8.14:
zen-observable-ts@^0.8.21:
version "0.8.21"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d"
integrity sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg==
dependencies:
tslib "^1.9.3"
zen-observable "^0.8.0"
zen-observable@^0.8.0, zen-observable@^0.8.14:
version "0.8.15"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==

View File

@ -4,3 +4,4 @@ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEX
KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0

View File

@ -8,4 +8,19 @@ DATAHUB_PLAY_MEM_BUFFER_SIZE=10MB
# NOTE: Currently GMS itself does not offer SSL support, these settings are intended for when there is a proxy in front
# of GMS that handles SSL, such as an EC2 Load Balancer.
#DATAHUB_GMS_USE_SSL=true
#DATAHUB_GMS_SSL_PROTOCOL=
#DATAHUB_GMS_SSL_PROTOCOL=
# Uncomment & populate these configs to enable OIDC SSO in React application.
# Required OIDC configs
# AUTH_OIDC_ENABLED=true
# AUTH_OIDC_CLIENT_ID=1030786188615-rr9ics9gl8n4acngj9opqbf2mruflqpr.apps.googleusercontent.com
# AUTH_OIDC_CLIENT_SECRET=acEdaGcnfd7KxvsXRFDD7FNF
# AUTH_OIDC_DISCOVERY_URI=https://accounts.google.com/.well-known/openid-configuration
# AUTH_OIDC_BASE_URL=http://localhost:9001
# Optional OIDC configs
# AUTH_OIDC_USER_NAME_CLAIM=email
# AUTH_OIDC_USER_NAME_CLAIM_REGEX=([^@]+)
# AUTH_OIDC_SCOPE=
# Uncomment to disable JAAS username / password authentication (enabled by default)
# AUTH_JAAS_ENABLED=false

View File

@ -76,6 +76,9 @@ module.exports = {
"docs/demo/graph-onboarding",
"docs/how/search-onboarding",
"docs/how/search-over-new-field",
"docs/how/configure-oidc-react",
"docs/how/sso/configure-oidc-react-google",
"docs/how/sso/configure-oidc-react-okta",
],
Components: [
"datahub-web-react/README",

View File

@ -0,0 +1,120 @@
# OIDC Authentication in React
The DataHub React application supports OIDC authentication built on top of the [Pac4j Play](https://github.com/pac4j/play-pac4j) library.
This enables operators of DataHub to integrate with 3rd party identity providers like Okta, Google, Keycloak, & more to authenticate their users.
When configured, OIDC auth will be enabled between clients of the DataHub UI & `datahub-frontend` server. Beyond this point is considered
to be a secure environment and as such authentication is validated & enforced only at the "front door" inside datahub-frontend.
## Provider-Specific Guides
1. [Configuring OIDC using Google](./sso/configure-oidc-react-google.md)
2. [Configuring OIDC using Okta](./sso/configure-oidc-react-okta.md)
## Configuring OIDC in React
### 1. Register an app with your Identity Provider
To configure OIDC in React, you will most often need to register yourself as a client with your identity provider (Google, Okta, etc). Each provider may
have their own instructions. Provided below are links to examples for Okta, Google, Azure AD, & Keycloak.
- [Registering an App in Okta](https://developer.okta.com/docs/guides/add-an-external-idp/apple/register-app-in-okta/)
- [OpenID Connect in Google Identity](https://developers.google.com/identity/protocols/oauth2/openid-connect)
- [OpenID Connect authentication with Azure Active Directory](https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/auth-oidc)
- [Keycloak - Securing Applications and Services Guide](https://www.keycloak.org/docs/latest/securing_apps/)
During the registration process, you'll need to provide a login redirect URI to the identity provider. This tells the identity provider
where to redirect to once they've authenticated the end user.
By default, the URL will be constructed as follows:
> "http://your-datahub-domain.com/callback/oidc"
For example, if you're hosted DataHub at `datahub.myorg.com`, this
value would be `http://datahub.myorg.com/callback/oidc`. For testing purposes you can also specify localhost as the domain name
directly: `http://localhost:9002/callback/oidc`
The goal of this step should be to obtain the following values, which will need to be configured before deploying DataHub:
1. **Client ID** - A unique identifier for your application with the identity provider
2. **Client Secret** - A shared secret to use for exchange between you and your identity provider
3. **Discovery URL** - A URL where the OIDC API of your identity provider can be discovered. This should suffixed by
`.well-known/openid-configuration`. Sometimes, identity providers will not explicitly include this URL in their setup guides, though
this endpoint *will* exist as per the OIDC specification. For more info see http://openid.net/specs/openid-connect-discovery-1_0.html.
### 2. Configure DataHub Frontend Server
The second step to enabling OIDC involves configuring `datahub-frontend` to enable OIDC authentication with your Identity Provider.
To do so, you must update the `datahub-frontend` [docker.env](../../docker/datahub-frontend/env/docker.env) file with the
values received from your identity provider:
```
# Required Configuration Values:
AUTH_OIDC_ENABLED=true
AUTH_OIDC_CLIENT_ID=your-client-id
AUTH_OIDC_CLIENT_SECRET=your-client-secret
AUTH_OIDC_DISCOVERY_URI=your-provider-discovery-url
AUTH_OIDC_BASE_URL=your-datahub-url
```
- `AUTH_OIDC_ENABLED`: Enable delegating authentication to OIDC identity provider
- `AUTH_OIDC_CLIENT_ID`: Unique client id received from identity provider
- `AUTH_OIDC_CLIENT_SECRET`: Unique client secret received from identity provider
- `AUTH_OIDC_DISCOVERY_URI`: Location of the identity provider OIDC discovery API. Suffixed with `.well-known/openid-configuration`
- `AUTH_OIDC_BASE_URL`: The base URL of your DataHub deployment, e.g. https://yourorgdatahub.com (prod) or http://localhost:9002 (testing)
Providing these configs will cause DataHub to delegate authentication to your identity
provider, requesting the "oidc email profile" scopes and parsing the "preferred_username" claim from
the authenticated profile as the DataHub CorpUser identity.
> By default, the login callback endpoint exposed by DataHub will be located at `${baseUrl}/callback/oidc`. This must **exactly** match the login redirect URL you've registered with your identity provider in step 1.
#### Advanced
You can optionally customize the flow further using advanced configurations. These allow
you to specify the OIDC scopes requested & how the DataHub username is parsed from the claims returned by the identity provider.
```
# Optional Configuration Values:
AUTH_OIDC_USER_NAME_CLAIM=your-custom-claim
AUTH_OIDC_USER_NAME_CLAIM_REGEX=your-custom-regex
AUTH_OIDC_SCOPE=your-custom-scope
```
- `AUTH_OIDC_USER_NAME_CLAIM`: The attribute that will contain the username used on the DataHub platform. By default, this is "preferred_username" provided
as part of the standard `profile` scope.
- `AUTH_OIDC_USER_NAME_CLAIM_REGEX`: A regex string used for extracting the username from the userNameClaim attribute. For example, if
the userNameClaim field will contain an email address, and we want to omit the domain name suffix of the email, we can specify a custom
regex to do so. (e.g. `([^@]+)`)
- `AUTH_OIDC_SCOPE`: a string representing the scopes to be requested from the identity provider, granted by the end user. For more info,
see [OpenID Connect Scopes](https://auth0.com/docs/scopes/openid-connect-scopes).
Once configuration has been updated, `datahub-frontend` will need to be rebuilt (either by rebuilding the `datahub-frontend-react` container or via `./gradlew :datahub-frontend:build -PenableReact=true` for testing)
>Note that by default, enabling OIDC will *not* disable the dummy JAAS authentication path, which can be reached at the `/login`
route of the React app. To disable this authentication path, additionally specify the following config:
> `auth.jaas.enabled = false`
### Summary
Once configured, deploying `datahub-frontend` to serve React will enable an indirect authentication flow in which DataHub delegates
authentication to the specified identity provider.
Once a user is authenticated by the identity provider, DataHub will extract a username from the provided claims
and grant DataHub access to the user by setting a pair of session cookies.
A brief summary of the steps that occur when the user navigates to the React app are as follows:
1. A `GET` to the `/authenticate` endpoint in `datahub-frontend` server is initiated
2. The `/authenticate` attempts to authenticate the request via session cookies
3. If auth fails, the server issues a redirect to the Identity Provider's login experience
4. The user logs in with the Identity Provider
5. The Identity Provider authenticates the user and redirects back to DataHub's registered login redirect URL, providing an authorization code which
can be used to retrieve information on behalf of the authenticated user
6. DataHub fetches the authenticated user's profile and extracts a username to identify the user on DataHub (eg. urn:li:corpuser:username)
7. DataHub sets session cookies for the newly authenticated user
8. DataHub redirects the user to the homepage ("/")

View File

@ -0,0 +1,102 @@
# Configuring Google Authentication for React App (OIDC)
*Authored on 3/10/2021*
`datahub-frontend` server can be configured to authenticate users over OpenID Connect (OIDC). As such, it can be configured to delegate
authentication responsibility to identity providers like Google.
This guide will provide steps for configuring DataHub authentication using Google.
## Steps
### 1. Create a project in the Google API Console
Using an account linked to your organization, navigate to the [Google API Console](https://console.developers.google.com/) and select **New project**.
Within this project, we will configure the OAuth2.0 screen and credentials.
### 2. Create OAuth2.0 consent screen
a. Navigate to `OAuth consent screen`. This is where you'll configure the screen your users see when attempting to
log in to DataHub.
b. Select `Internal` (if you only want your company users to have access) and then click **Create**.
Note that in order to complete this step you should be logged into a Google account associated with your organization.
c. Fill out the details in the App Information & Domain sections. Make sure the 'Application Home Page' provided matches where DataHub is deployed
at your organization.
![google-setup-1](./img/google-setup-1.png)
Once you've completed this, **Save & Continue**.
d. Configure the scopes: Next, click **Add or Remove Scopes**. Select the following scopes:
- `.../auth/userinfo.email`
- `.../auth/userinfo.profile`
- `openid`
Once you've selected these, **Save & Continue**.
### 3. Configure client credentials
Now navigate to the **Credentials** tab. This is where you'll obtain your client id & secret, as well as configure info
like the redirect URI used after a user is authenticated.
a. Click **Create Credentials** & select `OAuth client ID` as the credential type.
b. On the following screen, select `Web application` as your Application Type.
c. Add the domain where DataHub is hosted to your 'Authorized Javascript Origins'.
```
https://your-datahub-domain.com
```
d. Add the domain where DataHub is hosted with the path `/callback/oidc` appended to 'Authorized Redirect URLs'.
```
https://your-datahub-domain.com/callback/oidc
```
e. Click **Create**
f. You will now receive a pair of values, a client id and a client secret. Bookmark these for the next step.
At this point, you should be looking at a screen like the following:
![google-setup-2](./img/google-setup-2.png)
Success!
### 4. Configure `datahub-frontend` to enable OIDC authentication
a. Open the file `docker/datahub-frontend/env/docker.env`
b. Add the following configuration values to the file:
```
AUTH_OIDC_ENABLED=true
AUTH_OIDC_CLIENT_ID=your-client-id
AUTH_OIDC_CLIENT_SECRET=your-client-secret
AUTH_OIDC_DISCOVERY_URI=https://accounts.google.com/.well-known/openid-configuration
AUTH_OIDC_BASE_URL=your-datahub-url
AUTH_OIDC_USER_NAME_CLAIM=email
AUTH_OIDC_USER_NAME_CLAIM_REGEX=([^@]+)
```
Replacing the placeholders above with the client id & client secret received from Google in Step 3f.
### 5. Restart `datahub-frontend-react` docker container
Now, simply restart the `datahub-frontend-react` container to enable the integration.
```
docker-compose restart datahub-frontend-react
```
Navigate to your DataHub domain to see SSO in action.
## References
- [OpenID Connect in Google Identity](https://developers.google.com/identity/protocols/oauth2/openid-connect)

View File

@ -0,0 +1,83 @@
# Configuring Okta Authentication for React App (OIDC)
*Authored on 3/10/2021*
`datahub-frontend` server can be configured to authenticate users over OpenID Connect (OIDC). As such, it can be configured to
delegate authentication responsibility to identity providers like Okta.
This guide will provide steps for configuring DataHub authentication using Okta.
## Steps
### 1. Create an application in Okta Developer Console
a. Log in to your Okta admin account & navigate to the developer console
b. Select **Applications**, then **Add Application**, the **Create New App** to create a new app.
c. Select `Web` as the **Platform**, and `OpenID Connect` as the **Sign on method**
d. Click **Create**
e. Under 'General Settings', name your application
f. Below, add a **Login Redirect URI**. This should be formatted as
```
https://your-datahub-domain.com/callback/oidc
```
If you're just testing locally, this can be `http://localhost:9002/callback/oidc`.
f. Click **Save**
### 2. Obtain Client Credentials
On the subsequent screen, you should see the client credentials. Bookmark the `Client id` and `Client secret` for the next step.
### 3. Obtain Discovery URI
On the same page, you should see an `Okta Domain`. Your OIDC discovery URI will be formatted as follows:
```
https://your-okta-domain.com/.well-known/openid-configuration
```
for example, `https://dev-33231928.okta.com/.well-known/openid-configuration`.
At this point, you should be looking at a screen like the following:
![okta-setup-1](./img/okta-setup-1.png)
![okta-setup-2](./img/okta-setup-2.png)
Success!
### 4. Configure `datahub-frontend` to enable OIDC authentication
a. Open the file `docker/datahub-frontend/env/docker.env`
b. Add the following configuration values to the file:
```
AUTH_OIDC_ENABLED=true
AUTH_OIDC_CLIENT_ID=your-client-id
AUTH_OIDC_CLIENT_SECRET=your-client-secret
AUTH_OIDC_DISCOVERY_URI=https://your-okta-domain.com/.well-known/openid-configuration
AUTH_OIDC_BASE_URL=your-datahub-url
```
Replacing the placeholders above with the client id & client secret received from Google in Step 3f.
### 5. Restart `datahub-frontend-react` docker container
Now, simply restart the `datahub-frontend-react` container to enable the integration.
```
docker-compose restart datahub-frontend-react
```
Navigate to your DataHub domain to see SSO in action.
## Resources
- [OAuth 2.0 and OpenID Connect Overview](https://developer.okta.com/docs/concepts/oauth-openid/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

View File

@ -111,7 +111,7 @@ For more details about the GraphQL Schemas & Types, see [here](https://graphql.o
### Configuring a GraphQL Endpoint
All GraphQL queries are serviced via a single endpoint. We place the new POST route in `datahub-frontend/conf/routes`:
```POST /api/v2/graphql controllers.api.v2.GraphQLController.execute()```
```POST /api/v2/graphql react.controllers.GraphQLController.execute()```
We also provide an implementation of a GraphQL Play Controller, exposing an "execute" method. The controller is responsible for
- parsing & validating incoming queries