mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-26 17:37:33 +00:00
feat(react): SSO support simple OIDC authentication (#2190)
Co-authored-by: John Joyce <john@acryl.io>
This commit is contained in:
parent
e659f749f2
commit
08616cc610
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
165
datahub-frontend/app/react/auth/AuthModule.java
Normal file
165
datahub-frontend/app/react/auth/AuthModule.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
47
datahub-frontend/app/react/auth/AuthUtils.java
Normal file
47
datahub-frontend/app/react/auth/AuthUtils.java
Normal 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() { }
|
||||
|
||||
}
|
||||
27
datahub-frontend/app/react/auth/Authenticator.java
Normal file
27
datahub-frontend/app/react/auth/Authenticator.java
Normal 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();
|
||||
}
|
||||
}
|
||||
24
datahub-frontend/app/react/auth/JAASConfigs.java
Normal file
24
datahub-frontend/app/react/auth/JAASConfigs.java
Normal 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;
|
||||
}
|
||||
}
|
||||
124
datahub-frontend/app/react/auth/OidcConfigs.java
Normal file
124
datahub-frontend/app/react/auth/OidcConfigs.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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() { }
|
||||
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# This will host GQL schema extensions specific to the frontend.
|
||||
|
||||
extend type Mutation {
|
||||
logIn(username: String!, password: String!): CorpUser
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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="/" />;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 || ''}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) || '' } });
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
mutation login($username: String!, $password: String!) {
|
||||
logIn(username: $username, password: $password) {
|
||||
urn
|
||||
username
|
||||
info {
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ export function getTestEntityRegistry() {
|
||||
|
||||
export default ({ children, initialEntries }: Props) => {
|
||||
const entityRegistry = useMemo(() => getTestEntityRegistry(), []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={defaultThemeConfig}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
|
||||
@ -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==
|
||||
|
||||
1
docker/broker/env/docker.env
vendored
1
docker/broker/env/docker.env
vendored
@ -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
|
||||
|
||||
|
||||
17
docker/datahub-frontend/env/docker.env
vendored
17
docker/datahub-frontend/env/docker.env
vendored
@ -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
|
||||
@ -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",
|
||||
|
||||
120
docs/how/configure-oidc-react.md
Normal file
120
docs/how/configure-oidc-react.md
Normal 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 ("/")
|
||||
|
||||
102
docs/how/sso/configure-oidc-react-google.md
Normal file
102
docs/how/sso/configure-oidc-react-google.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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)
|
||||
83
docs/how/sso/configure-oidc-react-okta.md
Normal file
83
docs/how/sso/configure-oidc-react-okta.md
Normal 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:
|
||||
|
||||

|
||||

|
||||
|
||||
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/)
|
||||
BIN
docs/how/sso/img/google-setup-1.png
Normal file
BIN
docs/how/sso/img/google-setup-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
BIN
docs/how/sso/img/google-setup-2.png
Normal file
BIN
docs/how/sso/img/google-setup-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/how/sso/img/okta-setup-1.png
Normal file
BIN
docs/how/sso/img/okta-setup-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
BIN
docs/how/sso/img/okta-setup-2.png
Normal file
BIN
docs/how/sso/img/okta-setup-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user