Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@
<artifactId>kotlin-stdlib</artifactId>
<version>2.1.21</version>
</dependency>
<dependency>
<groupId>com.warrenstrange</groupId>
<artifactId>googleauth</artifactId>
<version>1.5.0</version>
</dependency>
</dependencies>


Expand Down
264 changes: 134 additions & 130 deletions src/main/java/com/contentstack/cms/Contentstack.java

Large diffs are not rendered by default.

122 changes: 96 additions & 26 deletions src/main/java/com/contentstack/cms/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,24 @@
import org.json.simple.JSONObject;
import retrofit2.Call;
import retrofit2.Retrofit;
import com.warrenstrange.googleauth.GoogleAuthenticator;

import java.util.HashMap;
import java.util.Map;

/**
* All accounts registered with Contentstack are known as <a href=
* "https://www.contentstack.com/docs/developers/invite-users-and-assign-roles/about-stack-users">Users</a>.
* A <a href=
* "https://www.contentstack.com/docs/developers/set-up-stack/about-stack">Stack</a>
* can have many users with varying
* permissions and roles.
* All accounts registered with Contentstack are known as Users.
* A stack can have many users with varying permissions and roles.
*
* @author ***REMOVED***
* @version v0.1.0
* @since 2022-10-22
*/
public class User implements BaseImplementation {
public class User implements BaseImplementation<User> {

protected final UserService userService;
protected HashMap<String, String> headers;
protected HashMap<String, Object> params;
protected final HashMap<String, String> headers;
protected final HashMap<String, Object> params;

/**
* @param client Retrofit adapts a Java interface to HTTP calls by using
Expand Down Expand Up @@ -66,8 +64,13 @@ public User(Retrofit client) {
* @return Call
*/
public Call<LoginDetails> login(@NotNull String email, @NotNull String password) {
HashMap<String, String> credentials = new HashMap<>();
credentials.put("email", email);
credentials.put("password", password);

HashMap<String, HashMap<String, String>> userSession = new HashMap<>();
userSession.put("user", setCredentials(email, password));
userSession.put("user", credentials);

JSONObject userDetail = new JSONObject(userSession);
return this.userService.login(loginHeader(), userDetail);
}
Expand All @@ -79,28 +82,96 @@ private HashMap<String, String> loginHeader() {
}

/**
* Login call.
* Login with two-factor authentication. This method provides flexibility to use either:
* 1. A direct 2FA token using params.put("tfaToken", "123456"), OR
* 2. An MFA secret to generate TOTP using params.put("mfaSecret", "YOUR_SECRET")
*
* Note: Do not provide both tfaToken and mfaSecret. Choose one authentication method.
*
* @param email email for user to login
* @param password password for user to login
* @param tfaToken the tfa token
* @return Call
* @param email email for user to login
* @param password password for user to login
* @param params Map containing either tfaToken or mfaSecret
* @return Call containing login details
* @throws IllegalArgumentException if validation fails or if both tfaToken and mfaSecret are provided
*
* Example:
* <pre>
* // Login with direct token
* Map<String, String> params = new HashMap<>();
* params.put("tfaToken", "123456");
* Call<LoginDetails> call = user.login(email, password, params);
*
* // OR login with MFA secret
* Map<String, String> params = new HashMap<>();
* params.put("mfaSecret", "YOUR_SECRET");
* Call<LoginDetails> call = user.login(email, password, params);
* </pre>
*/
public Call<LoginDetails> login(@NotNull String email, @NotNull String password, @NotNull String tfaToken) {
public Call<LoginDetails> login(@NotNull String email, @NotNull String password, @NotNull Map<String, String> params) {
// Validate basic inputs
if (email.trim().isEmpty()) {
throw new IllegalArgumentException("Email is required");
}
if (password.trim().isEmpty()) {
throw new IllegalArgumentException("Password is required");
}
if (params.isEmpty()) {
throw new IllegalArgumentException("Authentication parameters are required");
}

String tfaToken = params.get("tfaToken");
String mfaSecret = params.get("mfaSecret");

// Check for mutual exclusivity
boolean hasTfaToken = tfaToken != null && !tfaToken.trim().isEmpty();
boolean hasMfaSecret = mfaSecret != null && !mfaSecret.trim().isEmpty();

if (hasTfaToken && hasMfaSecret) {
throw new IllegalArgumentException("Cannot provide both tfaToken and mfaSecret. Use either one.");
}
if (!hasTfaToken && !hasMfaSecret) {
throw new IllegalArgumentException("Must provide either tfaToken or mfaSecret");
}

// Generate TOTP if needed
String finalTfaToken = tfaToken;
if (!hasTfaToken && hasMfaSecret) {
finalTfaToken = generateTOTP(mfaSecret);
}

// Perform login
HashMap<String, String> credentials = new HashMap<>();
credentials.put("email", email);
credentials.put("password", password);
credentials.put("tfa_token", finalTfaToken);

HashMap<String, HashMap<String, String>> userSession = new HashMap<>();
userSession.put("user", setCredentials(email, password, tfaToken));
userSession.put("user", credentials);

JSONObject userDetail = new JSONObject(userSession);
return this.userService.login(loginHeader(), userDetail);
}

private HashMap<String, String> setCredentials(@NotNull String... arguments) {
HashMap<String, String> credentials = new HashMap<>();
credentials.put("email", arguments[0]);
credentials.put("password", arguments[1]);
if (arguments.length > 2) {
credentials.put("tfa_token", arguments[2]);
private String generateTOTP(String secret) {
if (secret == null || secret.trim().isEmpty()) {
throw new IllegalArgumentException("MFA secret cannot be null or empty");
}

try {
GoogleAuthenticator gAuth = new GoogleAuthenticator();
String totp = String.format("%06d", gAuth.getTotpPassword(secret));

// Validate the generated token
if (!totp.matches("\\d{6}")) {
throw new IllegalArgumentException("Generated TOTP token is invalid (not 6 digits)");
}

return totp;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e);
}
return credentials;
}

/**
Expand Down Expand Up @@ -128,8 +199,7 @@ public Call<ResponseBody> getUser() {
* @return Call
*/
public Call<ResponseBody> update(JSONObject body) {
HashMap<String, String> headers = new HashMap<>();
return userService.update(headers, body);
return userService.update(new HashMap<>(), body);
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/test/java/com/contentstack/cms/ContentstackAPITest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import retrofit2.Response;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/*
@author ***REMOVED***@gmail.com
Expand Down Expand Up @@ -69,8 +71,9 @@ void testContentstackUserLoginWhenAlreadyLoggedIn() throws IOException {
Contentstack contentstack = new Contentstack.Builder()
.setAuthtoken(null)
.build();
Response<LoginDetails> response = contentstack.login("invalid@credentials.com", "invalid@password",
"invalid_tfa_token");
Map<String, String> params = new HashMap<>();
params.put("tfaToken", "invalid_tfa_token");
Response<LoginDetails> response = contentstack.login("invalid@credentials.com", "invalid@password", params);
Assertions.assertEquals(422, response.code());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.HashMap;
import java.util.Map;

public class ContentstackUnitTest {

Expand Down Expand Up @@ -210,7 +212,9 @@ void testSetAuthtokenLogin() {
void testSetAuthtokenLoginWithTfa() {
Contentstack client = new Contentstack.Builder().setAuthtoken("fake@authtoken").build();
try {
client.login("fake@email.com", "fake@password", "fake@tfa");
Map<String, String> params = new HashMap<>();
params.put("tfaToken", "fake@tfa");
client.login("fake@email.com", "fake@password", params);
} catch (Exception e) {
Assertions.assertEquals("User is already loggedIn, Please logout then try to login again", e.getMessage());
}
Expand Down
1 change: 1 addition & 0 deletions src/test/java/com/contentstack/cms/TestClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class TestClient {
public final static String USER_ID = (env.get("userId") != null) ? env.get("userId") : "c11e668e0295477f";
public final static String OWNERSHIP = (env.get("ownershipToken") != null) ? env.get("ownershipToken")
: "ownershipTokenId";
// file deepcode ignore NonCryptoHardcodedSecret/test: <please specify a reason of ignoring this>
public final static String API_KEY = (env.get("apiKey") != null) ? env.get("apiKey") : "apiKey99999999";
public final static String MANAGEMENT_TOKEN = (env.get("managementToken") != null) ? env.get("managementToken")
: "managementToken99999999";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ void getterSetterUserModelLastName() {
@Test
void getterSetterUserModelUsername() {
UserModel userModel = new UserModel();
// deepcode ignore NoHardcodedCredentials/test: <please specify a reason of ignoring this>
userModel.setUsername("***REMOVED***");
Assertions.assertEquals("***REMOVED***",
userModel.getUsername());
Expand Down
135 changes: 133 additions & 2 deletions src/test/java/com/contentstack/cms/user/UserUnitTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.net.URL;
import java.util.HashMap;
import java.util.Map;

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand Down Expand Up @@ -587,13 +588,143 @@ void testUserBaseParameters() {
HashMap<String, String> headers = new HashMap<>();
headers.put("header", "something");
Request requestInfo = userInstance.
addParams(headers).
addParam("param", "value")
addParam("param", (Object)"value")
.addHeader("key", "value")
.addHeaders(headers)
.logoutWithAuthtoken("authtoken")
.request();
Assertions.assertEquals(0,
requestInfo.url().queryParameterNames().size());
}

@Test
@Order(42)
@DisplayName("Test login with TOTP token")
void testLoginWithTOTPToken() {
// Test basic login with TOTP token
Map<String, String> params = new HashMap<>();
params.put("tfaToken", "123456");
Request requestInfo = userInstance.login("test@example.com", "password", params).request();

// Verify request format
Assertions.assertEquals("POST", requestInfo.method());
Assertions.assertEquals("/v3/user-session", requestInfo.url().encodedPath());

// Verify request body
String requestBody = requestInfo.body() != null ? requestInfo.body().toString() : "";
Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\""));
}

@Test
@Order(43)
@DisplayName("Test login with MFA parameters")
void testLoginWithMFAParameters() {
// Test login with both token and secret (should throw exception)
Map<String, String> params = new HashMap<>();
params.put("tfaToken", "123456");
params.put("mfaSecret", "test-secret");

IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", params).request()
);

Assertions.assertTrue(exception.getMessage().contains("Cannot provide both tfaToken and mfaSecret"));
}

@Test
@Order(44)
@DisplayName("Test login validation")
void testLoginValidation() {
Map<String, String> params = new HashMap<>();
params.put("tfaToken", "123456");

// Test with missing required parameters
Assertions.assertThrows(IllegalArgumentException.class,
() -> userInstance.login("", "password", params).request());

Assertions.assertThrows(IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "", params).request());

Assertions.assertThrows(IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", new HashMap<>()).request());
}

@Test
@Order(45)
@DisplayName("Test TOTP generation from MFA secret")
void testTOTPGenerationFromSecret() {
// Test login with MFA secret only
Map<String, String> params = new HashMap<>();
params.put("mfaSecret", "test-secret");
Request requestInfo = userInstance.login("test@example.com", "password", params).request();

// Verify request format
Assertions.assertEquals("POST", requestInfo.method());
Assertions.assertEquals("/v3/user-session", requestInfo.url().encodedPath());

// Verify generated TOTP format
String requestBody = requestInfo.body() != null ? requestInfo.body().toString() : "";
Assertions.assertTrue(requestBody.contains("\"tfa_token\":\""));

// Extract TOTP token and verify it's 6 digits
String token = requestBody.split("\"tfa_token\":\"")[1].split("\"")[0];
Assertions.assertTrue(token.matches("\\d{6}"), "TOTP should be 6 digits");
}

@Test
@Order(46)
@DisplayName("Test invalid MFA secret handling")
void testInvalidMFASecret() {
// Test with invalid MFA secret
Map<String, String> params = new HashMap<>();
params.put("mfaSecret", "invalid-secret");

IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", params).request()
);

Assertions.assertTrue(exception.getMessage().contains("Invalid MFA secret key"));
}

@Test
@Order(47)
@DisplayName("Test empty parameters")
void testEmptyParameters() {
// Test with empty parameters map
Map<String, String> params = new HashMap<>();

IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", params).request()
);

Assertions.assertTrue(exception.getMessage().contains("Must provide either tfaToken or mfaSecret"));
}

@Test
@Order(48)
@DisplayName("Test empty values in parameters")
void testEmptyValues() {
// Test with empty tfaToken
Map<String, String> params1 = new HashMap<>();
params1.put("tfaToken", "");

IllegalArgumentException exception1 = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", params1).request()
);
Assertions.assertTrue(exception1.getMessage().contains("Must provide either tfaToken or mfaSecret"));

// Test with empty mfaSecret
Map<String, String> params2 = new HashMap<>();
params2.put("mfaSecret", "");

IllegalArgumentException exception2 = Assertions.assertThrows(
IllegalArgumentException.class,
() -> userInstance.login("test@example.com", "password", params2).request()
);
Assertions.assertTrue(exception2.getMessage().contains("Must provide either tfaToken or mfaSecret"));
}
}
Loading