mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-24 08:28:12 +00:00
feat(users): add ability to add native users from the UI (#5097)
Co-authored-by: John Joyce <john@acryl.io>
This commit is contained in:
parent
09fc50674d
commit
fdf4e48495
@ -47,6 +47,11 @@ public class AuthUtils {
|
||||
public static final String PASSWORD = "password";
|
||||
public static final String ACTOR = "actor";
|
||||
public static final String ACCESS_TOKEN = "token";
|
||||
public static final String FULL_NAME = "fullName";
|
||||
public static final String EMAIL = "email";
|
||||
public static final String TITLE = "title";
|
||||
public static final String INVITE_TOKEN = "inviteToken";
|
||||
public static final String RESET_TOKEN = "resetToken";
|
||||
|
||||
/**
|
||||
* Determines whether the inbound request should be forward to downstream Metadata Service. Today, this simply
|
||||
|
||||
23
datahub-frontend/app/auth/NativeAuthenticationConfigs.java
Normal file
23
datahub-frontend/app/auth/NativeAuthenticationConfigs.java
Normal file
@ -0,0 +1,23 @@
|
||||
package auth;
|
||||
|
||||
/**
|
||||
* Currently, this config enables or disable native user authentication.
|
||||
*/
|
||||
public class NativeAuthenticationConfigs {
|
||||
|
||||
public static final String NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH = "auth.native.enabled";
|
||||
|
||||
private Boolean _isEnabled = true;
|
||||
|
||||
public NativeAuthenticationConfigs(final com.typesafe.config.Config configs) {
|
||||
if (configs.hasPath(NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH)
|
||||
&& Boolean.FALSE.equals(
|
||||
Boolean.parseBoolean(configs.getValue(NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH).toString()))) {
|
||||
_isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNativeAuthenticationEnabled() {
|
||||
return _isEnabled;
|
||||
}
|
||||
}
|
||||
@ -23,19 +23,29 @@ import com.datahub.authentication.Authentication;
|
||||
public class AuthServiceClient {
|
||||
|
||||
private static final String GENERATE_SESSION_TOKEN_ENDPOINT = "auth/generateSessionTokenForUser";
|
||||
private static final String SIGN_UP_ENDPOINT = "auth/signUp";
|
||||
private static final String RESET_NATIVE_USER_CREDENTIALS_ENDPOINT = "auth/resetNativeUserCredentials";
|
||||
private static final String VERIFY_NATIVE_USER_CREDENTIALS_ENDPOINT = "auth/verifyNativeUserCredentials";
|
||||
private static final String ACCESS_TOKEN_FIELD = "accessToken";
|
||||
private static final String USER_ID_FIELD = "userId";
|
||||
private static final String USER_URN_FIELD = "userUrn";
|
||||
private static final String FULL_NAME_FIELD = "fullName";
|
||||
private static final String EMAIL_FIELD = "email";
|
||||
private static final String TITLE_FIELD = "title";
|
||||
private static final String PASSWORD_FIELD = "password";
|
||||
private static final String INVITE_TOKEN_FIELD = "inviteToken";
|
||||
private static final String RESET_TOKEN_FIELD = "resetToken";
|
||||
private static final String IS_NATIVE_USER_CREATED_FIELD = "isNativeUserCreated";
|
||||
private static final String ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD = "areNativeUserCredentialsReset";
|
||||
private static final String DOES_PASSWORD_MATCH_FIELD = "doesPasswordMatch";
|
||||
|
||||
private final String metadataServiceHost;
|
||||
private final Integer metadataServicePort;
|
||||
private final Boolean metadataServiceUseSsl;
|
||||
private final Authentication systemAuthentication;
|
||||
|
||||
public AuthServiceClient(
|
||||
@Nonnull final String metadataServiceHost,
|
||||
@Nonnull final Integer metadataServicePort,
|
||||
@Nonnull final Boolean useSsl,
|
||||
@Nonnull final Authentication systemAuthentication) {
|
||||
public AuthServiceClient(@Nonnull final String metadataServiceHost, @Nonnull final Integer metadataServicePort,
|
||||
@Nonnull final Boolean useSsl, @Nonnull final Authentication systemAuthentication) {
|
||||
this.metadataServiceHost = Objects.requireNonNull(metadataServiceHost);
|
||||
this.metadataServicePort = Objects.requireNonNull(metadataServicePort);
|
||||
this.metadataServiceUseSsl = Objects.requireNonNull(useSsl);
|
||||
@ -88,6 +98,154 @@ public class AuthServiceClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Auth Service to create a native Datahub user.
|
||||
*/
|
||||
@Nonnull
|
||||
public boolean signUp(@Nonnull final String userUrn, @Nonnull final String fullName, @Nonnull final String email,
|
||||
@Nonnull final String title, @Nonnull final String password, @Nonnull final String inviteToken) {
|
||||
Objects.requireNonNull(userUrn, "userUrn must not be null");
|
||||
Objects.requireNonNull(fullName, "fullName must not be null");
|
||||
Objects.requireNonNull(email, "email must not be null");
|
||||
Objects.requireNonNull(title, "title must not be null");
|
||||
Objects.requireNonNull(password, "password must not be null");
|
||||
Objects.requireNonNull(inviteToken, "inviteToken must not be null");
|
||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
||||
|
||||
try {
|
||||
|
||||
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
|
||||
final HttpPost request =
|
||||
new HttpPost(String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
|
||||
SIGN_UP_ENDPOINT));
|
||||
|
||||
// Build JSON request to verify credentials for a native user.
|
||||
String json =
|
||||
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\" }",
|
||||
USER_URN_FIELD, userUrn, FULL_NAME_FIELD, fullName, EMAIL_FIELD, email, TITLE_FIELD, title,
|
||||
PASSWORD_FIELD, password, INVITE_TOKEN_FIELD, inviteToken);
|
||||
request.setEntity(new StringEntity(json));
|
||||
|
||||
// Add authorization header with DataHub frontend system id and secret.
|
||||
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
|
||||
|
||||
CloseableHttpResponse response = httpClient.execute(request);
|
||||
final HttpEntity entity = response.getEntity();
|
||||
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
|
||||
// Successfully generated a token for the User
|
||||
final String jsonStr = EntityUtils.toString(entity);
|
||||
return getIsNativeUserCreatedFromJson(jsonStr);
|
||||
} else {
|
||||
throw new RuntimeException(
|
||||
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
|
||||
response.getEntity().toString()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create user", e);
|
||||
} finally {
|
||||
try {
|
||||
httpClient.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to close http client", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Auth Service to reset credentials for a native DataHub user.
|
||||
*/
|
||||
@Nonnull
|
||||
public boolean resetNativeUserCredentials(@Nonnull final String userUrn, @Nonnull final String password,
|
||||
@Nonnull final String resetToken) {
|
||||
Objects.requireNonNull(userUrn, "userUrn must not be null");
|
||||
Objects.requireNonNull(password, "password must not be null");
|
||||
Objects.requireNonNull(resetToken, "reset token must not be null");
|
||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
||||
|
||||
try {
|
||||
|
||||
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
|
||||
final HttpPost request = new HttpPost(
|
||||
String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
|
||||
RESET_NATIVE_USER_CREDENTIALS_ENDPOINT));
|
||||
|
||||
// Build JSON request to verify credentials for a native user.
|
||||
String json =
|
||||
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\" }", USER_URN_FIELD, userUrn,
|
||||
PASSWORD_FIELD, password, RESET_TOKEN_FIELD, resetToken);
|
||||
request.setEntity(new StringEntity(json));
|
||||
|
||||
// Add authorization header with DataHub frontend system id and secret.
|
||||
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
|
||||
|
||||
CloseableHttpResponse response = httpClient.execute(request);
|
||||
final HttpEntity entity = response.getEntity();
|
||||
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
|
||||
// Successfully generated a token for the User
|
||||
final String jsonStr = EntityUtils.toString(entity);
|
||||
return getAreNativeUserCredentialsResetFromJson(jsonStr);
|
||||
} else {
|
||||
throw new RuntimeException(
|
||||
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
|
||||
response.getEntity().toString()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to reset credentials for user", e);
|
||||
} finally {
|
||||
try {
|
||||
httpClient.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to close http client", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Auth Service to verify the credentials for a native Datahub user.
|
||||
*/
|
||||
@Nonnull
|
||||
public boolean verifyNativeUserCredentials(@Nonnull final String userUrn, @Nonnull final String password) {
|
||||
Objects.requireNonNull(userUrn, "userUrn must not be null");
|
||||
Objects.requireNonNull(password, "password must not be null");
|
||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
||||
|
||||
try {
|
||||
|
||||
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
|
||||
final HttpPost request = new HttpPost(
|
||||
String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
|
||||
VERIFY_NATIVE_USER_CREDENTIALS_ENDPOINT));
|
||||
|
||||
// Build JSON request to verify credentials for a native user.
|
||||
String json =
|
||||
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\" }", USER_URN_FIELD, userUrn, PASSWORD_FIELD, password);
|
||||
request.setEntity(new StringEntity(json));
|
||||
|
||||
// Add authorization header with DataHub frontend system id and secret.
|
||||
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
|
||||
|
||||
CloseableHttpResponse response = httpClient.execute(request);
|
||||
final HttpEntity entity = response.getEntity();
|
||||
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
|
||||
// Successfully generated a token for the User
|
||||
final String jsonStr = EntityUtils.toString(entity);
|
||||
return getDoesPasswordMatchFromJson(jsonStr);
|
||||
} else {
|
||||
throw new RuntimeException(
|
||||
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
|
||||
response.getEntity().toString()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to verify credentials for user", e);
|
||||
} finally {
|
||||
try {
|
||||
httpClient.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to close http client", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getAccessTokenFromJson(final String jsonStr) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
try {
|
||||
@ -97,4 +255,31 @@ public class AuthServiceClient {
|
||||
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean getIsNativeUserCreatedFromJson(final String jsonStr) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
try {
|
||||
return mapper.readTree(jsonStr).get(IS_NATIVE_USER_CREATED_FIELD).asBoolean();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean getAreNativeUserCredentialsResetFromJson(final String jsonStr) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
try {
|
||||
return mapper.readTree(jsonStr).get(ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD).asBoolean();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean getDoesPasswordMatchFromJson(final String jsonStr) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
try {
|
||||
return mapper.readTree(jsonStr).get(DOES_PASSWORD_MATCH_FIELD).asBoolean();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,12 +23,12 @@ import play.mvc.Http;
|
||||
import play.mvc.Result;
|
||||
import auth.AuthUtils;
|
||||
import auth.JAASConfigs;
|
||||
import auth.NativeAuthenticationConfigs;
|
||||
import auth.sso.SsoManager;
|
||||
import security.AuthenticationManager;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.inject.Inject;
|
||||
import javax.naming.NamingException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@ -45,6 +45,7 @@ public class AuthenticationController extends Controller {
|
||||
private final Logger _logger = LoggerFactory.getLogger(AuthenticationController.class.getName());
|
||||
private final Config _configs;
|
||||
private final JAASConfigs _jaasConfigs;
|
||||
private final NativeAuthenticationConfigs _nativeAuthenticationConfigs;
|
||||
|
||||
@Inject
|
||||
private org.pac4j.core.config.Config _ssoConfig;
|
||||
@ -62,6 +63,7 @@ public class AuthenticationController extends Controller {
|
||||
public AuthenticationController(@Nonnull Config configs) {
|
||||
_configs = configs;
|
||||
_jaasConfigs = new JAASConfigs(configs);
|
||||
_nativeAuthenticationConfigs = new NativeAuthenticationConfigs(configs);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,9 +89,10 @@ public class AuthenticationController extends Controller {
|
||||
return redirectToIdentityProvider();
|
||||
}
|
||||
|
||||
// 2. If JAAS auth is enabled, fallback to it
|
||||
if (_jaasConfigs.isJAASEnabled()) {
|
||||
return redirect(LOGIN_ROUTE + String.format("?%s=%s", AUTH_REDIRECT_URI_PARAM, encodeRedirectUri(redirectPath)));
|
||||
// 2. If either JAAS auth or Native auth is enabled, fallback to it
|
||||
if (_jaasConfigs.isJAASEnabled() || _nativeAuthenticationConfigs.isNativeAuthenticationEnabled()) {
|
||||
return redirect(
|
||||
LOGIN_ROUTE + String.format("?%s=%s", AUTH_REDIRECT_URI_PARAM, encodeRedirectUri(redirectPath)));
|
||||
}
|
||||
|
||||
// 3. If no auth enabled, fallback to using default user account & redirect.
|
||||
@ -109,9 +112,15 @@ public class AuthenticationController extends Controller {
|
||||
*/
|
||||
@Nonnull
|
||||
public Result logIn() {
|
||||
if (!_jaasConfigs.isJAASEnabled()) {
|
||||
boolean jaasEnabled = _jaasConfigs.isJAASEnabled();
|
||||
_logger.debug(String.format("Jaas authentication enabled: %b", jaasEnabled));
|
||||
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
|
||||
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
|
||||
boolean noAuthEnabled = !jaasEnabled && !nativeAuthenticationEnabled;
|
||||
if (noAuthEnabled) {
|
||||
String message = "Neither JAAS nor native authentication is enabled on the server.";
|
||||
final ObjectNode error = Json.newObject();
|
||||
error.put("message", "JAAS authentication is not enabled on the server.");
|
||||
error.put("message", message);
|
||||
return badRequest(error);
|
||||
}
|
||||
|
||||
@ -120,23 +129,19 @@ public class AuthenticationController extends Controller {
|
||||
final String password = json.findPath(PASSWORD).textValue();
|
||||
|
||||
if (StringUtils.isBlank(username)) {
|
||||
JsonNode invalidCredsJson = Json.newObject()
|
||||
.put("message", "User name must not be empty.");
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "User name must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
ctx().session().clear();
|
||||
|
||||
try {
|
||||
AuthenticationManager.authenticateUser(username, password);
|
||||
} catch (NamingException e) {
|
||||
_logger.error("Authentication error", e);
|
||||
JsonNode invalidCredsJson = Json.newObject()
|
||||
.put("message", "Invalid Credentials");
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Invalid Credentials");
|
||||
boolean loginSucceeded = tryLogin(username, password);
|
||||
|
||||
if (!loginSucceeded) {
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
|
||||
final Urn actorUrn = new CorpuserUrn(username);
|
||||
final String accessToken = _authClient.generateSessionTokenForUser(actorUrn.getId());
|
||||
ctx().session().put(ACTOR, actorUrn.toString());
|
||||
@ -147,6 +152,119 @@ public class AuthenticationController extends Controller {
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up a native user based on a name, email, title, and password. The invite token must match the global invite
|
||||
* token stored for the DataHub instance.
|
||||
*
|
||||
*/
|
||||
@Nonnull
|
||||
public Result signUp() {
|
||||
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
|
||||
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
|
||||
if (!nativeAuthenticationEnabled) {
|
||||
String message = "Native authentication is not enabled on the server.";
|
||||
final ObjectNode error = Json.newObject();
|
||||
error.put("message", message);
|
||||
return badRequest(error);
|
||||
}
|
||||
|
||||
final JsonNode json = request().body().asJson();
|
||||
final String fullName = json.findPath(FULL_NAME).textValue();
|
||||
final String email = json.findPath(EMAIL).textValue();
|
||||
final String title = json.findPath(TITLE).textValue();
|
||||
final String password = json.findPath(PASSWORD).textValue();
|
||||
final String inviteToken = json.findPath(INVITE_TOKEN).textValue();
|
||||
|
||||
if (StringUtils.isBlank(fullName)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Full name must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(email)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Email must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(password)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Password must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(title)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Title must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(inviteToken)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Invite token must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
ctx().session().clear();
|
||||
|
||||
final Urn userUrn = new CorpuserUrn(email);
|
||||
final String userUrnString = userUrn.toString();
|
||||
boolean isNativeUserCreated = _authClient.signUp(userUrnString, fullName, email, title, password, inviteToken);
|
||||
final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId());
|
||||
ctx().session().put(ACTOR, userUrnString);
|
||||
ctx().session().put(ACCESS_TOKEN, accessToken);
|
||||
return ok().withCookies(Http.Cookie.builder(ACTOR, userUrnString)
|
||||
.withHttpOnly(false)
|
||||
.withMaxAge(Duration.of(30, ChronoUnit.DAYS))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a native user based on a name, email, and password.
|
||||
*
|
||||
*/
|
||||
@Nonnull
|
||||
public Result resetNativeUserCredentials() {
|
||||
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
|
||||
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
|
||||
if (!nativeAuthenticationEnabled) {
|
||||
String message = "Native authentication is not enabled on the server.";
|
||||
final ObjectNode error = Json.newObject();
|
||||
error.put("message", message);
|
||||
return badRequest(error);
|
||||
}
|
||||
|
||||
final JsonNode json = request().body().asJson();
|
||||
final String email = json.findPath(EMAIL).textValue();
|
||||
final String password = json.findPath(PASSWORD).textValue();
|
||||
final String resetToken = json.findPath(RESET_TOKEN).textValue();
|
||||
|
||||
if (StringUtils.isBlank(email)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Email must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(password)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Password must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(resetToken)) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Reset token must not be empty.");
|
||||
return badRequest(invalidCredsJson);
|
||||
}
|
||||
|
||||
ctx().session().clear();
|
||||
|
||||
final Urn userUrn = new CorpuserUrn(email);
|
||||
final String userUrnString = userUrn.toString();
|
||||
boolean areNativeUserCredentialsReset =
|
||||
_authClient.resetNativeUserCredentials(userUrnString, password, resetToken);
|
||||
_logger.debug(String.format("Are native user credentials reset: %b", areNativeUserCredentialsReset));
|
||||
final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId());
|
||||
ctx().session().put(ACTOR, userUrnString);
|
||||
ctx().session().put(ACCESS_TOKEN, accessToken);
|
||||
return ok().withCookies(Http.Cookie.builder(ACTOR, userUrnString)
|
||||
.withHttpOnly(false)
|
||||
.withMaxAge(Duration.of(30, ChronoUnit.DAYS))
|
||||
.build());
|
||||
}
|
||||
|
||||
private Result redirectToIdentityProvider() {
|
||||
final PlayWebContext playWebContext = new PlayWebContext(ctx(), _playSessionStore);
|
||||
final Client<?, ?> client = _ssoManager.getSsoProvider().client();
|
||||
@ -168,4 +286,30 @@ public class AuthenticationController extends Controller {
|
||||
throw new RuntimeException(String.format("Failed to encode redirect URI %s", redirectUri), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryLogin(String username, String password) {
|
||||
JsonNode invalidCredsJson = Json.newObject().put("message", "Invalid Credentials");
|
||||
boolean loginSucceeded = false;
|
||||
|
||||
// First try jaas login, if enabled
|
||||
if (_jaasConfigs.isJAASEnabled()) {
|
||||
try {
|
||||
_logger.debug("Attempting jaas authentication");
|
||||
AuthenticationManager.authenticateJaasUser(username, password);
|
||||
loginSucceeded = true;
|
||||
_logger.debug("Jaas authentication successful");
|
||||
} catch (Exception e) {
|
||||
_logger.debug("Jaas authentication error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// If jaas login fails or is disabled, try native auth login
|
||||
if (_nativeAuthenticationConfigs.isNativeAuthenticationEnabled() && !loginSucceeded) {
|
||||
final Urn userUrn = new CorpuserUrn(username);
|
||||
final String userUrnString = userUrn.toString();
|
||||
loginSucceeded = loginSucceeded || _authClient.verifyNativeUserCredentials(userUrnString, password);
|
||||
}
|
||||
|
||||
return loginSucceeded;
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,7 @@ public class AuthenticationManager {
|
||||
|
||||
}
|
||||
|
||||
public static void authenticateUser(@Nonnull String userName, @Nonnull String password) throws NamingException {
|
||||
public static void authenticateJaasUser(@Nonnull String userName, @Nonnull String password) throws NamingException {
|
||||
Preconditions.checkArgument(!StringUtils.isAnyEmpty(userName), "Username cannot be empty");
|
||||
try {
|
||||
JAASLoginService jaasLoginService = new JAASLoginService("WHZ-Authentication");
|
||||
|
||||
@ -152,11 +152,13 @@ auth.oidc.readTimeout = ${?AUTH_OIDC_READ_TIMEOUT}
|
||||
# any username / password combination as valid credentials. To disable this entry point altogether, specify the following config:
|
||||
#
|
||||
auth.jaas.enabled = ${?AUTH_JAAS_ENABLED}
|
||||
auth.native.enabled = ${?AUTH_NATIVE_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:
|
||||
# To disable all authentication to the app, and proxy all users through a master "datahub" account, make sure that,
|
||||
# jaas, native and oidc auth are disabled:
|
||||
#
|
||||
# auth.jaas.enabled = false
|
||||
# auth.native.enabled = false
|
||||
# auth.oidc.enabled = false # (or simply omit oidc configurations)
|
||||
|
||||
# Login session expiration time
|
||||
|
||||
@ -14,6 +14,8 @@ GET /config co
|
||||
# Authentication in React
|
||||
GET /authenticate controllers.AuthenticationController.authenticate()
|
||||
POST /logIn controllers.AuthenticationController.logIn()
|
||||
POST /signUp controllers.AuthenticationController.signUp()
|
||||
POST /resetNativeUserCredentials controllers.AuthenticationController.resetNativeUserCredentials()
|
||||
GET /callback/:protocol controllers.SsoCallbackController.handleCallback(protocol: String)
|
||||
POST /callback/:protocol controllers.SsoCallbackController.handleCallback(protocol: String)
|
||||
GET /logOut controllers.CentralLogoutController.executeLogout()
|
||||
|
||||
@ -2,6 +2,7 @@ package com.linkedin.datahub.graphql;
|
||||
|
||||
import com.datahub.authentication.AuthenticationConfiguration;
|
||||
import com.datahub.authentication.token.StatefulTokenService;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.datahub.authorization.AuthorizationConfiguration;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.linkedin.common.VersionedUrn;
|
||||
@ -163,6 +164,9 @@ import com.linkedin.datahub.graphql.resolvers.type.HyperParameterValueTypeResolv
|
||||
import com.linkedin.datahub.graphql.resolvers.type.PlatformSchemaUnionTypeResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.type.ResultsTypeResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.type.TimeSeriesAspectInterfaceTypeResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.CreateNativeUserInviteTokenResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.CreateNativeUserResetTokenResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.GetNativeUserInviteTokenResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.ListUsersResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.RemoveUserResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.user.UpdateUserStatusResolver;
|
||||
@ -263,6 +267,7 @@ public class GmsGraphQLEngine {
|
||||
private final boolean supportsImpactAnalysis;
|
||||
private final TimeseriesAspectService timeseriesAspectService;
|
||||
private final TimelineService timelineService;
|
||||
private final NativeUserService nativeUserService;
|
||||
|
||||
private final IngestionConfiguration ingestionConfiguration;
|
||||
private final AuthenticationConfiguration authenticationConfiguration;
|
||||
@ -335,6 +340,7 @@ public class GmsGraphQLEngine {
|
||||
final TimeseriesAspectService timeseriesAspectService,
|
||||
final EntityRegistry entityRegistry,
|
||||
final SecretService secretService,
|
||||
final NativeUserService nativeUserService,
|
||||
final IngestionConfiguration ingestionConfiguration,
|
||||
final AuthenticationConfiguration authenticationConfiguration,
|
||||
final AuthorizationConfiguration authorizationConfiguration,
|
||||
@ -361,6 +367,7 @@ public class GmsGraphQLEngine {
|
||||
this.supportsImpactAnalysis = supportsImpactAnalysis;
|
||||
this.timeseriesAspectService = timeseriesAspectService;
|
||||
this.timelineService = timelineService;
|
||||
this.nativeUserService = nativeUserService;
|
||||
|
||||
this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration);
|
||||
this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration);
|
||||
@ -625,6 +632,7 @@ public class GmsGraphQLEngine {
|
||||
.dataFetcher("getRootGlossaryTerms", new GetRootGlossaryTermsResolver(this.entityClient))
|
||||
.dataFetcher("getRootGlossaryNodes", new GetRootGlossaryNodesResolver(this.entityClient))
|
||||
.dataFetcher("entityExists", new EntityExistsResolver(this.entityService))
|
||||
.dataFetcher("getNativeUserInviteToken", new GetNativeUserInviteTokenResolver(this.nativeUserService))
|
||||
);
|
||||
}
|
||||
|
||||
@ -699,6 +707,8 @@ public class GmsGraphQLEngine {
|
||||
.dataFetcher("updateName", new UpdateNameResolver(entityService))
|
||||
.dataFetcher("addRelatedTerms", new AddRelatedTermsResolver(this.entityService))
|
||||
.dataFetcher("removeRelatedTerms", new RemoveRelatedTermsResolver(this.entityService))
|
||||
.dataFetcher("createNativeUserInviteToken", new CreateNativeUserInviteTokenResolver(this.nativeUserService))
|
||||
.dataFetcher("createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,10 @@ public class AuthorizationUtils {
|
||||
return isAuthorized(context, Optional.empty(), PoliciesConfig.MANAGE_GLOSSARIES_PRIVILEGE);
|
||||
}
|
||||
|
||||
public static boolean canManageUserCredentials(@Nonnull QueryContext context) {
|
||||
return isAuthorized(context, Optional.empty(), PoliciesConfig.MANAGE_USER_CREDENTIALS_PRIVILEGE);
|
||||
}
|
||||
|
||||
public static boolean isAuthorized(
|
||||
@Nonnull QueryContext context,
|
||||
@Nonnull Optional<ResourceSpec> resourceSpec,
|
||||
|
||||
@ -19,6 +19,7 @@ import java.net.URISyntaxException;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import static com.linkedin.datahub.graphql.resolvers.ingest.IngestionAuthUtils.*;
|
||||
import static com.linkedin.metadata.Constants.*;
|
||||
@ -63,6 +64,7 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
|
||||
platformPrivileges.setManageTokens(canManageTokens(context));
|
||||
platformPrivileges.setManageTests(canManageTests(context));
|
||||
platformPrivileges.setManageGlossaries(canManageGlossaries(context));
|
||||
platformPrivileges.setManageUserCredentials(canManageUserCredentials(context));
|
||||
|
||||
// Construct and return authenticated user object.
|
||||
final AuthenticatedUser authUser = new AuthenticatedUser();
|
||||
@ -131,6 +133,14 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
|
||||
return isAuthorized(context.getAuthorizer(), context.getActorUrn(), PoliciesConfig.MANAGE_GLOSSARIES_PRIVILEGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the authenticated user has privileges to manage user credentials
|
||||
*/
|
||||
private boolean canManageUserCredentials(@Nonnull QueryContext context) {
|
||||
return isAuthorized(context.getAuthorizer(), context.getActorUrn(),
|
||||
PoliciesConfig.MANAGE_USER_CREDENTIALS_PRIVILEGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided actor is authorized for a particular privilege, false otherwise.
|
||||
*/
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.exception.AuthorizationException;
|
||||
import com.linkedin.datahub.graphql.generated.InviteToken;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
|
||||
|
||||
/**
|
||||
* Resolver responsible for creating an invite token that Admins can share with prospective users to create native
|
||||
* user accounts.
|
||||
*/
|
||||
public class CreateNativeUserInviteTokenResolver implements DataFetcher<CompletableFuture<InviteToken>> {
|
||||
private final NativeUserService _nativeUserService;
|
||||
|
||||
public CreateNativeUserInviteTokenResolver(final NativeUserService nativeUserService) {
|
||||
_nativeUserService = nativeUserService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<InviteToken> get(final DataFetchingEnvironment environment) throws Exception {
|
||||
final QueryContext context = environment.getContext();
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
if (!canManageUserCredentials(context)) {
|
||||
throw new AuthorizationException(
|
||||
"Unauthorized to perform this action. Please contact your DataHub administrator.");
|
||||
}
|
||||
|
||||
try {
|
||||
String inviteToken = _nativeUserService.generateNativeUserInviteToken(context.getAuthentication());
|
||||
return new InviteToken(inviteToken);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate new invite token");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.exception.AuthorizationException;
|
||||
import com.linkedin.datahub.graphql.generated.CreateNativeUserResetTokenInput;
|
||||
import com.linkedin.datahub.graphql.generated.ResetToken;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
|
||||
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
|
||||
|
||||
/**
|
||||
* Resolver responsible for creating a password reset token that Admins can share with native users to reset their
|
||||
* credentials.
|
||||
*/
|
||||
public class CreateNativeUserResetTokenResolver implements DataFetcher<CompletableFuture<ResetToken>> {
|
||||
private final NativeUserService _nativeUserService;
|
||||
|
||||
public CreateNativeUserResetTokenResolver(final NativeUserService nativeUserService) {
|
||||
_nativeUserService = nativeUserService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ResetToken> get(final DataFetchingEnvironment environment) throws Exception {
|
||||
final QueryContext context = environment.getContext();
|
||||
final CreateNativeUserResetTokenInput input =
|
||||
bindArgument(environment.getArgument("input"), CreateNativeUserResetTokenInput.class);
|
||||
|
||||
final String userUrnString = input.getUserUrn();
|
||||
Objects.requireNonNull(userUrnString, "No user urn was provided!");
|
||||
|
||||
if (!canManageUserCredentials(context)) {
|
||||
throw new AuthorizationException(
|
||||
"Unauthorized to perform this action. Please contact your DataHub administrator.");
|
||||
}
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
String resetToken =
|
||||
_nativeUserService.generateNativeUserPasswordResetToken(userUrnString, context.getAuthentication());
|
||||
return new ResetToken(resetToken);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
String.format("Failed to generate password reset token for user: %s", userUrnString));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.exception.AuthorizationException;
|
||||
import com.linkedin.datahub.graphql.generated.InviteToken;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
|
||||
|
||||
/**
|
||||
* Resolver responsible for getting an existing invite token that Admins can share with prospective users to create
|
||||
* native user accounts. If the invite token does not already exist, this resolver will create a new one.
|
||||
*/
|
||||
public class GetNativeUserInviteTokenResolver implements DataFetcher<CompletableFuture<InviteToken>> {
|
||||
private final NativeUserService _nativeUserService;
|
||||
|
||||
public GetNativeUserInviteTokenResolver(final NativeUserService nativeUserService) {
|
||||
_nativeUserService = nativeUserService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<InviteToken> get(final DataFetchingEnvironment environment) throws Exception {
|
||||
final QueryContext context = environment.getContext();
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
if (!canManageUserCredentials(context)) {
|
||||
throw new AuthorizationException(
|
||||
"Unauthorized to perform this action. Please contact your DataHub administrator.");
|
||||
}
|
||||
|
||||
try {
|
||||
String inviteToken = _nativeUserService.getNativeUserInviteToken(context.getAuthentication());
|
||||
return new InviteToken(inviteToken);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate new invite token");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper;
|
||||
import com.linkedin.entity.EntityResponse;
|
||||
import com.linkedin.entity.EnvelopedAspectMap;
|
||||
import com.linkedin.identity.CorpUserCredentials;
|
||||
import com.linkedin.identity.CorpUserEditableInfo;
|
||||
import com.linkedin.identity.CorpUserInfo;
|
||||
import com.linkedin.identity.CorpUserStatus;
|
||||
@ -45,8 +46,9 @@ public class CorpUserMapper implements ModelMapper<EntityResponse, CorpUser> {
|
||||
corpUser.setEditableProperties(CorpUserEditableInfoMapper.map(new CorpUserEditableInfo(dataMap))));
|
||||
mappingHelper.mapToResult(GLOBAL_TAGS_ASPECT_NAME, (corpUser, dataMap) ->
|
||||
corpUser.setGlobalTags(GlobalTagsMapper.map(new GlobalTags(dataMap))));
|
||||
mappingHelper.mapToResult(CORP_USER_STATUS_ASPECT_NAME, (corpUser, dataMap) ->
|
||||
corpUser.setStatus(CorpUserStatusMapper.map(new CorpUserStatus(dataMap))));
|
||||
mappingHelper.mapToResult(CORP_USER_STATUS_ASPECT_NAME,
|
||||
(corpUser, dataMap) -> corpUser.setStatus(CorpUserStatusMapper.map(new CorpUserStatus(dataMap))));
|
||||
mappingHelper.mapToResult(CORP_USER_CREDENTIALS_ASPECT_NAME, this::mapIsNativeUser);
|
||||
return mappingHelper.getResult();
|
||||
}
|
||||
|
||||
@ -60,4 +62,11 @@ public class CorpUserMapper implements ModelMapper<EntityResponse, CorpUser> {
|
||||
corpUser.setProperties(CorpUserPropertiesMapper.map(corpUserInfo));
|
||||
corpUser.setInfo(CorpUserInfoMapper.map(corpUserInfo));
|
||||
}
|
||||
|
||||
private void mapIsNativeUser(@Nonnull CorpUser corpUser, @Nonnull DataMap dataMap) {
|
||||
CorpUserCredentials corpUserCredentials = new CorpUserCredentials(dataMap);
|
||||
boolean isNativeUser =
|
||||
corpUserCredentials != null && corpUserCredentials.hasSalt() && corpUserCredentials.hasHashedPassword();
|
||||
corpUser.setIsNativeUser(isNativeUser);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,11 @@ type PlatformPrivileges {
|
||||
Whether the user should be able to manage Glossaries
|
||||
"""
|
||||
manageGlossaries: Boolean!
|
||||
|
||||
"""
|
||||
Whether the user is able to manage user credentials
|
||||
"""
|
||||
manageUserCredentials: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@ -162,7 +162,12 @@ type Query {
|
||||
"""
|
||||
Get whether or not not an entity exists
|
||||
"""
|
||||
entityExisst(urn: String!): Boolean
|
||||
entityExists(urn: String!): Boolean
|
||||
|
||||
"""
|
||||
Gets the current invite token. If the optional regenerate param is set to true, generate a new invite token.
|
||||
"""
|
||||
getNativeUserInviteToken: InviteToken
|
||||
}
|
||||
|
||||
"""
|
||||
@ -365,7 +370,7 @@ type Mutation {
|
||||
Input required to report an operation
|
||||
"""
|
||||
input: ReportOperationInput!): String
|
||||
|
||||
|
||||
"""
|
||||
Create a new GlossaryTerm. Returns the urn of the newly created GlossaryTerm. If a term with the provided ID already exists, it will be overwritten.
|
||||
"""
|
||||
@ -400,6 +405,16 @@ type Mutation {
|
||||
Remove multiple related Terms for a Glossary Term
|
||||
"""
|
||||
removeRelatedTerms(input: RelatedTermsInput!): Boolean
|
||||
|
||||
"""
|
||||
Generates an invite token that can be shared with prospective users to create their accounts.
|
||||
"""
|
||||
createNativeUserInviteToken: InviteToken
|
||||
|
||||
"""
|
||||
Generates a token that can be shared with existing native users to reset their credentials.
|
||||
"""
|
||||
createNativeUserResetToken(input: CreateNativeUserResetTokenInput!): ResetToken
|
||||
}
|
||||
|
||||
"""
|
||||
@ -2672,6 +2687,11 @@ type CorpUser implements Entity {
|
||||
"""
|
||||
relationships(input: RelationshipsInput!): EntityRelationshipsResult
|
||||
|
||||
"""
|
||||
Whether or not this user is a native DataHub user
|
||||
"""
|
||||
isNativeUser: Boolean
|
||||
|
||||
"""
|
||||
Deprecated, use properties field instead
|
||||
Additional read only info about the corp user
|
||||
@ -8192,3 +8212,33 @@ input StringMapEntryInput {
|
||||
"""
|
||||
value: String
|
||||
}
|
||||
|
||||
"""
|
||||
Token that allows users to sign up as a native user
|
||||
"""
|
||||
type InviteToken {
|
||||
"""
|
||||
The invite token
|
||||
"""
|
||||
inviteToken: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Input required to generate a password reset token for a native user.
|
||||
"""
|
||||
input CreateNativeUserResetTokenInput {
|
||||
"""
|
||||
The urn of the user to reset the password of
|
||||
"""
|
||||
userUrn: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Token that allows native users to reset their credentials
|
||||
"""
|
||||
type ResetToken {
|
||||
"""
|
||||
The reset token
|
||||
"""
|
||||
resetToken: String!
|
||||
}
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.Authentication;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import static com.linkedin.datahub.graphql.TestUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.testng.Assert.*;
|
||||
|
||||
|
||||
public class CreateNativeUserInviteTokenResolverTest {
|
||||
|
||||
private static final String INVITE_TOKEN = "inviteToken";
|
||||
|
||||
private NativeUserService _nativeUserService;
|
||||
private CreateNativeUserInviteTokenResolver _resolver;
|
||||
private DataFetchingEnvironment _dataFetchingEnvironment;
|
||||
private Authentication _authentication;
|
||||
|
||||
@BeforeMethod
|
||||
public void setupTest() {
|
||||
_nativeUserService = mock(NativeUserService.class);
|
||||
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
|
||||
_authentication = mock(Authentication.class);
|
||||
|
||||
_resolver = new CreateNativeUserInviteTokenResolver(_nativeUserService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFailsCannotManageUserCredentials() {
|
||||
QueryContext mockContext = getMockDenyContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasses() throws Exception {
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
when(mockContext.getAuthentication()).thenReturn(_authentication);
|
||||
when(_nativeUserService.generateNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN);
|
||||
|
||||
assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.Authentication;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.generated.CreateNativeUserResetTokenInput;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import static com.linkedin.datahub.graphql.TestUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.testng.Assert.*;
|
||||
|
||||
|
||||
public class CreateNativeUserResetTokenResolverTest {
|
||||
|
||||
private static final String RESET_TOKEN = "resetToken";
|
||||
private static final String USER_URN_STRING = "urn:li:corpuser:test";
|
||||
|
||||
private NativeUserService _nativeUserService;
|
||||
private CreateNativeUserResetTokenResolver _resolver;
|
||||
private DataFetchingEnvironment _dataFetchingEnvironment;
|
||||
private Authentication _authentication;
|
||||
|
||||
@BeforeMethod
|
||||
public void setupTest() {
|
||||
_nativeUserService = mock(NativeUserService.class);
|
||||
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
|
||||
_authentication = mock(Authentication.class);
|
||||
|
||||
_resolver = new CreateNativeUserResetTokenResolver(_nativeUserService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFailsCannotManageUserCredentials() {
|
||||
QueryContext mockContext = getMockDenyContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFailsNullUserUrn() throws Exception {
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
CreateNativeUserResetTokenInput input = new CreateNativeUserResetTokenInput(null);
|
||||
when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input);
|
||||
when(mockContext.getAuthentication()).thenReturn(_authentication);
|
||||
when(_nativeUserService.generateNativeUserPasswordResetToken(any(), any())).thenReturn(RESET_TOKEN);
|
||||
|
||||
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasses() throws Exception {
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
CreateNativeUserResetTokenInput input = new CreateNativeUserResetTokenInput(USER_URN_STRING);
|
||||
when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input);
|
||||
when(mockContext.getAuthentication()).thenReturn(_authentication);
|
||||
when(_nativeUserService.generateNativeUserPasswordResetToken(any(), any())).thenReturn(RESET_TOKEN);
|
||||
|
||||
assertEquals(RESET_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getResetToken());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.user;
|
||||
|
||||
import com.datahub.authentication.Authentication;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import static com.linkedin.datahub.graphql.TestUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.testng.Assert.*;
|
||||
|
||||
|
||||
public class GetNativeUserInviteTokenResolverTest {
|
||||
|
||||
private static final String INVITE_TOKEN = "inviteToken";
|
||||
|
||||
private NativeUserService _nativeUserService;
|
||||
private GetNativeUserInviteTokenResolver _resolver;
|
||||
private DataFetchingEnvironment _dataFetchingEnvironment;
|
||||
private Authentication _authentication;
|
||||
|
||||
@BeforeMethod
|
||||
public void setupTest() {
|
||||
_nativeUserService = mock(NativeUserService.class);
|
||||
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
|
||||
_authentication = mock(Authentication.class);
|
||||
|
||||
_resolver = new GetNativeUserInviteTokenResolver(_nativeUserService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFailsCannotManageUserCredentials() {
|
||||
QueryContext mockContext = getMockDenyContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
|
||||
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasses() throws Exception {
|
||||
QueryContext mockContext = getMockAllowContext();
|
||||
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
|
||||
when(mockContext.getAuthentication()).thenReturn(_authentication);
|
||||
when(_nativeUserService.getNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN);
|
||||
|
||||
assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken());
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { Switch, Route, RouteProps, Redirect } from 'react-router-dom';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import { LogIn } from './auth/LogIn';
|
||||
import { SignUp } from './auth/SignUp';
|
||||
import { ResetCredentials } from './auth/ResetCredentials';
|
||||
import { NoPageFound } from './shared/NoPageFound';
|
||||
import { PageRoutes } from '../conf/Global';
|
||||
import { isLoggedInVar } from './auth/checkAuthStatus';
|
||||
@ -32,6 +34,8 @@ export const Routes = (): JSX.Element => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={PageRoutes.LOG_IN} component={LogIn} />
|
||||
<Route path={PageRoutes.SIGN_UP} component={SignUp} />
|
||||
<Route path={PageRoutes.RESET_CREDENTIALS} component={ResetCredentials} />
|
||||
<ProtectedRoute isLoggedIn={isLoggedIn} render={() => <ProtectedRoutes />} />
|
||||
{/* Starting the react app locally opens /assets by default. For a smoother dev experience, we'll redirect to the homepage */}
|
||||
<Route path={PageRoutes.ASSETS} component={() => <Redirect to="/" />} exact />
|
||||
|
||||
@ -20,6 +20,8 @@ export enum EventType {
|
||||
SearchAcrossLineageEvent,
|
||||
SearchAcrossLineageResultsViewEvent,
|
||||
DownloadAsCsvEvent,
|
||||
SignUpEvent,
|
||||
ResetCredentialsEvent,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -40,6 +42,14 @@ export interface PageViewEvent extends BaseEvent {
|
||||
type: EventType.PageViewEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged on successful new user sign up.
|
||||
*/
|
||||
export interface SignUpEvent extends BaseEvent {
|
||||
type: EventType.SignUpEvent;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged on user successful login.
|
||||
*/
|
||||
@ -54,6 +64,13 @@ export interface LogOutEvent extends BaseEvent {
|
||||
type: EventType.LogOutEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged on user resetting their credentials
|
||||
*/
|
||||
export interface ResetCredentialsEvent extends BaseEvent {
|
||||
type: EventType.ResetCredentialsEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged on user successful search query.
|
||||
*/
|
||||
@ -189,8 +206,10 @@ export interface DownloadAsCsvEvent extends BaseEvent {
|
||||
*/
|
||||
export type Event =
|
||||
| PageViewEvent
|
||||
| SignUpEvent
|
||||
| LogInEvent
|
||||
| LogOutEvent
|
||||
| ResetCredentialsEvent
|
||||
| SearchEvent
|
||||
| SearchResultsViewEvent
|
||||
| SearchResultClickEvent
|
||||
|
||||
169
datahub-web-react/src/app/auth/ResetCredentials.tsx
Normal file
169
datahub-web-react/src/app/auth/ResetCredentials.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Input, Button, Form, message, Image } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import styled, { useTheme } from 'styled-components';
|
||||
import { Redirect } from 'react-router';
|
||||
import styles from './login.module.css';
|
||||
import { Message } from '../shared/Message';
|
||||
import { isLoggedInVar } from './checkAuthStatus';
|
||||
import analytics, { EventType } from '../analytics';
|
||||
import { useAppConfig } from '../useAppConfig';
|
||||
import { PageRoutes } from '../../conf/Global';
|
||||
import useGetResetTokenFromUrlParams from './useGetResetTokenFromUrlParams';
|
||||
|
||||
type FormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const FormInput = styled(Input)`
|
||||
&&& {
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 5px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
line-height: 1.5715;
|
||||
}
|
||||
> .ant-input {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
}
|
||||
> .ant-input:hover {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export type ResetCredentialsProps = Record<string, never>;
|
||||
|
||||
export const ResetCredentials: React.VFC<ResetCredentialsProps> = () => {
|
||||
const isLoggedIn = useReactiveVar(isLoggedInVar);
|
||||
const resetToken = useGetResetTokenFromUrlParams();
|
||||
|
||||
const themeConfig = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { refreshContext } = useAppConfig();
|
||||
|
||||
const handleResetCredentials = useCallback(
|
||||
(values: FormValues) => {
|
||||
setLoading(true);
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
resetToken,
|
||||
}),
|
||||
};
|
||||
fetch('/resetNativeUserCredentials', 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);
|
||||
refreshContext();
|
||||
analytics.event({ type: EventType.ResetCredentialsEvent });
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`Failed to log in! ${error}`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[refreshContext, resetToken],
|
||||
);
|
||||
|
||||
if (isLoggedIn && !loading) {
|
||||
return <Redirect to={`${PageRoutes.ROOT}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.login_page}>
|
||||
<div className={styles.login_box}>
|
||||
<div className={styles.login_logo_box}>
|
||||
<Image wrapperClassName={styles.logo_image} src={themeConfig.assets?.logoUrl} preview={false} />
|
||||
</div>
|
||||
<div className={styles.login_form_box}>
|
||||
{loading && <Message type="loading" content="Resetting credentials..." />}
|
||||
<Form onFinish={handleResetCredentials} layout="vertical">
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: 'Please fill in your email!' }]}
|
||||
name="email"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Email</label>}
|
||||
>
|
||||
<FormInput prefix={<UserOutlined />} data-testid="email" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[
|
||||
{ required: true, message: 'Please fill in your password!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator() {
|
||||
if (getFieldValue('password').length < 8) {
|
||||
return Promise.reject(
|
||||
new Error('Your password is less than 8 characters!'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name="password"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Password</label>}
|
||||
>
|
||||
<FormInput prefix={<LockOutlined />} type="password" data-testid="password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[
|
||||
{ required: true, message: 'Please confirm your password!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator() {
|
||||
if (getFieldValue('confirmPassword') !== getFieldValue('password')) {
|
||||
return Promise.reject(new Error('Your passwords do not match!'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name="confirmPassword"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Confirm Password</label>}
|
||||
>
|
||||
<FormInput prefix={<LockOutlined />} type="password" data-testid="confirmPassword" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: '0px' }} shouldUpdate>
|
||||
{({ getFieldsValue }) => {
|
||||
const { email, password, confirmPassword } = getFieldsValue();
|
||||
const fieldsAreNotEmpty = !!email && !!password && !!confirmPassword;
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const formIsComplete = fieldsAreNotEmpty && passwordsMatch;
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
htmlType="submit"
|
||||
className={styles.login_button}
|
||||
disabled={!formIsComplete}
|
||||
>
|
||||
Reset credentials
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
209
datahub-web-react/src/app/auth/SignUp.tsx
Normal file
209
datahub-web-react/src/app/auth/SignUp.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Input, Button, Form, message, Image, Select } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import styled, { useTheme } from 'styled-components/macro';
|
||||
import { Redirect } from 'react-router';
|
||||
import styles from './login.module.css';
|
||||
import { Message } from '../shared/Message';
|
||||
import { isLoggedInVar } from './checkAuthStatus';
|
||||
import analytics, { EventType } from '../analytics';
|
||||
import { useAppConfig } from '../useAppConfig';
|
||||
import { PageRoutes } from '../../conf/Global';
|
||||
import useGetInviteTokenFromUrlParams from './useGetInviteTokenFromUrlParams';
|
||||
|
||||
type FormValues = {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const FormInput = styled(Input)`
|
||||
&&& {
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 5px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
line-height: 1.5715;
|
||||
}
|
||||
> .ant-input {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
}
|
||||
> .ant-input:hover {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleSelector = styled(Select)`
|
||||
.ant-select-selector {
|
||||
color: white;
|
||||
border: 1px solid #555555 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.ant-select-arrow {
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
export type SignUpProps = Record<string, never>;
|
||||
|
||||
export const SignUp: React.VFC<SignUpProps> = () => {
|
||||
const isLoggedIn = useReactiveVar(isLoggedInVar);
|
||||
const inviteToken = useGetInviteTokenFromUrlParams();
|
||||
|
||||
const themeConfig = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { refreshContext } = useAppConfig();
|
||||
|
||||
const handleSignUp = useCallback(
|
||||
(values: FormValues) => {
|
||||
setLoading(true);
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName: values.fullName,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
title: values.title,
|
||||
inviteToken,
|
||||
}),
|
||||
};
|
||||
fetch('/signUp', 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);
|
||||
refreshContext();
|
||||
analytics.event({ type: EventType.SignUpEvent, title: values.title });
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`Failed to log in! ${error}`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[refreshContext, inviteToken],
|
||||
);
|
||||
|
||||
if (isLoggedIn && !loading) {
|
||||
return <Redirect to={`${PageRoutes.ROOT}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.login_page}>
|
||||
<div className={styles.login_box}>
|
||||
<div className={styles.login_logo_box}>
|
||||
<Image wrapperClassName={styles.logo_image} src={themeConfig.assets?.logoUrl} preview={false} />
|
||||
</div>
|
||||
<div className={styles.login_form_box}>
|
||||
{loading && <Message type="loading" content="Signing up..." />}
|
||||
<Form onFinish={handleSignUp} layout="vertical">
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: 'Please fill in your email!' }]}
|
||||
name="email"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Email</label>}
|
||||
>
|
||||
<FormInput prefix={<UserOutlined />} data-testid="email" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: 'Please fill in your name!' }]}
|
||||
name="fullName"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Full Name</label>}
|
||||
>
|
||||
<FormInput prefix={<UserOutlined />} data-testid="name" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[
|
||||
{ required: true, message: 'Please fill in your password!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator() {
|
||||
if (getFieldValue('password').length < 8) {
|
||||
return Promise.reject(
|
||||
new Error('Your password is less than 8 characters!'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name="password"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Password</label>}
|
||||
>
|
||||
<FormInput prefix={<LockOutlined />} type="password" data-testid="password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[
|
||||
{ required: true, message: 'Please confirm your password!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator() {
|
||||
if (getFieldValue('confirmPassword') !== getFieldValue('password')) {
|
||||
return Promise.reject(new Error('Your passwords do not match!'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name="confirmPassword"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Confirm Password</label>}
|
||||
>
|
||||
<FormInput prefix={<LockOutlined />} type="password" data-testid="confirmPassword" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: 'Please fill in your title!' }]}
|
||||
name="title"
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
label={<label style={{ color: 'white' }}>Title</label>}
|
||||
>
|
||||
<TitleSelector placeholder="Title">
|
||||
<Select.Option value="Data Analyst">Data Analyst</Select.Option>
|
||||
<Select.Option value="Data Engineer">Data Engineer</Select.Option>
|
||||
<Select.Option value="Data Scientist">Data Scientist</Select.Option>
|
||||
<Select.Option value="Software Engineer">Software Engineer</Select.Option>
|
||||
<Select.Option value="Manager">Manager</Select.Option>
|
||||
<Select.Option value="Product Manager">Product Manager</Select.Option>
|
||||
<Select.Option value="Other">Other</Select.Option>
|
||||
</TitleSelector>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: '0px' }} shouldUpdate>
|
||||
{({ getFieldsValue }) => {
|
||||
const { fullName, email, password, confirmPassword, title } = getFieldsValue();
|
||||
const fieldsAreNotEmpty =
|
||||
!!fullName && !!email && !!password && !!confirmPassword && !!title;
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const formIsComplete = fieldsAreNotEmpty && passwordsMatch;
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
htmlType="submit"
|
||||
className={styles.login_button}
|
||||
disabled={!formIsComplete}
|
||||
>
|
||||
Sign Up!
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import * as QueryString from 'query-string';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export default function useGetInviteTokenFromUrlParams() {
|
||||
const location = useLocation();
|
||||
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
|
||||
const inviteToken: string = params.invite_token as string;
|
||||
return inviteToken;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import * as QueryString from 'query-string';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export default function useGetResetTokenFromUrlParams() {
|
||||
const location = useLocation();
|
||||
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
|
||||
const resetToken: string = params.reset_token as string;
|
||||
return resetToken;
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Empty, List, message, Pagination } from 'antd';
|
||||
import { Button, Empty, List, message, Pagination } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
import * as QueryString from 'query-string';
|
||||
import { UsergroupAddOutlined } from '@ant-design/icons';
|
||||
import { useLocation } from 'react-router';
|
||||
import UserListItem from './UserListItem';
|
||||
import { Message } from '../../shared/Message';
|
||||
@ -10,6 +11,8 @@ import { CorpUser } from '../../../types.generated';
|
||||
import TabToolbar from '../../entity/shared/components/styled/TabToolbar';
|
||||
import { SearchBar } from '../../search/SearchBar';
|
||||
import { useEntityRegistry } from '../../useEntityRegistry';
|
||||
import ViewInviteTokenModal from './ViewInviteTokenModal';
|
||||
import { useGetAuthenticatedUser } from '../../useGetAuthenticatedUser';
|
||||
|
||||
const UserContainer = styled.div``;
|
||||
|
||||
@ -36,8 +39,12 @@ export const UserList = () => {
|
||||
useEffect(() => setQuery(paramsQuery), [paramsQuery]);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [isViewingInviteToken, setIsViewingInviteToken] = useState(false);
|
||||
const [removedUrns, setRemovedUrns] = useState<string[]>([]);
|
||||
|
||||
const authenticatedUser = useGetAuthenticatedUser();
|
||||
const canManageUserCredentials = authenticatedUser?.platformPrivileges.manageUserCredentials || false;
|
||||
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
@ -76,7 +83,13 @@ export const UserList = () => {
|
||||
<UserContainer>
|
||||
<TabToolbar>
|
||||
<div>
|
||||
<></>
|
||||
<Button
|
||||
disabled={!canManageUserCredentials}
|
||||
type="text"
|
||||
onClick={() => setIsViewingInviteToken(true)}
|
||||
>
|
||||
<UsergroupAddOutlined /> Invite Users
|
||||
</Button>
|
||||
</div>
|
||||
<SearchBar
|
||||
initialQuery={query || ''}
|
||||
@ -102,7 +115,11 @@ export const UserList = () => {
|
||||
}}
|
||||
dataSource={filteredUsers}
|
||||
renderItem={(item: any) => (
|
||||
<UserListItem onDelete={() => handleDelete(item.urn as string)} user={item as CorpUser} />
|
||||
<UserListItem
|
||||
onDelete={() => handleDelete(item.urn as string)}
|
||||
user={item as CorpUser}
|
||||
canManageUserCredentials={canManageUserCredentials}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<UserPaginationContainer>
|
||||
@ -116,6 +133,7 @@ export const UserList = () => {
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</UserPaginationContainer>
|
||||
<ViewInviteTokenModal visible={isViewingInviteToken} onClose={() => setIsViewingInviteToken(false)} />
|
||||
</UserContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Button, List, message, Modal, Tag, Tooltip, Typography } from 'antd';
|
||||
import { Button, Dropdown, List, Menu, message, Modal, Tag, Tooltip, Typography } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, MoreOutlined, UnlockOutlined } from '@ant-design/icons';
|
||||
import { CorpUser, CorpUserStatus, EntityType } from '../../../types.generated';
|
||||
import CustomAvatar from '../../shared/avatar/CustomAvatar';
|
||||
import { useEntityRegistry } from '../../useEntityRegistry';
|
||||
import { useRemoveUserMutation } from '../../../graphql/user.generated';
|
||||
import { ANTD_GRAY, REDESIGN_COLORS } from '../../entity/shared/constants';
|
||||
import ViewResetTokenModal from './ViewResetTokenModal';
|
||||
|
||||
type Props = {
|
||||
user: CorpUser;
|
||||
canManageUserCredentials: boolean;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
@ -34,9 +36,12 @@ const ButtonGroup = styled.div`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export default function UserListItem({ user, onDelete }: Props) {
|
||||
export default function UserListItem({ user, canManageUserCredentials, onDelete }: Props) {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const [isViewingResetToken, setIsViewingResetToken] = useState(false);
|
||||
const displayName = entityRegistry.getDisplayName(EntityType.CorpUser, user);
|
||||
const isNativeUser: boolean = user.isNativeUser as boolean;
|
||||
const shouldShowPasswordReset: boolean = canManageUserCredentials && isNativeUser;
|
||||
|
||||
const [removeUserMutation] = useRemoveUserMutation();
|
||||
|
||||
@ -118,10 +123,28 @@ export default function UserListItem({ user, onDelete }: Props) {
|
||||
</Link>
|
||||
</UserItemContainer>
|
||||
<ButtonGroup>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item disabled={!shouldShowPasswordReset} onClick={() => setIsViewingResetToken(true)}>
|
||||
<UnlockOutlined /> Reset user password
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Dropdown>
|
||||
<Button onClick={() => handleRemoveUser(user.urn)} type="text" shape="circle" danger>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ViewResetTokenModal
|
||||
visible={isViewingResetToken}
|
||||
userUrn={user.urn}
|
||||
username={user.username}
|
||||
onClose={() => setIsViewingResetToken(false)}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import { RedoOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { PageRoutes } from '../../../conf/Global';
|
||||
import {
|
||||
useCreateNativeUserInviteTokenMutation,
|
||||
useGetNativeUserInviteTokenQuery,
|
||||
} from '../../../graphql/user.generated';
|
||||
|
||||
const ModalSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 12px;
|
||||
`;
|
||||
|
||||
const ModalSectionHeader = styled(Typography.Text)`
|
||||
&&&& {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ModalSectionParagraph = styled(Typography.Paragraph)`
|
||||
&&&& {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CreateInviteTokenButton = styled(Button)`
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
margin-left: -6px;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ViewInviteTokenModal({ visible, onClose }: Props) {
|
||||
const baseUrl = window.location.origin;
|
||||
const { data: getNativeUserInviteTokenData } = useGetNativeUserInviteTokenQuery({});
|
||||
|
||||
const [createNativeUserInviteToken, { data: createNativeUserInviteTokenData }] =
|
||||
useCreateNativeUserInviteTokenMutation({});
|
||||
|
||||
const inviteToken = createNativeUserInviteTokenData?.createNativeUserInviteToken?.inviteToken
|
||||
? createNativeUserInviteTokenData?.createNativeUserInviteToken.inviteToken
|
||||
: getNativeUserInviteTokenData?.getNativeUserInviteToken?.inviteToken || '';
|
||||
|
||||
const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={700}
|
||||
footer={null}
|
||||
title={
|
||||
<Typography.Text>
|
||||
<b>Invite new DataHub users</b>
|
||||
</Typography.Text>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<ModalSection>
|
||||
<ModalSectionHeader strong>Share invite link</ModalSectionHeader>
|
||||
<ModalSectionParagraph>
|
||||
Share this invite link with other users in your workspace!
|
||||
</ModalSectionParagraph>
|
||||
<Typography.Paragraph copyable={{ text: inviteLink }}>
|
||||
<pre>{inviteLink}</pre>
|
||||
</Typography.Paragraph>
|
||||
</ModalSection>
|
||||
<ModalSection>
|
||||
<ModalSectionHeader strong>Generate a new link</ModalSectionHeader>
|
||||
<ModalSectionParagraph>
|
||||
Generate a new invite link! Note, any old links will <b>cease to be active</b>.
|
||||
</ModalSectionParagraph>
|
||||
<CreateInviteTokenButton onClick={() => createNativeUserInviteToken({})} size="small" type="text">
|
||||
<RedoOutlined style={{}} />
|
||||
</CreateInviteTokenButton>
|
||||
</ModalSection>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
108
datahub-web-react/src/app/identity/user/ViewResetTokenModal.tsx
Normal file
108
datahub-web-react/src/app/identity/user/ViewResetTokenModal.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { RedoOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { PageRoutes } from '../../../conf/Global';
|
||||
import { useCreateNativeUserResetTokenMutation } from '../../../graphql/user.generated';
|
||||
|
||||
const ModalSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 12px;
|
||||
`;
|
||||
|
||||
const ModalSectionHeader = styled(Typography.Text)`
|
||||
&&&& {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ModalSectionParagraph = styled(Typography.Paragraph)`
|
||||
&&&& {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CreateResetTokenButton = styled(Button)`
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
margin-left: -6px;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
userUrn: string;
|
||||
username: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ViewResetTokenModal({ visible, userUrn, username, onClose }: Props) {
|
||||
const baseUrl = window.location.origin;
|
||||
const [hasGeneratedResetToken, setHasGeneratedResetToken] = useState(false);
|
||||
|
||||
const [createNativeUserResetToken, { data: createNativeUserResetTokenData }] =
|
||||
useCreateNativeUserResetTokenMutation({});
|
||||
|
||||
const resetToken = createNativeUserResetTokenData?.createNativeUserResetToken?.resetToken || '';
|
||||
|
||||
const inviteLink = `${baseUrl}${PageRoutes.RESET_CREDENTIALS}?reset_token=${resetToken}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={700}
|
||||
footer={null}
|
||||
title={
|
||||
<Typography.Text>
|
||||
<b>Reset User Password</b>
|
||||
</Typography.Text>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{hasGeneratedResetToken ? (
|
||||
<ModalSection>
|
||||
<ModalSectionHeader strong>Share reset link</ModalSectionHeader>
|
||||
<ModalSectionParagraph>
|
||||
Share this reset link to reset the credentials for {username}.
|
||||
<b>This link will expire in 24 hours.</b>
|
||||
</ModalSectionParagraph>
|
||||
<Typography.Paragraph copyable={{ text: inviteLink }}>
|
||||
<pre>{inviteLink}</pre>
|
||||
</Typography.Paragraph>
|
||||
</ModalSection>
|
||||
) : (
|
||||
<ModalSection>
|
||||
<ModalSectionHeader strong>A new link must be generated</ModalSectionHeader>
|
||||
<ModalSectionParagraph>
|
||||
You cannot view any old reset links. Please generate a new one below.
|
||||
</ModalSectionParagraph>
|
||||
</ModalSection>
|
||||
)}
|
||||
<ModalSection>
|
||||
<ModalSectionHeader strong>Generate a new link</ModalSectionHeader>
|
||||
<ModalSectionParagraph>
|
||||
Generate a new reset link! Note, any old links will <b>cease to be active</b>.
|
||||
</ModalSectionParagraph>
|
||||
<CreateResetTokenButton
|
||||
onClick={() => {
|
||||
createNativeUserResetToken({
|
||||
variables: {
|
||||
input: {
|
||||
userUrn,
|
||||
},
|
||||
},
|
||||
});
|
||||
setHasGeneratedResetToken(true);
|
||||
}}
|
||||
size="small"
|
||||
type="text"
|
||||
>
|
||||
<RedoOutlined style={{}} />
|
||||
</CreateResetTokenButton>
|
||||
</ModalSection>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -5,8 +5,11 @@ export enum PageRoutes {
|
||||
/**
|
||||
* Server-side authentication route
|
||||
*/
|
||||
ROOT = '/',
|
||||
AUTHENTICATE = '/authenticate',
|
||||
SIGN_UP = '/signup',
|
||||
LOG_IN = '/login',
|
||||
RESET_CREDENTIALS = '/reset',
|
||||
SEARCH_RESULTS = '/search/:type?',
|
||||
SEARCH = '/search',
|
||||
BROWSE = '/browse',
|
||||
|
||||
@ -30,6 +30,7 @@ query getMe {
|
||||
manageDomains
|
||||
manageTests
|
||||
manageGlossaries
|
||||
manageUserCredentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ query getUser($urn: String!, $groupsCount: Int!) {
|
||||
corpUser(urn: $urn) {
|
||||
urn
|
||||
username
|
||||
isNativeUser
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
@ -90,6 +91,7 @@ query listUsers($input: ListUsersInput!) {
|
||||
users {
|
||||
urn
|
||||
username
|
||||
isNativeUser
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
@ -124,3 +126,22 @@ mutation updateCorpUserProperties($urn: String!, $input: CorpUserUpdateInput!) {
|
||||
urn
|
||||
}
|
||||
}
|
||||
|
||||
mutation createNativeUserInviteToken {
|
||||
createNativeUserInviteToken {
|
||||
inviteToken
|
||||
}
|
||||
}
|
||||
|
||||
query getNativeUserInviteToken {
|
||||
getNativeUserInviteToken {
|
||||
inviteToken
|
||||
}
|
||||
}
|
||||
|
||||
mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {
|
||||
createNativeUserResetToken(input: $input) {
|
||||
resetToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,49 @@
|
||||
# Adding Users to DataHub
|
||||
|
||||
Users can log into DataHub in 2 ways:
|
||||
Users can log into DataHub in 3 ways:
|
||||
|
||||
1. Static credentials (Simplest)
|
||||
2. Single Sign-On via [OpenID Connect](https://www.google.com/search?q=openid+connect&oq=openid+connect&aqs=chrome.0.0i131i433i512j0i512l4j69i60l2j69i61.1468j0j7&sourceid=chrome&ie=UTF-8) (For Production Use)
|
||||
1. Invite users via the UI
|
||||
2. Static credentials
|
||||
3. Single Sign-On via [OpenID Connect](https://www.google.com/search?q=openid+connect&oq=openid+connect&aqs=chrome.0.0i131i433i512j0i512l4j69i60l2j69i61.1468j0j7&sourceid=chrome&ie=UTF-8) (For Production Use)
|
||||
|
||||
which can be both enabled simultaneously. Option 1 is useful for running proof-of-concept exercises, or just getting DataHub up & running quickly. Option 2 is highly recommended for deploying DataHub in production.
|
||||
which can be enabled simultaneously. Options 1 and 2 are useful for running proof-of-concept exercises, or just getting DataHub up & running quickly. Option 3 is highly recommended for deploying DataHub in production.
|
||||
|
||||
# Method 1: Inviting users via the DataHub UI
|
||||
|
||||
# Method 1: Configuring static credentials
|
||||
## Send prospective users an invite link
|
||||
|
||||
With the right permissions (`MANAGE_USER_CREDENTIALS`), you can invite new users to your deployed DataHub instance from the UI. It's as simple as sending a link!
|
||||
|
||||
First navigate, to the Users and Groups tab (under Access) on the Settings page. You'll then see an `Invite Users` button. Note that this will only be clickable
|
||||
if you have the correct permissions.
|
||||
|
||||

|
||||
|
||||
If you click on this button, you'll see a pop-up where you can copy an invite link to send to users, or generate a fresh one.
|
||||
|
||||

|
||||
|
||||
When a new user visits the link, they will be directed to a sign up screen. Note that if a new link has since been regenerated, the new user won't be able to sign up!
|
||||
|
||||

|
||||
|
||||
## Reset password for native users
|
||||
|
||||
If a user forgets their password, an admin user with the `MANAGE_USER_CREDENTIALS` privilege can go to the Users and Groups tab and click on the respective user's
|
||||
`Reset user password` button.
|
||||
|
||||

|
||||
|
||||
Similar to the invite link, you can generate a new reset link and send a link to that user which they can use to reset their credentials.
|
||||
|
||||

|
||||
|
||||
When that user visits the link, they will be direct to a screen where they can reset their credentials. If the link is older than 24 hours or another link has since
|
||||
been generated, they won't be able to reset their credentials!
|
||||
|
||||

|
||||
|
||||
# Method 2: Configuring static credentials
|
||||
|
||||
## Create a user.props file
|
||||
|
||||
@ -134,8 +169,7 @@ and modify the `datahub-frontend-react` block to contain the extra volume mount.
|
||||
datahub docker quickstart —quickstart-compose-file <your-modified-compose>.yml
|
||||
```
|
||||
|
||||
|
||||
# Method 2: Configuring SSO via OpenID Connect
|
||||
# Method 3: Configuring SSO via OpenID Connect
|
||||
|
||||
Setting up SSO via OpenID Connect means that users will be able to login to DataHub via a central Identity Provider such as
|
||||
|
||||
|
||||
BIN
docs/imgs/invite-users-button.png
Normal file
BIN
docs/imgs/invite-users-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/imgs/invite-users-popup.png
Normal file
BIN
docs/imgs/invite-users-popup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
docs/imgs/reset-credentials-screen.png
Normal file
BIN
docs/imgs/reset-credentials-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/imgs/reset-user-password-button.png
Normal file
BIN
docs/imgs/reset-user-password-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/imgs/reset-user-password-popup.png
Normal file
BIN
docs/imgs/reset-user-password-popup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/imgs/user-sign-up-screen.png
Normal file
BIN
docs/imgs/user-sign-up-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@ -0,0 +1,32 @@
|
||||
namespace com.linkedin.identity
|
||||
|
||||
import com.linkedin.common.CorpuserUrn
|
||||
|
||||
/**
|
||||
* Corp user credentials
|
||||
*/
|
||||
@Aspect = {
|
||||
"name": "corpUserCredentials"
|
||||
}
|
||||
@Aspect.EntityUrns = [ "com.linkedin.common.CorpuserUrn" ]
|
||||
record CorpUserCredentials {
|
||||
/**
|
||||
* Salt used to hash password
|
||||
*/
|
||||
salt: string
|
||||
|
||||
/**
|
||||
* Hashed password generated by concatenating salt and password, then hashing
|
||||
*/
|
||||
hashedPassword: string
|
||||
|
||||
/**
|
||||
* Optional token needed to reset a user's password. Can only be set by the admin.
|
||||
*/
|
||||
passwordResetToken: optional string
|
||||
|
||||
/**
|
||||
* When the password reset token expires.
|
||||
*/
|
||||
passwordResetTokenExpirationTimeMillis: optional long
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
namespace com.linkedin.identity
|
||||
|
||||
import com.linkedin.common.Urn
|
||||
|
||||
/**
|
||||
* Aspect used to store the token needed to invite native DataHub users
|
||||
*/
|
||||
@Aspect = {
|
||||
"name": "inviteToken"
|
||||
}
|
||||
record InviteToken {
|
||||
/**
|
||||
* The encrypted invite token.
|
||||
*/
|
||||
token: string
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
namespace com.linkedin.metadata.key
|
||||
|
||||
/**
|
||||
* Key for an InviteToken.
|
||||
*/
|
||||
@Aspect = {
|
||||
"name": "inviteTokenKey"
|
||||
}
|
||||
record InviteTokenKey {
|
||||
/**
|
||||
* A unique id for the invite token.
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
@ -88,6 +88,7 @@ entities:
|
||||
- groupMembership
|
||||
- globalTags
|
||||
- status
|
||||
- corpUserCredentials
|
||||
- name: corpGroup
|
||||
doc: CorpGroup represents an identity of a group of users in the enterprise.
|
||||
keyAspect: corpGroupKey
|
||||
@ -232,4 +233,9 @@ entities:
|
||||
aspects:
|
||||
- dataHubUpgradeRequest
|
||||
- dataHubUpgradeResult
|
||||
- name: inviteToken
|
||||
category: core
|
||||
keyAspect: inviteTokenKey
|
||||
aspects:
|
||||
- inviteToken
|
||||
events:
|
||||
|
||||
@ -0,0 +1,295 @@
|
||||
package com.datahub.authentication.user;
|
||||
|
||||
import com.datahub.authentication.Authentication;
|
||||
import com.linkedin.common.AuditStamp;
|
||||
import com.linkedin.common.urn.Urn;
|
||||
import com.linkedin.entity.client.EntityClient;
|
||||
import com.linkedin.events.metadata.ChangeType;
|
||||
import com.linkedin.identity.CorpUserCredentials;
|
||||
import com.linkedin.identity.CorpUserInfo;
|
||||
import com.linkedin.identity.CorpUserStatus;
|
||||
import com.linkedin.identity.InviteToken;
|
||||
import com.linkedin.metadata.entity.EntityService;
|
||||
import com.linkedin.metadata.secret.SecretService;
|
||||
import com.linkedin.metadata.utils.GenericRecordUtils;
|
||||
import com.linkedin.mxe.MetadataChangeProposal;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static com.linkedin.metadata.Constants.*;
|
||||
|
||||
|
||||
/**
|
||||
* Service responsible for creating, updating and authenticating native DataHub users.
|
||||
*/
|
||||
@Slf4j
|
||||
public class NativeUserService {
|
||||
private static final int LOWERCASE_ASCII_START = 97;
|
||||
private static final int LOWERCASE_ASCII_END = 122;
|
||||
private static final int INVITE_TOKEN_LENGTH = 32;
|
||||
private static final int SALT_TOKEN_LENGTH = 16;
|
||||
private static final int PASSWORD_RESET_TOKEN_LENGTH = 32;
|
||||
private static final String HASHING_ALGORITHM = "SHA-256";
|
||||
private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1);
|
||||
|
||||
private final EntityService _entityService;
|
||||
private final EntityClient _entityClient;
|
||||
private final SecretService _secretService;
|
||||
private final SecureRandom _secureRandom;
|
||||
private final MessageDigest _messageDigest;
|
||||
|
||||
public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityClient entityClient, @Nonnull SecretService secretService)
|
||||
throws Exception {
|
||||
Objects.requireNonNull(entityService, "entityService must not be null!");
|
||||
Objects.requireNonNull(entityClient, "entityClient must not be null!");
|
||||
Objects.requireNonNull(secretService, "secretService must not be null!");
|
||||
|
||||
_entityService = entityService;
|
||||
_entityClient = entityClient;
|
||||
_secretService = secretService;
|
||||
_secureRandom = new SecureRandom();
|
||||
_messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM);
|
||||
}
|
||||
|
||||
public void createNativeUser(@Nonnull String userUrnString, @Nonnull String fullName, @Nonnull String email,
|
||||
@Nonnull String title, @Nonnull String password, @Nonnull String inviteToken, Authentication authentication)
|
||||
throws Exception {
|
||||
Objects.requireNonNull(userUrnString, "userUrnSting must not be null!");
|
||||
Objects.requireNonNull(fullName, "fullName must not be null!");
|
||||
Objects.requireNonNull(email, "email must not be null!");
|
||||
Objects.requireNonNull(title, "title must not be null!");
|
||||
Objects.requireNonNull(password, "password must not be null!");
|
||||
Objects.requireNonNull(inviteToken, "inviteToken must not be null!");
|
||||
|
||||
InviteToken inviteTokenAspect =
|
||||
(InviteToken) _entityService.getLatestAspect(Urn.createFromString(GLOBAL_INVITE_TOKEN),
|
||||
INVITE_TOKEN_ASPECT_NAME);
|
||||
if (inviteTokenAspect == null || !inviteTokenAspect.hasToken() || !_secretService.decrypt(
|
||||
inviteTokenAspect.getToken()).equals(inviteToken)) {
|
||||
throw new RuntimeException("Invalid sign-up token. Please ask your administrator to send you an updated link!");
|
||||
}
|
||||
|
||||
Urn userUrn = Urn.createFromString(userUrnString);
|
||||
if (_entityService.exists(userUrn)) {
|
||||
throw new RuntimeException("This user already exists! Cannot create a new user.");
|
||||
}
|
||||
updateCorpUserInfo(userUrn, fullName, email, title, authentication);
|
||||
updateCorpUserStatus(userUrn, authentication);
|
||||
updateCorpUserCredentials(userUrn, password, authentication);
|
||||
}
|
||||
|
||||
void updateCorpUserInfo(@Nonnull Urn userUrn, @Nonnull String fullName, @Nonnull String email, @Nonnull String title,
|
||||
Authentication authentication) throws Exception {
|
||||
// Construct corpUserInfo
|
||||
final CorpUserInfo corpUserInfo = new CorpUserInfo();
|
||||
corpUserInfo.setFullName(fullName);
|
||||
corpUserInfo.setDisplayName(fullName);
|
||||
corpUserInfo.setEmail(email);
|
||||
corpUserInfo.setTitle(title);
|
||||
corpUserInfo.setActive(true);
|
||||
|
||||
// Ingest corpUserInfo MCP
|
||||
final MetadataChangeProposal corpUserInfoProposal = new MetadataChangeProposal();
|
||||
corpUserInfoProposal.setEntityType(CORP_USER_ENTITY_NAME);
|
||||
corpUserInfoProposal.setEntityUrn(userUrn);
|
||||
corpUserInfoProposal.setAspectName(CORP_USER_INFO_ASPECT_NAME);
|
||||
corpUserInfoProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserInfo));
|
||||
corpUserInfoProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(corpUserInfoProposal, authentication);
|
||||
}
|
||||
|
||||
void updateCorpUserStatus(@Nonnull Urn userUrn, Authentication authentication) throws Exception {
|
||||
// Construct corpUserStatus
|
||||
CorpUserStatus corpUserStatus = new CorpUserStatus();
|
||||
corpUserStatus.setStatus(CORP_USER_STATUS_ACTIVE);
|
||||
corpUserStatus.setLastModified(
|
||||
new AuditStamp().setActor(Urn.createFromString(SYSTEM_ACTOR)).setTime(System.currentTimeMillis()));
|
||||
|
||||
// Ingest corpUserStatus MCP
|
||||
final MetadataChangeProposal corpUserStatusProposal = new MetadataChangeProposal();
|
||||
corpUserStatusProposal.setEntityType(CORP_USER_ENTITY_NAME);
|
||||
corpUserStatusProposal.setEntityUrn(userUrn);
|
||||
corpUserStatusProposal.setAspectName(CORP_USER_STATUS_ASPECT_NAME);
|
||||
corpUserStatusProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserStatus));
|
||||
corpUserStatusProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(corpUserStatusProposal, authentication);
|
||||
}
|
||||
|
||||
void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password,
|
||||
Authentication authentication) throws Exception {
|
||||
// Construct corpUserCredentials
|
||||
CorpUserCredentials corpUserCredentials = new CorpUserCredentials();
|
||||
final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH);
|
||||
String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt));
|
||||
corpUserCredentials.setSalt(encryptedSalt);
|
||||
String hashedPassword = getHashedPassword(salt, password);
|
||||
corpUserCredentials.setHashedPassword(hashedPassword);
|
||||
|
||||
// Ingest corpUserCredentials MCP
|
||||
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
|
||||
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
|
||||
corpUserCredentialsProposal.setEntityUrn(userUrn);
|
||||
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
|
||||
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
|
||||
}
|
||||
|
||||
public String generateNativeUserInviteToken(Authentication authentication) throws Exception {
|
||||
// Construct inviteToken
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
String token = generateRandomLowercaseToken(INVITE_TOKEN_LENGTH);
|
||||
inviteToken.setToken(_secretService.encrypt(token));
|
||||
|
||||
// Ingest corpUserCredentials MCP
|
||||
final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal();
|
||||
inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME);
|
||||
inviteTokenProposal.setEntityUrn(Urn.createFromString(GLOBAL_INVITE_TOKEN));
|
||||
inviteTokenProposal.setAspectName(INVITE_TOKEN_ASPECT_NAME);
|
||||
inviteTokenProposal.setAspect(GenericRecordUtils.serializeAspect(inviteToken));
|
||||
inviteTokenProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(inviteTokenProposal, authentication);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public String getNativeUserInviteToken(Authentication authentication) throws Exception {
|
||||
InviteToken inviteToken = (InviteToken) _entityService.getLatestAspect(Urn.createFromString(GLOBAL_INVITE_TOKEN),
|
||||
INVITE_TOKEN_ASPECT_NAME);
|
||||
if (inviteToken == null || !inviteToken.hasToken()) {
|
||||
return generateNativeUserInviteToken(authentication);
|
||||
}
|
||||
return _secretService.decrypt(inviteToken.getToken());
|
||||
}
|
||||
|
||||
public String generateNativeUserPasswordResetToken(@Nonnull String userUrnString,
|
||||
Authentication authentication) throws Exception {
|
||||
Objects.requireNonNull(userUrnString, "userUrnString must not be null!");
|
||||
|
||||
Urn userUrn = Urn.createFromString(userUrnString);
|
||||
|
||||
CorpUserCredentials corpUserCredentials =
|
||||
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
|
||||
throw new RuntimeException("User does not exist or is a non-native user!");
|
||||
}
|
||||
// Add reset token to CorpUserCredentials
|
||||
String passwordResetToken = generateRandomLowercaseToken(PASSWORD_RESET_TOKEN_LENGTH);
|
||||
corpUserCredentials.setPasswordResetToken(_secretService.encrypt(passwordResetToken));
|
||||
|
||||
long expirationTime = Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli();
|
||||
corpUserCredentials.setPasswordResetTokenExpirationTimeMillis(expirationTime);
|
||||
|
||||
// Ingest CorpUserCredentials MCP
|
||||
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
|
||||
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
|
||||
corpUserCredentialsProposal.setEntityUrn(userUrn);
|
||||
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
|
||||
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
|
||||
|
||||
return passwordResetToken;
|
||||
}
|
||||
|
||||
public void resetCorpUserCredentials(@Nonnull String userUrnString, @Nonnull String password,
|
||||
@Nonnull String resetToken, Authentication authentication) throws Exception {
|
||||
Objects.requireNonNull(userUrnString, "userUrnString must not be null!");
|
||||
Objects.requireNonNull(password, "password must not be null!");
|
||||
Objects.requireNonNull(resetToken, "resetToken must not be null!");
|
||||
|
||||
Urn userUrn = Urn.createFromString(userUrnString);
|
||||
|
||||
CorpUserCredentials corpUserCredentials =
|
||||
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
|
||||
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
|
||||
throw new RuntimeException("User does not exist!");
|
||||
}
|
||||
|
||||
if (!corpUserCredentials.hasPasswordResetToken()
|
||||
|| !corpUserCredentials.hasPasswordResetTokenExpirationTimeMillis()
|
||||
|| corpUserCredentials.getPasswordResetTokenExpirationTimeMillis() == null) {
|
||||
throw new RuntimeException("User has not generated a password reset token!");
|
||||
}
|
||||
|
||||
if (!_secretService.decrypt(
|
||||
corpUserCredentials.getPasswordResetToken()).equals(resetToken)) {
|
||||
throw new RuntimeException("Invalid reset token. Please ask your administrator to send you an updated link!");
|
||||
}
|
||||
|
||||
long currentTimeMillis = Instant.now().toEpochMilli();
|
||||
if (currentTimeMillis > corpUserCredentials.getPasswordResetTokenExpirationTimeMillis()) {
|
||||
throw new RuntimeException("Reset token has expired! Please ask your administrator to create a new one");
|
||||
}
|
||||
|
||||
// Construct corpUserCredentials
|
||||
final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH);
|
||||
String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt));
|
||||
corpUserCredentials.setSalt(encryptedSalt);
|
||||
String hashedPassword = getHashedPassword(salt, password);
|
||||
corpUserCredentials.setHashedPassword(hashedPassword);
|
||||
|
||||
// Ingest corpUserCredentials MCP
|
||||
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
|
||||
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
|
||||
corpUserCredentialsProposal.setEntityUrn(userUrn);
|
||||
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
|
||||
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
|
||||
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
|
||||
}
|
||||
|
||||
byte[] getRandomBytes(int length) {
|
||||
byte[] randomBytes = new byte[length];
|
||||
_secureRandom.nextBytes(randomBytes);
|
||||
return randomBytes;
|
||||
}
|
||||
|
||||
String generateRandomLowercaseToken(int length) {
|
||||
return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1)
|
||||
.mapToObj(i -> String.valueOf((char) i))
|
||||
.collect(Collectors.joining());
|
||||
}
|
||||
|
||||
byte[] saltPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException {
|
||||
byte[] passwordBytes = password.getBytes();
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
byteArrayOutputStream.write(salt);
|
||||
byteArrayOutputStream.write(passwordBytes);
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
public String getHashedPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException {
|
||||
byte[] saltedPassword = saltPassword(salt, password);
|
||||
byte[] hashedPassword = _messageDigest.digest(saltedPassword);
|
||||
return Base64.getEncoder().encodeToString(hashedPassword);
|
||||
}
|
||||
|
||||
public boolean doesPasswordMatch(@Nonnull String userUrnString, @Nonnull String password) throws Exception {
|
||||
Objects.requireNonNull(userUrnString, "userUrnSting must not be null!");
|
||||
Objects.requireNonNull(password, "Password must not be null!");
|
||||
|
||||
Urn userUrn = Urn.createFromString(userUrnString);
|
||||
CorpUserCredentials corpUserCredentials =
|
||||
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
|
||||
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String decryptedSalt = _secretService.decrypt(corpUserCredentials.getSalt());
|
||||
byte[] salt = Base64.getDecoder().decode(decryptedSalt);
|
||||
String storedHashedPassword = corpUserCredentials.getHashedPassword();
|
||||
String hashedPassword = getHashedPassword(salt, password);
|
||||
return storedHashedPassword.equals(hashedPassword);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,289 @@
|
||||
package com.datahub.authentication.user;
|
||||
|
||||
import com.datahub.authentication.Actor;
|
||||
import com.datahub.authentication.ActorType;
|
||||
import com.datahub.authentication.Authentication;
|
||||
import com.linkedin.common.urn.CorpuserUrn;
|
||||
import com.linkedin.common.urn.Urn;
|
||||
import com.linkedin.entity.client.EntityClient;
|
||||
import com.linkedin.identity.CorpUserCredentials;
|
||||
import com.linkedin.identity.InviteToken;
|
||||
import com.linkedin.metadata.entity.EntityService;
|
||||
import com.linkedin.metadata.secret.SecretService;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import static com.linkedin.metadata.Constants.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.testng.Assert.*;
|
||||
|
||||
|
||||
public class NativeUserServiceTest {
|
||||
private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system";
|
||||
|
||||
private static final String USER_URN_STRING = "urn:li:corpuser:test";
|
||||
private static final String FULL_NAME = "MOCK NAME";
|
||||
private static final String EMAIL = "mock@email.com";
|
||||
private static final String TITLE = "Data Scientist";
|
||||
private static final String PASSWORD = "password";
|
||||
private static final String INVITE_TOKEN = "inviteToken";
|
||||
private static final String ENCRYPTED_INVITE_TOKEN = "encryptedInviteToken";
|
||||
private static final String RESET_TOKEN = "inviteToken";
|
||||
private static final String ENCRYPTED_RESET_TOKEN = "encryptedInviteToken";
|
||||
private static final String ENCRYPTED_SALT = "encryptedSalt";
|
||||
private static final Urn USER_URN = new CorpuserUrn(EMAIL);
|
||||
private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1);
|
||||
private static final Authentication SYSTEM_AUTHENTICATION =
|
||||
new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), "");
|
||||
|
||||
private EntityService _entityService;
|
||||
private EntityClient _entityClient;
|
||||
private SecretService _secretService;
|
||||
private NativeUserService _nativeUserService;
|
||||
|
||||
@BeforeMethod
|
||||
public void setupTest() throws Exception {
|
||||
_entityService = mock(EntityService.class);
|
||||
_entityClient = mock(EntityClient.class);
|
||||
_secretService = mock(SecretService.class);
|
||||
|
||||
_nativeUserService = new NativeUserService(_entityService, _entityClient, _secretService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor() throws Exception {
|
||||
assertThrows(() -> new NativeUserService(null, _entityClient, _secretService));
|
||||
assertThrows(() -> new NativeUserService(_entityService, null, _secretService));
|
||||
assertThrows(() -> new NativeUserService(_entityService, _entityClient, null));
|
||||
|
||||
// Succeeds!
|
||||
new NativeUserService(_entityService, _entityClient, _secretService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateNativeUserNullArguments() {
|
||||
assertThrows(() -> _nativeUserService.createNativeUser(null, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, null, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
assertThrows(
|
||||
() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, null, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
assertThrows(
|
||||
() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, null, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, null, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, null,
|
||||
SYSTEM_AUTHENTICATION));
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class,
|
||||
expectedExceptionsMessageRegExp = "Invalid sign-up token. Please ask your administrator to send you an updated link!")
|
||||
public void testCreateNativeUserInviteTokenDoesNotExist() throws Exception {
|
||||
// Nonexistent invite token
|
||||
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(null);
|
||||
|
||||
_nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "This user already exists! Cannot create a new user.")
|
||||
public void testCreateNativeUserUserAlreadyExists() throws Exception {
|
||||
InviteToken mockInviteTokenAspect = mock(InviteToken.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect);
|
||||
when(mockInviteTokenAspect.hasToken()).thenReturn(true);
|
||||
when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN);
|
||||
|
||||
// The user already exists
|
||||
when(_entityService.exists(any())).thenReturn(true);
|
||||
|
||||
_nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateNativeUserPasses() throws Exception {
|
||||
InviteToken mockInviteTokenAspect = mock(InviteToken.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect);
|
||||
when(mockInviteTokenAspect.hasToken()).thenReturn(true);
|
||||
when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
when(_entityService.exists(any())).thenReturn(false);
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN);
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT);
|
||||
|
||||
_nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
|
||||
SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpdateCorpUserInfoPasses() throws Exception {
|
||||
_nativeUserService.updateCorpUserInfo(USER_URN, FULL_NAME, EMAIL, TITLE, SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpdateCorpUserStatusPasses() throws Exception {
|
||||
_nativeUserService.updateCorpUserStatus(USER_URN, SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpdateCorpUserCredentialsPasses() throws Exception {
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT);
|
||||
|
||||
_nativeUserService.updateCorpUserCredentials(USER_URN, PASSWORD, SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateNativeUserInviteTokenPasses() throws Exception {
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
|
||||
_nativeUserService.generateNativeUserInviteToken(SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetNativeUserInviteTokenInviteTokenDoesNotExistPasses() throws Exception {
|
||||
// Nonexistent invite token
|
||||
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(null);
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
|
||||
_nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetNativeUserInviteTokenPasses() throws Exception {
|
||||
InviteToken mockInviteTokenAspect = mock(InviteToken.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect);
|
||||
when(_entityService.exists(any())).thenReturn(false);
|
||||
when(mockInviteTokenAspect.hasToken()).thenReturn(true);
|
||||
when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN);
|
||||
|
||||
assertEquals(_nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION), INVITE_TOKEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateNativeUserResetTokenNullArguments() {
|
||||
assertThrows(() -> _nativeUserService.generateNativeUserPasswordResetToken(null, SYSTEM_AUTHENTICATION));
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "User does not exist or is a non-native user!")
|
||||
public void testGenerateNativeUserResetTokenNotNativeUser() throws Exception {
|
||||
// Nonexistent corpUserCredentials
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(null);
|
||||
|
||||
_nativeUserService.generateNativeUserPasswordResetToken(USER_URN_STRING, SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateNativeUserResetToken() throws Exception {
|
||||
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
|
||||
mockCorpUserCredentialsAspect);
|
||||
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
|
||||
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
|
||||
|
||||
_nativeUserService.generateNativeUserPasswordResetToken(USER_URN_STRING, SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testResetCorpUserCredentialsNullArguments() {
|
||||
assertThrows(() -> _nativeUserService.resetCorpUserCredentials(null, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION));
|
||||
assertThrows(
|
||||
() -> _nativeUserService.resetCorpUserCredentials(USER_URN_STRING, null, RESET_TOKEN, SYSTEM_AUTHENTICATION));
|
||||
assertThrows(
|
||||
() -> _nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, null, SYSTEM_AUTHENTICATION));
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "User has not generated a password reset token!")
|
||||
public void testResetCorpUserCredentialsNoPasswordResetToken() throws Exception {
|
||||
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
|
||||
mockCorpUserCredentialsAspect);
|
||||
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
|
||||
// No password reset token
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(false);
|
||||
|
||||
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class,
|
||||
expectedExceptionsMessageRegExp = "Invalid reset token. Please ask your administrator to send you an updated link!")
|
||||
public void testResetCorpUserCredentialsBadResetToken() throws Exception {
|
||||
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
|
||||
mockCorpUserCredentialsAspect);
|
||||
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(
|
||||
Instant.now().toEpochMilli());
|
||||
// Reset token won't match
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn("badResetToken");
|
||||
|
||||
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = RuntimeException.class,
|
||||
expectedExceptionsMessageRegExp = "Reset token has expired! Please ask your administrator to create a new one")
|
||||
public void testResetCorpUserCredentialsExpiredResetToken() throws Exception {
|
||||
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
|
||||
mockCorpUserCredentialsAspect);
|
||||
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
|
||||
// Reset token expiration time will be before the system time when we run resetCorpUserCredentials
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(0L);
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn(RESET_TOKEN);
|
||||
|
||||
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testResetCorpUserCredentialsPasses() throws Exception {
|
||||
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
|
||||
mockCorpUserCredentialsAspect);
|
||||
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
|
||||
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
|
||||
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(
|
||||
Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli());
|
||||
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn(RESET_TOKEN);
|
||||
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT);
|
||||
|
||||
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
|
||||
verify(_entityClient).ingestProposal(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDoesPasswordMatchNullArguments() {
|
||||
assertThrows(() -> _nativeUserService.doesPasswordMatch(null, PASSWORD));
|
||||
assertThrows(() -> _nativeUserService.doesPasswordMatch(USER_URN_STRING, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDoesPasswordMatchNoCorpUserCredentials() throws Exception {
|
||||
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(null);
|
||||
|
||||
assertFalse(_nativeUserService.doesPasswordMatch(USER_URN_STRING, PASSWORD));
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
package com.datahub.authentication;
|
||||
|
||||
import com.datahub.authentication.token.TokenType;
|
||||
import com.datahub.authentication.token.StatelessTokenService;
|
||||
import com.datahub.authentication.token.TokenType;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -25,6 +26,16 @@ public class AuthServiceController {
|
||||
|
||||
private static final String USER_ID_FIELD_NAME = "userId";
|
||||
private static final String ACCESS_TOKEN_FIELD_NAME = "accessToken";
|
||||
private static final String USER_URN_FIELD_NAME = "userUrn";
|
||||
private static final String FULL_NAME_FIELD_NAME = "fullName";
|
||||
private static final String EMAIL_FIELD_NAME = "email";
|
||||
private static final String TITLE_FIELD_NAME = "title";
|
||||
private static final String PASSWORD_FIELD_NAME = "password";
|
||||
private static final String INVITE_TOKEN_FIELD_NAME = "inviteToken";
|
||||
private static final String RESET_TOKEN_FIELD_NAME = "resetToken";
|
||||
private static final String IS_NATIVE_USER_CREATED_FIELD_NAME = "isNativeUserCreated";
|
||||
private static final String ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD_NAME = "areNativeUserCredentialsReset";
|
||||
private static final String DOES_PASSWORD_MATCH_FIELD_NAME = "doesPasswordMatch";
|
||||
|
||||
@Inject
|
||||
StatelessTokenService _statelessTokenService;
|
||||
@ -35,13 +46,16 @@ public class AuthServiceController {
|
||||
@Inject
|
||||
ConfigurationProvider _configProvider;
|
||||
|
||||
@Inject
|
||||
NativeUserService _nativeUserService;
|
||||
|
||||
/**
|
||||
* Generates a JWT access token for as user UI session, provided a unique "user id" to generate the token for inside a JSON
|
||||
* POST body.
|
||||
*
|
||||
* Example Request:
|
||||
*
|
||||
* POST /generateSessionToken -H "Authorization: Basic <system-client-id>:<system-client-secret>"
|
||||
* POST /generateSessionTokenForUser -H "Authorization: Basic <system-client-id>:<system-client-secret>"
|
||||
* {
|
||||
* "userId": "datahub"
|
||||
* }
|
||||
@ -95,7 +109,192 @@ public class AuthServiceController {
|
||||
});
|
||||
}
|
||||
|
||||
// Currently only internal system is authorized to generate a token on behalf of a user!
|
||||
/**
|
||||
* Creates a native DataHub user using the provided full name, email and password. The provided invite token must
|
||||
* be current otherwise a new user will not be created.
|
||||
*
|
||||
* Example Request:
|
||||
*
|
||||
* POST /signUp -H "Authorization: Basic <system-client-id>:<system-client-secret>"
|
||||
* {
|
||||
* "fullName": "Full Name"
|
||||
* "userUrn": "urn:li:corpuser:test"
|
||||
* "email": "email@test.com"
|
||||
* "title": "Data Scientist"
|
||||
* "password": "password123"
|
||||
* "inviteToken": "abcd"
|
||||
* }
|
||||
*
|
||||
* Example Response:
|
||||
*
|
||||
* {
|
||||
* "isNativeUserCreated": true
|
||||
* }
|
||||
*/
|
||||
@PostMapping(value = "/signUp", produces = "application/json;charset=utf-8")
|
||||
CompletableFuture<ResponseEntity<String>> signUp(final HttpEntity<String> httpEntity) {
|
||||
String jsonStr = httpEntity.getBody();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode bodyJson;
|
||||
try {
|
||||
bodyJson = mapper.readTree(jsonStr);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error(String.format("Failed to parse json while attempting to create native user %s", jsonStr));
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
if (bodyJson == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
/*
|
||||
* Extract username and password field
|
||||
*/
|
||||
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
|
||||
JsonNode fullName = bodyJson.get(FULL_NAME_FIELD_NAME);
|
||||
JsonNode email = bodyJson.get(EMAIL_FIELD_NAME);
|
||||
JsonNode title = bodyJson.get(TITLE_FIELD_NAME);
|
||||
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
|
||||
JsonNode inviteToken = bodyJson.get(INVITE_TOKEN_FIELD_NAME);
|
||||
if (fullName == null || userUrn == null || email == null || title == null || password == null
|
||||
|| inviteToken == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
String userUrnString = userUrn.asText();
|
||||
String fullNameString = fullName.asText();
|
||||
String emailString = email.asText();
|
||||
String titleString = title.asText();
|
||||
String passwordString = password.asText();
|
||||
String inviteTokenString = inviteToken.asText();
|
||||
log.debug(String.format("Attempting to create credentials for native user %s", userUrnString));
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
_nativeUserService.createNativeUser(userUrnString, fullNameString, emailString, titleString, passwordString,
|
||||
inviteTokenString, AuthenticationContext.getAuthentication());
|
||||
String response = buildSignUpResponse();
|
||||
return new ResponseEntity<>(response, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
log.error(String.format("Failed to create credentials for native user %s", userUrnString), e);
|
||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the credentials for a native DataHub user using the provided email and new password. The provided reset
|
||||
* token must be current otherwise the credentials will not be updated
|
||||
*
|
||||
* Example Request:
|
||||
*
|
||||
* POST /resetNativeUserCredentials -H "Authorization: Basic <system-client-id>:<system-client-secret>"
|
||||
* {
|
||||
* "userUrn": "urn:li:corpuser:test"
|
||||
* "password": "password123"
|
||||
* "resetToken": "abcd"
|
||||
* }
|
||||
*
|
||||
* Example Response:
|
||||
*
|
||||
* {
|
||||
* "areNativeUserCredentialsReset": true
|
||||
* }
|
||||
*/
|
||||
@PostMapping(value = "/resetNativeUserCredentials", produces = "application/json;charset=utf-8")
|
||||
CompletableFuture<ResponseEntity<String>> resetNativeUserCredentials(final HttpEntity<String> httpEntity) {
|
||||
String jsonStr = httpEntity.getBody();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode bodyJson;
|
||||
try {
|
||||
bodyJson = mapper.readTree(jsonStr);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error(String.format("Failed to parse json while attempting to create native user %s", jsonStr));
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
if (bodyJson == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
/*
|
||||
* Extract username and password field
|
||||
*/
|
||||
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
|
||||
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
|
||||
JsonNode resetToken = bodyJson.get(RESET_TOKEN_FIELD_NAME);
|
||||
if (userUrn == null || password == null || resetToken == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
String userUrnString = userUrn.asText();
|
||||
String passwordString = password.asText();
|
||||
String resetTokenString = resetToken.asText();
|
||||
log.debug(String.format("Attempting to reset credentials for native user %s", userUrnString));
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
_nativeUserService.resetCorpUserCredentials(userUrnString, passwordString, resetTokenString,
|
||||
AuthenticationContext.getAuthentication());
|
||||
String response = buildResetNativeUserCredentialsResponse();
|
||||
return new ResponseEntity<>(response, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
log.error(String.format("Failed to reset credentials for native user %s", userUrnString), e);
|
||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the credentials for a native DataHub user.
|
||||
*
|
||||
* Example Request:
|
||||
*
|
||||
* POST /verifyNativeUserCredentials -H "Authorization: Basic <system-client-id>:<system-client-secret>"
|
||||
* {
|
||||
* "userUrn": "urn:li:corpuser:test"
|
||||
* "password": "password123"
|
||||
* }
|
||||
*
|
||||
* Example Response:
|
||||
*
|
||||
* {
|
||||
* "passwordMatches": true
|
||||
* }
|
||||
*/
|
||||
@PostMapping(value = "/verifyNativeUserCredentials", produces = "application/json;charset=utf-8")
|
||||
CompletableFuture<ResponseEntity<String>> verifyNativeUserCredentials(final HttpEntity<String> httpEntity) {
|
||||
String jsonStr = httpEntity.getBody();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode bodyJson;
|
||||
try {
|
||||
bodyJson = mapper.readTree(jsonStr);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error(String.format("Failed to parse json while attempting to verify native user password %s", jsonStr));
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
if (bodyJson == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
/*
|
||||
* Extract username and password field
|
||||
*/
|
||||
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
|
||||
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
|
||||
if (userUrn == null || password == null) {
|
||||
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
String userUrnString = userUrn.asText();
|
||||
String passwordString = password.asText();
|
||||
log.debug(String.format("Attempting to verify credentials for native user %s", userUrnString));
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
boolean doesPasswordMatch = _nativeUserService.doesPasswordMatch(userUrnString, passwordString);
|
||||
String response = buildVerifyNativeUserPasswordResponse(doesPasswordMatch);
|
||||
return new ResponseEntity<>(response, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
log.error(String.format("Failed to verify credentials for native user %s", userUrnString), e);
|
||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Currently, only internal system is authorized to generate a token on behalf of a user!
|
||||
private boolean isAuthorizedToGenerateSessionToken(final String actorId) {
|
||||
// Verify that the actor is an internal system caller.
|
||||
final String systemClientId = _systemAuthentication.getActor().getId();
|
||||
@ -107,4 +306,22 @@ public class AuthServiceController {
|
||||
json.put(ACCESS_TOKEN_FIELD_NAME, token);
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
private String buildSignUpResponse() {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put(IS_NATIVE_USER_CREATED_FIELD_NAME, true);
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
private String buildResetNativeUserCredentialsResponse() {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put(ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD_NAME, true);
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
private String buildVerifyNativeUserPasswordResponse(final boolean doesPasswordMatch) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put(DOES_PASSWORD_MATCH_FIELD_NAME, doesPasswordMatch);
|
||||
return json.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
package com.linkedin.gms.factory.auth;
|
||||
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.entity.client.JavaEntityClient;
|
||||
import com.linkedin.gms.factory.spring.YamlPropertySourceFactory;
|
||||
import com.linkedin.metadata.entity.EntityService;
|
||||
import com.linkedin.metadata.secret.SecretService;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.PropertySource;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
|
||||
@Configuration
|
||||
@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class)
|
||||
public class NativeUserServiceFactory {
|
||||
@Autowired
|
||||
@Qualifier("entityService")
|
||||
private EntityService _entityService;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("javaEntityClient")
|
||||
private JavaEntityClient _javaEntityClient;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("dataHubSecretService")
|
||||
private SecretService _secretService;
|
||||
|
||||
@Bean(name = "nativeUserService")
|
||||
@Scope("singleton")
|
||||
@Nonnull
|
||||
protected NativeUserService getInstance() throws Exception {
|
||||
return new NativeUserService(this._entityService, this._javaEntityClient, this._secretService);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.linkedin.gms.factory.graphql;
|
||||
|
||||
import com.datahub.authentication.token.StatefulTokenService;
|
||||
import com.datahub.authentication.user.NativeUserService;
|
||||
import com.linkedin.datahub.graphql.GmsGraphQLEngine;
|
||||
import com.linkedin.datahub.graphql.GraphQLEngine;
|
||||
import com.linkedin.datahub.graphql.analytics.service.AnalyticsService;
|
||||
@ -97,6 +98,10 @@ public class GraphQLEngineFactory {
|
||||
@Qualifier("timelineService")
|
||||
private TimelineService _timelineService;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("nativeUserService")
|
||||
private NativeUserService _nativeUserService;
|
||||
|
||||
@Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED
|
||||
private Boolean isAnalyticsEnabled;
|
||||
|
||||
@ -115,6 +120,7 @@ public class GraphQLEngineFactory {
|
||||
_timeseriesAspectService,
|
||||
_entityRegistry,
|
||||
_secretService,
|
||||
_nativeUserService,
|
||||
_configProvider.getIngestion(),
|
||||
_configProvider.getAuthentication(),
|
||||
_configProvider.getAuthorization(),
|
||||
@ -138,6 +144,7 @@ public class GraphQLEngineFactory {
|
||||
_timeseriesAspectService,
|
||||
_entityRegistry,
|
||||
_secretService,
|
||||
_nativeUserService,
|
||||
_configProvider.getIngestion(),
|
||||
_configProvider.getAuthentication(),
|
||||
_configProvider.getAuthorization(),
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
"MANAGE_ACCESS_TOKENS",
|
||||
"MANAGE_DOMAINS",
|
||||
"MANAGE_TESTS",
|
||||
"MANAGE_GLOSSARIES"
|
||||
"MANAGE_GLOSSARIES",
|
||||
"MANAGE_USER_CREDENTIALS"
|
||||
],
|
||||
"displayName":"Root User - All Platform Privileges",
|
||||
"description":"Grants full platform privileges to root datahub super user.",
|
||||
|
||||
@ -15,6 +15,8 @@ public class Constants {
|
||||
|
||||
public static final String DEFAULT_RUN_ID = "no-run-id-provided";
|
||||
|
||||
public static final String GLOBAL_INVITE_TOKEN = "urn:li:inviteToken:global";
|
||||
|
||||
/**
|
||||
* Entities
|
||||
*/
|
||||
@ -45,6 +47,8 @@ public class Constants {
|
||||
public static final String DATA_PLATFORM_INSTANCE_ENTITY_NAME = "dataPlatformInstance";
|
||||
public static final String ACCESS_TOKEN_ENTITY_NAME = "dataHubAccessToken";
|
||||
public static final String DATA_HUB_UPGRADE_ENTITY_NAME = "dataHubUpgrade";
|
||||
public static final String INVITE_TOKEN_ENTITY_NAME = "inviteToken";
|
||||
|
||||
|
||||
/**
|
||||
* Aspects
|
||||
@ -68,6 +72,7 @@ public class Constants {
|
||||
public static final String CORP_USER_EDITABLE_INFO_ASPECT_NAME = "corpUserEditableInfo";
|
||||
public static final String CORP_USER_INFO_ASPECT_NAME = "corpUserInfo";
|
||||
public static final String CORP_USER_STATUS_ASPECT_NAME = "corpUserStatus";
|
||||
public static final String CORP_USER_CREDENTIALS_ASPECT_NAME = "corpUserCredentials";
|
||||
|
||||
// Group
|
||||
public static final String CORP_GROUP_KEY_ASPECT_NAME = "corpGroupKey";
|
||||
@ -217,6 +222,10 @@ public class Constants {
|
||||
public static final String DATA_HUB_UPGRADE_RESULT_ASPECT_NAME = "dataHubUpgradeResult";
|
||||
|
||||
|
||||
// Invite Token
|
||||
public static final String INVITE_TOKEN_ASPECT_NAME = "inviteToken";
|
||||
|
||||
// acryl-main only
|
||||
public static final String CHANGE_EVENT_PLATFORM_EVENT_NAME = "entityChangeEvent";
|
||||
|
||||
/**
|
||||
|
||||
@ -75,6 +75,10 @@ public class PoliciesConfig {
|
||||
"Manage Glossaries",
|
||||
"Create, edit, and remove Glossary Entities");
|
||||
|
||||
public static final Privilege MANAGE_USER_CREDENTIALS_PRIVILEGE =
|
||||
Privilege.of("MANAGE_USER_CREDENTIALS", "Manage User Credentials",
|
||||
"Manage credentials for native DataHub users, including inviting new users and resetting passwords");
|
||||
|
||||
public static final List<Privilege> PLATFORM_PRIVILEGES = ImmutableList.of(
|
||||
MANAGE_POLICIES_PRIVILEGE,
|
||||
MANAGE_USERS_AND_GROUPS_PRIVILEGE,
|
||||
@ -85,7 +89,8 @@ public class PoliciesConfig {
|
||||
GENERATE_PERSONAL_ACCESS_TOKENS_PRIVILEGE,
|
||||
MANAGE_ACCESS_TOKENS,
|
||||
MANAGE_TESTS_PRIVILEGE,
|
||||
MANAGE_GLOSSARIES_PRIVILEGE
|
||||
MANAGE_GLOSSARIES_PRIVILEGE,
|
||||
MANAGE_USER_CREDENTIALS_PRIVILEGE
|
||||
);
|
||||
|
||||
// Resource Privileges //
|
||||
|
||||
@ -865,7 +865,8 @@ def test_frontend_me_query(frontend_session):
|
||||
viewAnalytics
|
||||
managePolicies
|
||||
manageIdentities
|
||||
generatePersonalAccessTokens
|
||||
manageUserCredentials
|
||||
generatePersonalAccessTokens
|
||||
}\n
|
||||
}\n
|
||||
}"""
|
||||
@ -880,6 +881,7 @@ def test_frontend_me_query(frontend_session):
|
||||
assert res_data["data"]["me"]["corpUser"]["urn"] == "urn:li:corpuser:datahub"
|
||||
assert res_data["data"]["me"]["platformPrivileges"]["viewAnalytics"] is True
|
||||
assert res_data["data"]["me"]["platformPrivileges"]["managePolicies"] is True
|
||||
assert res_data["data"]["me"]["platformPrivileges"]["manageUserCredentials"] is True
|
||||
assert res_data["data"]["me"]["platformPrivileges"]["manageIdentities"] is True
|
||||
assert (
|
||||
res_data["data"]["me"]["platformPrivileges"]["generatePersonalAccessTokens"]
|
||||
@ -1445,3 +1447,159 @@ def test_generate_personal_access_token(frontend_session):
|
||||
|
||||
assert res_data
|
||||
assert "errors" in res_data # Assert the request fails
|
||||
|
||||
@pytest.mark.dependency(depends=["test_healthchecks", "test_run_ingestion"])
|
||||
def test_native_user_endpoints(frontend_session):
|
||||
# Sign up tests
|
||||
|
||||
# Test getting the invite token
|
||||
get_invite_token_json = {
|
||||
"query": """query getNativeUserInviteToken {\n
|
||||
getNativeUserInviteToken{\n
|
||||
inviteToken\n
|
||||
}\n
|
||||
}"""
|
||||
}
|
||||
|
||||
get_invite_token_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=get_invite_token_json)
|
||||
get_invite_token_response.raise_for_status()
|
||||
get_invite_token_res_data = get_invite_token_response.json()
|
||||
|
||||
assert get_invite_token_res_data
|
||||
assert get_invite_token_res_data["data"]
|
||||
invite_token = get_invite_token_res_data["data"]["getNativeUserInviteToken"]["inviteToken"]
|
||||
assert invite_token is not None
|
||||
assert "error" not in get_invite_token_res_data
|
||||
|
||||
|
||||
# Pass the invite token when creating the user
|
||||
sign_up_json = {
|
||||
"fullName": "Test User",
|
||||
"email": "test@email.com",
|
||||
"password": "password",
|
||||
"title": "Date Engineer",
|
||||
"inviteToken": invite_token
|
||||
}
|
||||
|
||||
sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=sign_up_json)
|
||||
assert sign_up_response
|
||||
assert "error" not in sign_up_response
|
||||
|
||||
# Creating the same user again fails
|
||||
same_user_sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=sign_up_json)
|
||||
assert not same_user_sign_up_response
|
||||
|
||||
# Test that a bad invite token leads to failed sign up
|
||||
bad_sign_up_json = {
|
||||
"fullName": "Test2 User",
|
||||
"email": "test2@email.com",
|
||||
"password": "password",
|
||||
"title": "Date Engineer",
|
||||
"inviteToken": "invite_token"
|
||||
}
|
||||
bad_sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=bad_sign_up_json)
|
||||
assert not bad_sign_up_response
|
||||
|
||||
frontend_session.cookies.clear()
|
||||
|
||||
|
||||
# Reset credentials tests
|
||||
|
||||
# Log in as root again
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
root_login_data = '{"username":"datahub", "password":"datahub"}'
|
||||
frontend_session.post(f"{FRONTEND_ENDPOINT}/logIn", headers=headers, data=root_login_data)
|
||||
|
||||
# Test creating the password reset token
|
||||
create_reset_token_json = {
|
||||
"query": """mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {\n
|
||||
createNativeUserResetToken(input: $input) {\n
|
||||
resetToken\n
|
||||
}\n
|
||||
}""",
|
||||
"variables": {
|
||||
"input": {
|
||||
"userUrn": "urn:li:corpuser:test@email.com"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
create_reset_token_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=create_reset_token_json)
|
||||
create_reset_token_response.raise_for_status()
|
||||
create_reset_token_res_data = create_reset_token_response.json()
|
||||
|
||||
assert create_reset_token_res_data
|
||||
assert create_reset_token_res_data["data"]
|
||||
reset_token = create_reset_token_res_data["data"]["createNativeUserResetToken"]["resetToken"]
|
||||
assert reset_token is not None
|
||||
assert "error" not in create_reset_token_res_data
|
||||
|
||||
# Pass the reset token when resetting credentials
|
||||
reset_credentials_json = {
|
||||
"email": "test@email.com",
|
||||
"password": "password",
|
||||
"resetToken": reset_token
|
||||
}
|
||||
|
||||
reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=reset_credentials_json)
|
||||
assert reset_credentials_response
|
||||
assert "error" not in reset_credentials_response
|
||||
|
||||
# Test that a bad reset token leads to failed response
|
||||
bad_user_reset_credentials_json = {
|
||||
"email": "test@email.com",
|
||||
"password": "password",
|
||||
"resetToken": "reset_token"
|
||||
}
|
||||
bad_reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=bad_user_reset_credentials_json)
|
||||
assert not bad_reset_credentials_response
|
||||
|
||||
# Test that only a native user can reset their password
|
||||
jaas_user_reset_credentials_json = {
|
||||
"email": "datahub",
|
||||
"password": "password",
|
||||
"resetToken": reset_token
|
||||
}
|
||||
jaas_user_reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=jaas_user_reset_credentials_json)
|
||||
assert not jaas_user_reset_credentials_response
|
||||
|
||||
|
||||
# Tests that unauthenticated users can't invite users or send reset password links
|
||||
|
||||
native_user_frontend_session = requests.Session()
|
||||
|
||||
native_user_login_data = '{"username":"test@email.com", "password":"password"}'
|
||||
native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/logIn", headers=headers, data=native_user_login_data)
|
||||
|
||||
unauthenticated_get_invite_token_response = native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=get_invite_token_json)
|
||||
unauthenticated_get_invite_token_response.raise_for_status()
|
||||
unauthenticated_get_invite_token_res_data = unauthenticated_get_invite_token_response.json()
|
||||
|
||||
assert unauthenticated_get_invite_token_res_data
|
||||
assert "errors" in unauthenticated_get_invite_token_res_data
|
||||
assert unauthenticated_get_invite_token_res_data["data"]
|
||||
assert unauthenticated_get_invite_token_res_data["data"]["getNativeUserInviteToken"] is None
|
||||
|
||||
unauthenticated_create_reset_token_json = {
|
||||
"query": """mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {\n
|
||||
createNativeUserResetToken(input: $input) {\n
|
||||
resetToken\n
|
||||
}\n
|
||||
}""",
|
||||
"variables": {
|
||||
"input": {
|
||||
"userUrn": "urn:li:corpuser:test@email.com"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
unauthenticated_create_reset_token_response = native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=unauthenticated_create_reset_token_json)
|
||||
unauthenticated_create_reset_token_response.raise_for_status()
|
||||
unauthenticated_create_reset_token_res_data = unauthenticated_create_reset_token_response.json()
|
||||
|
||||
assert unauthenticated_create_reset_token_res_data
|
||||
assert "errors" in unauthenticated_create_reset_token_res_data
|
||||
assert unauthenticated_create_reset_token_res_data["data"]
|
||||
assert unauthenticated_create_reset_token_res_data["data"]["createNativeUserResetToken"] is None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user