diff --git a/changelog.md b/changelog.md index 375a3af0..365536a0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v1.10.0 + +### Oct 27, 2025 + +- Feature : TOTP support + ## v1.9.0 ### Oct 13, 2025 @@ -8,7 +14,7 @@ ## v1.8.0 -### Sep 15, 2025 +### Sep 29, 2025 - Feature : OAuth 2.0 support with PKCE flow - Improved code organization and removed redundant methods diff --git a/pom.xml b/pom.xml index 0b0ceaee..11e2b793 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.9.0 + 1.10.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach @@ -214,6 +214,11 @@ kotlin-stdlib 2.1.21 + + com.warrenstrange + googleauth + 1.5.0 + diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index dbf3dbc0..1f01dbcb 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -27,6 +27,7 @@ import com.contentstack.cms.stack.Stack; import com.contentstack.cms.user.User; import com.google.gson.Gson; +import com.warrenstrange.googleauth.GoogleAuthenticator; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; @@ -40,9 +41,8 @@ * Contentstack Java Management SDK *
* Java Management SDK Interact with the Content Management APIs and - * allow you to create, update, - * delete, and fetch content from your Contentstack account. (They are - * read-write in nature.) + * allow you to create, update, delete, and fetch content from your Contentstack + * account. (They are read-write in nature.) *
* You can use them to build your own apps and manage your content from * Contentstack. @@ -66,8 +66,7 @@ public class Contentstack { /** * All accounts registered with Contentstack are known as Users. A stack can - * have many users with varying - * permissions and roles + * have many users with varying permissions and roles *

* To perform User operations first get User instance like below. *

@@ -89,7 +88,8 @@ public class Contentstack { *
* * @return User - * @author ***REMOVED*** + * @author ***REMOVED + *** * @see User * @@ -105,15 +105,14 @@ public User user() { /** * [Note]: Before executing any calls, retrieve the authtoken by - * authenticating yourself via the Log in call of User Session. The authtoken is - * returned to the 'Response' body of - * the Log in call and is mandatory in all the calls. + * authenticating yourself via the Log in call of User Session. The + * authtoken is returned to the 'Response' body of the Log in call and is + * mandatory in all the calls. *

* Example: *

* All accounts registered with Contentstack are known as Users. A stack can - * have many users with varying - * permissions and roles + * have many users with varying permissions and roles *

* To perform User operations first get User instance like below. *

@@ -136,18 +135,20 @@ public User user() { * *
* - * @param emailId the email id of the user + * @param emailId the email id of the user * @param password the password of the user * @return LoginDetails * @throws IOException the IOException - * @author ***REMOVED*** + * @author ***REMOVED + *** * @see User * */ public Response login(String emailId, String password) throws IOException { - if (this.authtoken != null) + if (this.authtoken != null) { throw new IllegalStateException(Util.USER_ALREADY_LOGGED_IN); + } user = new User(this.instance); Response response = user.login(emailId, password).execute(); setupLoginCredentials(response); @@ -155,61 +156,58 @@ public Response login(String emailId, String password) throws IOEx } /** - * [Note]: Before executing any calls, retrieve the authtoken by - * authenticating yourself via the Log in call of User Session. The authtoken is - * returned to the 'Response' body of - * the Log in call and is mandatory in all the calls. - *

- * Example: - *

- * All accounts registered with Contentstack are known as Users. A stack can - * have many users with varying - * permissions and roles - *

- * To perform User operations first get User instance like below. - *

- * Example: - * + * 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 emailId The email ID of the user + * @param password The user's password + * @param params Map containing either tfaToken or mfaSecret + * @return Response containing login details + * @throws IOException if there's a network error + * @throws IllegalArgumentException if validation fails or if both tfaToken and mfaSecret are provided + * @throws IllegalStateException if user is already logged in + * + * Example: *

-     * Contentstack contentstack = new Contentstack.Builder().setAuthtoken("authtoken").build();
-     * Response login = contentstack.login();
-     *
-     * Access more other user functions from the userInstance
+     * // Login with direct token
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("tfaToken", "123456");}
+     * {@code Response response = contentstack.login(email, password, params);}
+     * 
+     * // OR login with MFA secret
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("mfaSecret", "YOUR_SECRET");}
+     * {@code Response response = contentstack.login(email, password, params);}
      * 
- * - *
- * OR: - * - *
-     * Contentstack contentstack = new Contentstack.Builder().build();
-     * Response login = contentstack.login("emailId", "password");
-     * 
- * - *
- * - * @param emailId the email id - * @param password the password - * @param tfaToken the tfa token - * @return LoginDetails - * @throws IOException the io exception - * @throws IOException the IOException - * @author ***REMOVED*** - * @see Login - * your account - * */ - public Response login(String emailId, String password, String tfaToken) throws IOException { - if (this.authtoken != null) + public Response login(String emailId, String password, Map params) throws IOException { + // Check if already logged in + if (this.authtoken != null) { throw new IllegalStateException(Util.USER_ALREADY_LOGGED_IN); + } + + // Validate basic inputs + if (emailId.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"); + } + + // Perform login user = new User(this.instance); - Response response = user.login(emailId, password, tfaToken).execute(); + Response response = user.login(emailId, password, params).execute(); setupLoginCredentials(response); - user = new User(this.instance); return response; } + private void setupLoginCredentials(Response loginResponse) throws IOException { if (loginResponse.isSuccessful()) { LoginDetails loginDetails = loginResponse.body(); @@ -230,8 +228,8 @@ private void setupLoginCredentials(Response loginResponse) throws } /** - * The Log out of your account call is used to sign out the user of Contentstack - * account + * The Log out of your account call is used to sign out the user of + * Contentstack account *

* Example * @@ -250,8 +248,8 @@ Response logout() throws IOException { } /** - * The Log out of your account using authtoken is used to sign out the user of - * Contentstack account + * The Log out of your account using authtoken is used to sign out the user + * of Contentstack account *

* Example * @@ -276,9 +274,8 @@ Response logoutWithAuthtoken(String authtoken) throws IOException /** * Organization is the top-level entity in the hierarchy of Contentstack, - * consisting of stacks and stack resources, - * and users. Organization allows easy management of projects as well as users - * within the Organization. + * consisting of stacks and stack resources, and users. Organization allows + * easy management of projects as well as users within the Organization. *

* Example * @@ -293,7 +290,7 @@ public Organization organization() { if (!isOAuthConfigured() && this.authtoken == null) { throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " organization"); } - + // If using OAuth, get organization from tokens if (isOAuthConfigured() && oauthHandler.getTokens() != null) { String orgUid = oauthHandler.getTokens().getOrganizationUid(); @@ -301,19 +298,19 @@ public Organization organization() { return organization(orgUid); } } - + return new Organization(this.instance); } /** * Organization is the top-level entity in the hierarchy of Contentstack, - * consisting of stacks and stack resources, - * and users. Organization allows easy management of projects as well as users - * within the Organization. + * consisting of stacks and stack resources, and users. Organization allows + * easy management of projects as well as users within the Organization. *

* Example * - * @param organizationUid The UID of the organization that you want to retrieve + * @param organizationUid The UID of the organization that you want to + * retrieve * @return the organization *
* Example @@ -322,7 +319,7 @@ public Organization organization() { * Contentstack contentstack = new Contentstack.Builder().build(); *
* Organization org = contentstack.organization(); - * + * */ public Organization organization(@NotNull String organizationUid) { if (!isOAuthConfigured() && this.authtoken == null) { @@ -337,10 +334,9 @@ public Organization organization(@NotNull String organizationUid) { /** * stack - * A stack is - * a space that stores the content of a project (a web or mobile property). - * Within a stack, you can create content - * structures, content entries, users, etc. related to the project + * A stack is a space that stores the content of a project (a web or mobile + * property). Within a stack, you can create content structures, content + * entries, users, etc. related to the project *

* Example * @@ -361,10 +357,9 @@ public Stack stack() { /** * stack - * A stack is - * a space that stores the content of a project (a web or mobile property). - * Within a stack, you can create content - * structures, content entries, users, etc. related to the project + * A stack is a space that stores the content of a project (a web or mobile + * property). Within a stack, you can create content structures, content + * entries, users, etc. related to the project *

* Example * @@ -386,10 +381,9 @@ public Stack stack(@NotNull Map header) { /** * stack - * A stack is - * a space that stores the content of a project (a web or mobile property). - * Within a stack, you can create content - * structures, content entries, users, etc. related to the project + * A stack is a space that stores the content of a project (a web or mobile + * property). Within a stack, you can create content structures, content + * entries, users, etc. related to the project *

* Example * @@ -399,7 +393,7 @@ public Stack stack(@NotNull Map header) { * * * @param managementToken the authorization for the stack - * @param apiKey the apiKey for the stack + * @param apiKey the apiKey for the stack * @return the stack instance */ public Stack stack(@NotNull String apiKey, @NotNull String managementToken) { @@ -412,10 +406,9 @@ public Stack stack(@NotNull String apiKey, @NotNull String managementToken) { /** * stack - * A stack is - * a space that stores the content of a project (a web or mobile property). - * Within a stack, you can create content - * structures, content entries, users, etc. related to the project + * A stack is a space that stores the content of a project (a web or mobile + * property). Within a stack, you can create content structures, content + * entries, users, etc. related to the project *

* Example * @@ -442,10 +435,9 @@ public Stack stack(@NotNull String key) { /** * stack - * A stack is - * a space that stores the content of a project (a web or mobile property). - * Within a stack, you can create content - * structures, content entries, users, etc. related to the project + * A stack is a space that stores the content of a project (a web or mobile + * property). Within a stack, you can create content structures, content + * entries, users, etc. related to the project *

* Example * @@ -455,8 +447,8 @@ public Stack stack(@NotNull String key) { * * * @param managementToken the authorization for the stack - * @param apiKey the apiKey for the stack - * @param branch the branch that include branching in the response + * @param apiKey the apiKey for the stack + * @param branch the branch that include branching in the response * @return the stack instance */ public Stack stack(@NotNull String apiKey, @NotNull String managementToken, @NotNull String branch) { @@ -469,6 +461,7 @@ public Stack stack(@NotNull String apiKey, @NotNull String managementToken, @Not /** * Get the OAuth authorization URL for the user to visit + * * @return Authorization URL string * @throws IllegalStateException if OAuth is not configured */ @@ -481,6 +474,7 @@ public String getOAuthAuthorizationUrl() { /** * Exchange OAuth authorization code for tokens + * * @param code Authorization code from OAuth callback * @return CompletableFuture containing OAuth tokens * @throws IllegalStateException if OAuth is not configured @@ -494,8 +488,10 @@ public CompletableFuture exchangeOAuthCode(String code) { /** * Refresh the OAuth access token + * * @return CompletableFuture containing new OAuth tokens - * @throws IllegalStateException if OAuth is not configured or no refresh token available + * @throws IllegalStateException if OAuth is not configured or no refresh + * token available */ public CompletableFuture refreshOAuthToken() { if (!isOAuthConfigured()) { @@ -506,6 +502,7 @@ public CompletableFuture refreshOAuthToken() { /** * Get the current OAuth tokens + * * @return Current OAuth tokens or null if not available */ public OAuthTokens getOAuthTokens() { @@ -514,6 +511,7 @@ public OAuthTokens getOAuthTokens() { /** * Check if we have valid OAuth tokens + * * @return true if we have valid tokens */ public boolean hasValidOAuthTokens() { @@ -522,6 +520,7 @@ public boolean hasValidOAuthTokens() { /** * Check if OAuth is configured + * * @return true if OAuth is configured */ public boolean isOAuthConfigured() { @@ -530,6 +529,7 @@ public boolean isOAuthConfigured() { /** * Get the OAuth handler instance + * * @return OAuth handler or null if not configured */ public OAuthHandler getOAuthHandler() { @@ -538,6 +538,7 @@ public OAuthHandler getOAuthHandler() { /** * Logout from OAuth session and optionally revoke authorization + * * @param revokeAuthorization If true, revokes the OAuth authorization * @return CompletableFuture that completes when logout is done */ @@ -550,6 +551,7 @@ public CompletableFuture oauthLogout(boolean revokeAuthorization) { /** * Logout from OAuth session without revoking authorization + * * @return CompletableFuture that completes when logout is done */ public CompletableFuture oauthLogout() { @@ -595,8 +597,8 @@ public static class Builder { private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack /** - * Default ConnectionPool holds up to 5 idle connections which - * will be evicted after 5 minutes of inactivity. + * Default ConnectionPool holds up to 5 idle connections which will be + * evicted after 5 minutes of inactivity. */ private ConnectionPool connectionPool = new ConnectionPool(); // Connection @@ -609,8 +611,7 @@ public Builder() { /** * Sets proxy. (Setting proxy to the OkHttpClient) Proxy = new - * Proxy(Proxy.Type.HTTP, new - * InetSocketAddress(proxyHost, proxyPort)); + * Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); *
*

* {@code @@ -687,26 +688,20 @@ public Builder setTimeout(int timeout) { /** * Create a new connection pool with tuning parameters appropriate for a - * single-user application. - * The tuning parameters in this pool are subject to change in future OkHttp - * releases. Currently, - * this pool holds up to 5 idle connections which will be evicted after 5 - * minutes of inactivity. + * single-user application. The tuning parameters in this pool are + * subject to change in future OkHttp releases. Currently, this pool + * holds up to 5 idle connections which will be evicted after 5 minutes + * of inactivity *

- *

- * public ConnectionPool() { - * this(5, 5, TimeUnit.MINUTES); - * } + * public ConnectionPool() { this(5, 5, TimeUnit.MINUTES); } * * @param maxIdleConnections Maximum number of idle connections - * @param keepAliveDuration The Keep Alive Duration - * @param timeUnit A TimeUnit represents time durations at a given - * unit of granularity and provides utility methods to - * convert across units + * @param keepAliveDuration The Keep Alive Duration + * @param timeUnit A TimeUnit represents time durations at a given unit + * of granularity and provides utility methods to convert across units * @return instance of Builder *

- * Example: - * {@code + * Example: {@code * Contentstack cs = new Contentstack.Builder() * .setAuthtoken(AUTHTOKEN) * .setConnectionPool(5, 400, TimeUnit.MILLISECONDS) @@ -732,6 +727,7 @@ public Builder setAuthtoken(String authtoken) { /** * Sets OAuth configuration for the client + * * @param config OAuth configuration * @return Builder instance */ @@ -744,6 +740,7 @@ public Builder setOAuthConfig(OAuthConfig config) { /** * Sets the token callback for OAuth storage + * * @param callback The callback to handle token storage * @return Builder instance */ @@ -751,7 +748,7 @@ public Builder setTokenCallback(TokenCallback callback) { this.tokenCallback = callback; return this; } - + /** * Configures OAuth authentication with PKCE flow (no client secret) * @param appId Application ID @@ -787,10 +784,10 @@ public Builder setOAuth(String appId, String clientId, String redirectUri, Strin */ public Builder setOAuth(String appId, String clientId, String redirectUri, String host, String clientSecret) { OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() - .appId(appId) - .clientId(clientId) - .redirectUri(redirectUri) - .host(host); + .appId(appId) + .clientId(clientId) + .redirectUri(redirectUri) + .host(host); // Only set clientSecret if provided (otherwise PKCE flow will be used) if (clientSecret != null && !clientSecret.trim().isEmpty()) { @@ -828,7 +825,7 @@ private void validateClient(Contentstack contentstack) { .addConverterFactory(GsonConverterFactory.create()) .client(httpClient(contentstack, this.retry)).build(); contentstack.instance = this.instance; - + // Initialize OAuth if configured if (this.oauthConfig != null) { // OAuth handler and interceptor are created in httpClient @@ -851,12 +848,12 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur OkHttpClient tempClient = builder.build(); this.oauthHandler = new OAuthHandler(tempClient, this.oauthConfig); this.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); - + // Configure early access if needed if (this.earlyAccess != null) { this.oauthInterceptor.setEarlyAccess(this.earlyAccess); } - + // Add interceptor to handle OAuth, token refresh, and retries builder.addInterceptor(this.oauthInterceptor); } else { diff --git a/src/main/java/com/contentstack/cms/user/User.java b/src/main/java/com/contentstack/cms/user/User.java index 53b05b8f..fb549976 100644 --- a/src/main/java/com/contentstack/cms/user/User.java +++ b/src/main/java/com/contentstack/cms/user/User.java @@ -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 Users. - * A Stack - * 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 { protected final UserService userService; - protected HashMap headers; - protected HashMap params; + protected final HashMap headers; + protected final HashMap params; /** * @param client Retrofit adapts a Java interface to HTTP calls by using @@ -66,8 +64,13 @@ public User(Retrofit client) { * @return Call */ public Call login(@NotNull String email, @NotNull String password) { + HashMap credentials = new HashMap<>(); + credentials.put("email", email); + credentials.put("password", password); + HashMap> userSession = new HashMap<>(); - userSession.put("user", setCredentials(email, password)); + userSession.put("user", credentials); + JSONObject userDetail = new JSONObject(userSession); return this.userService.login(loginHeader(), userDetail); } @@ -79,28 +82,96 @@ private HashMap 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: + *

+     * // Login with direct token
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("tfaToken", "123456");}
+     * {@code Call call = user.login(email, password, params);}
+     * 
+     * // OR login with MFA secret
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("mfaSecret", "YOUR_SECRET");}
+     * {@code Call call = user.login(email, password, params);}
+     * 
*/ - public Call login(@NotNull String email, @NotNull String password, @NotNull String tfaToken) { + public Call login(@NotNull String email, @NotNull String password, @NotNull Map 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 credentials = new HashMap<>(); + credentials.put("email", email); + credentials.put("password", password); + credentials.put("tfa_token", finalTfaToken); + HashMap> 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 setCredentials(@NotNull String... arguments) { - HashMap 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; } /** @@ -128,8 +199,7 @@ public Call getUser() { * @return Call */ public Call update(JSONObject body) { - HashMap headers = new HashMap<>(); - return userService.update(headers, body); + return userService.update(new HashMap<>(), body); } /** diff --git a/src/test/java/com/contentstack/cms/ContentstackAPITest.java b/src/test/java/com/contentstack/cms/ContentstackAPITest.java index 6f1ca163..61ffba90 100644 --- a/src/test/java/com/contentstack/cms/ContentstackAPITest.java +++ b/src/test/java/com/contentstack/cms/ContentstackAPITest.java @@ -9,6 +9,8 @@ import retrofit2.Response; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; /* @author ***REMOVED***@gmail.com @@ -69,8 +71,9 @@ void testContentstackUserLoginWhenAlreadyLoggedIn() throws IOException { Contentstack contentstack = new Contentstack.Builder() .setAuthtoken(null) .build(); - Response response = contentstack.login("invalid@credentials.com", "invalid@password", - "invalid_tfa_token"); + Map params = new HashMap<>(); + params.put("tfaToken", "invalid_tfa_token"); + Response response = contentstack.login("invalid@credentials.com", "invalid@password", params); Assertions.assertEquals(422, response.code()); } diff --git a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java index 6386cf22..8c9e57dc 100644 --- a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java +++ b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java @@ -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 { @@ -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 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()); } diff --git a/src/test/java/com/contentstack/cms/user/UserUnitTests.java b/src/test/java/com/contentstack/cms/user/UserUnitTests.java index 685c4bd9..150eea96 100644 --- a/src/test/java/com/contentstack/cms/user/UserUnitTests.java +++ b/src/test/java/com/contentstack/cms/user/UserUnitTests.java @@ -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) @@ -587,8 +588,7 @@ void testUserBaseParameters() { HashMap 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") @@ -596,4 +596,135 @@ void testUserBaseParameters() { 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 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 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 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 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 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 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 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 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")); + } }