From 82326428695daf91eb114d42654bb3f2316a94d2 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 15 Sep 2025 07:14:10 +0530 Subject: [PATCH 1/6] feat: Add TOTP/MFA support for login --- pom.xml | 5 + .../com/contentstack/cms/Contentstack.java | 229 +++++++++++------- .../java/com/contentstack/cms/user/User.java | 18 ++ 3 files changed, 160 insertions(+), 92 deletions(-) diff --git a/pom.xml b/pom.xml index f2e0e305..4b788008 100644 --- a/pom.xml +++ b/pom.xml @@ -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 a98c6e73..4041963c 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); @@ -156,15 +157,14 @@ 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. + * 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. *

@@ -187,13 +187,14 @@ public Response login(String emailId, String password) throws IOEx * *
* - * @param emailId the email id + * @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*** + * @author ***REMOVED + *** * @see Login @@ -201,14 +202,52 @@ public Response login(String emailId, String password) throws IOEx * */ public Response login(String emailId, String password, String tfaToken) 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, tfaToken).execute(); setupLoginCredentials(response); user = new User(this.instance); return response; } + /** + * Login with TOTP/MFA support. + * + * @param emailId The email ID of the user + * @param password The user's password + * @param tfaToken The TOTP token (can be null if mfaSecret is provided) + * @param mfaSecret The MFA secret key to generate TOTP (optional if tfaToken is provided) + * @return Response containing login details + * @throws IOException if there's a network error + * @throws IllegalArgumentException if both tfaToken and mfaSecret are null or if secret is invalid + * @throws IllegalStateException if user is already logged in + */ + public Response login(String emailId, String password, String tfaToken, String mfaSecret) throws IOException { + String finalTfaToken = tfaToken; + if (mfaSecret != null && !mfaSecret.isEmpty()) { + finalTfaToken = generateTOTP(mfaSecret); + } + if (this.authtoken != null) { + throw new IllegalStateException(Util.USER_ALREADY_LOGGED_IN); + } + if (finalTfaToken == null) { + throw new IllegalArgumentException("Either tfaToken or mfaSecret must be provided"); + } + user = new User(this.instance); + Response response = user.login(emailId, password, finalTfaToken).execute(); + setupLoginCredentials(response); + return response; + } + + private String generateTOTP(String secret) { + try { + GoogleAuthenticator gAuth = new GoogleAuthenticator(); + return String.format("%06d", gAuth.getTotpPassword(secret)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid MFA secret key", e); + } + } private void setupLoginCredentials(Response loginResponse) throws IOException { if (loginResponse.isSuccessful()) { @@ -230,8 +269,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 +289,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 +315,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 +331,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 +339,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 +360,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 +375,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 +398,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 +422,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 +434,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 +447,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 +476,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 +488,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 +502,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 +515,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 +529,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 +543,7 @@ public CompletableFuture refreshOAuthToken() { /** * Get the current OAuth tokens + * * @return Current OAuth tokens or null if not available */ public OAuthTokens getOAuthTokens() { @@ -514,6 +552,7 @@ public OAuthTokens getOAuthTokens() { /** * Check if we have valid OAuth tokens + * * @return true if we have valid tokens */ public boolean hasValidOAuthTokens() { @@ -522,6 +561,7 @@ public boolean hasValidOAuthTokens() { /** * Check if OAuth is configured + * * @return true if OAuth is configured */ public boolean isOAuthConfigured() { @@ -530,6 +570,7 @@ public boolean isOAuthConfigured() { /** * Get the OAuth handler instance + * * @return OAuth handler or null if not configured */ public OAuthHandler getOAuthHandler() { @@ -538,6 +579,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 +592,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 +638,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 +652,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 +729,21 @@ 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 +769,7 @@ public Builder setAuthtoken(String authtoken) { /** * Sets OAuth configuration for the client + * * @param config OAuth configuration * @return Builder instance */ @@ -742,6 +780,7 @@ public Builder setOAuthConfig(OAuthConfig config) { /** * Configures OAuth with client credentials (traditional flow) + * * @param appId Application ID * @param clientId Client ID * @param clientSecret Client secret @@ -752,6 +791,7 @@ public Builder setOAuthConfig(OAuthConfig config) { /** * Sets the token callback for OAuth storage + * * @param callback The callback to handle token storage * @return Builder instance */ @@ -767,20 +807,22 @@ public Builder setOAuth(String appId, String clientId, String clientSecret, Stri /** * Configures OAuth with client credentials and specific host + * * @param appId Application ID * @param clientId Client ID * @param clientSecret Client secret * @param redirectUri Redirect URI - * @param host API host (e.g. "api.contentstack.io", "eu-api.contentstack.com") + * @param host API host (e.g. "api.contentstack.io", + * "eu-api.contentstack.com") * @return Builder instance */ public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri, String host) { OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() - .appId(appId) - .clientId(clientId) - .clientSecret(clientSecret) - .redirectUri(redirectUri) - .host(host); + .appId(appId) + .clientId(clientId) + .clientSecret(clientSecret) + .redirectUri(redirectUri) + .host(host); // Add token callback if set if (this.tokenCallback != null) { @@ -793,6 +835,7 @@ public Builder setOAuth(String appId, String clientId, String clientSecret, Stri /** * Configures OAuth with PKCE (no client secret) + * * @param appId Application ID * @param clientId Client ID * @param redirectUri Redirect URI @@ -805,18 +848,20 @@ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUr /** * Configures OAuth with PKCE (no client secret) and specific host + * * @param appId Application ID * @param clientId Client ID * @param redirectUri Redirect URI - * @param host API host (e.g. "api.contentstack.io", "eu-api.contentstack.com") + * @param host API host (e.g. "api.contentstack.io", + * "eu-api.contentstack.com") * @return Builder instance */ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri, String host) { OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() - .appId(appId) - .clientId(clientId) - .redirectUri(redirectUri) - .host(host); + .appId(appId) + .clientId(clientId) + .redirectUri(redirectUri) + .host(host); // Add token callback if set if (this.tokenCallback != null) { @@ -849,7 +894,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 @@ -872,12 +917,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..cc360672 100644 --- a/src/main/java/com/contentstack/cms/user/User.java +++ b/src/main/java/com/contentstack/cms/user/User.java @@ -8,6 +8,7 @@ import org.json.simple.JSONObject; import retrofit2.Call; import retrofit2.Retrofit; +import com.warrenstrange.googleauth.GoogleAuthenticator; import java.util.HashMap; @@ -103,6 +104,23 @@ private HashMap setCredentials(@NotNull String... arguments) { return credentials; } + public Call login(@NotNull String email, @NotNull String password, @NotNull String tfaToken, String mfaSecret) { + String finalTfaToken = tfaToken; + if (mfaSecret != null && !mfaSecret.isEmpty()) { + finalTfaToken = generateTOTP(mfaSecret); + } + return login(email, password, finalTfaToken); + } + + private String generateTOTP(String secret) { + try { + GoogleAuthenticator gAuth = new GoogleAuthenticator(); + return String.format("%06d", gAuth.getTotpPassword(secret)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid MFA secret key", e); + } + } + /** * The Get user call returns comprehensive information of an existing user * account. The information returned From 0b97c7c04a328bbbfd7df8f2b56a696ac8e1a7cb Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 15 Sep 2025 07:58:02 +0530 Subject: [PATCH 2/6] refactor: Enhance login functionality with TOTP/MFA support - Simplified login method to handle TOTP and MFA secret. - Added input validation for email, password, and TOTP token. - Updated User class to implement generic BaseImplementation. - Introduced unit tests for login scenarios including TOTP and MFA handling. --- .../com/contentstack/cms/Contentstack.java | 96 +++++---------- .../java/com/contentstack/cms/user/User.java | 66 +++++++++-- .../java/com/contentstack/cms/TestClient.java | 1 + .../cms/models/LoginDetailTest.java | 1 + .../contentstack/cms/user/UserUnitTests.java | 111 ++++++++++++++++++ 5 files changed, 198 insertions(+), 77 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 4041963c..93beae95 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -156,61 +156,19 @@ 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 TOTP token. * - *

-     * Contentstack contentstack = new Contentstack.Builder().setAuthtoken("authtoken").build();
-     * Response login = contentstack.login();
-     *
-     * Access more other user functions from the userInstance
-     * 
- * - *
- * 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 - * + * @param emailId The email ID of the user + * @param password The user's password + * @param tfaToken The TOTP token + * @return Response containing login details + * @throws IOException if there's a network error + * @throws IllegalStateException if user is already logged in */ public Response login(String emailId, String password, String tfaToken) throws IOException { - if (this.authtoken != null) { - throw new IllegalStateException(Util.USER_ALREADY_LOGGED_IN); - } - user = new User(this.instance); - Response response = user.login(emailId, password, tfaToken).execute(); - setupLoginCredentials(response); - user = new User(this.instance); - return response; + return login(emailId, password, tfaToken, null); } + /** * Login with TOTP/MFA support. * @@ -224,30 +182,40 @@ public Response login(String emailId, String password, String tfaT * @throws IllegalStateException if user is already logged in */ public Response login(String emailId, String password, String tfaToken, String mfaSecret) throws IOException { - String finalTfaToken = tfaToken; - if (mfaSecret != null && !mfaSecret.isEmpty()) { - finalTfaToken = generateTOTP(mfaSecret); - } + // Check if already logged in if (this.authtoken != null) { throw new IllegalStateException(Util.USER_ALREADY_LOGGED_IN); } - if (finalTfaToken == null) { + + // Validate inputs + if (emailId == null || emailId.trim().isEmpty()) { + throw new IllegalArgumentException("Email is required"); + } + if (password == null || password.trim().isEmpty()) { + throw new IllegalArgumentException("Password is required"); + } + if ((tfaToken == null || tfaToken.trim().isEmpty()) && (mfaSecret == null || mfaSecret.trim().isEmpty())) { throw new IllegalArgumentException("Either tfaToken or mfaSecret must be provided"); } + + // Generate TOTP if needed + String finalTfaToken = tfaToken; + if ((tfaToken == null || tfaToken.trim().isEmpty()) && mfaSecret != null && !mfaSecret.trim().isEmpty()) { + try { + GoogleAuthenticator gAuth = new GoogleAuthenticator(); + finalTfaToken = String.format("%06d", gAuth.getTotpPassword(mfaSecret)); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e); + } + } + + // Perform login user = new User(this.instance); Response response = user.login(emailId, password, finalTfaToken).execute(); setupLoginCredentials(response); return response; } - private String generateTOTP(String secret) { - try { - GoogleAuthenticator gAuth = new GoogleAuthenticator(); - return String.format("%06d", gAuth.getTotpPassword(secret)); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid MFA secret key", e); - } - } private void setupLoginCredentials(Response loginResponse) throws IOException { if (loginResponse.isSuccessful()) { diff --git a/src/main/java/com/contentstack/cms/user/User.java b/src/main/java/com/contentstack/cms/user/User.java index cc360672..d8839468 100644 --- a/src/main/java/com/contentstack/cms/user/User.java +++ b/src/main/java/com/contentstack/cms/user/User.java @@ -24,7 +24,7 @@ * @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; @@ -80,14 +80,18 @@ private HashMap loginHeader() { } /** - * Login call. + * Login with TOTP token for two-factor authentication. * * @param email email for user to login * @param password password for user to login - * @param tfaToken the tfa token - * @return Call + * @param tfaToken the TOTP token for two-factor authentication + * @return Call containing login details + * @throws IllegalArgumentException if email, password, or tfaToken is null or empty */ - public Call login(@NotNull String email, @NotNull String password, @NotNull String tfaToken) { + public Call login(@NotNull String email, @NotNull String password, String tfaToken) { + if (tfaToken == null || tfaToken.trim().isEmpty()) { + throw new IllegalArgumentException("TOTP token cannot be null or empty"); + } HashMap> userSession = new HashMap<>(); userSession.put("user", setCredentials(email, password, tfaToken)); JSONObject userDetail = new JSONObject(userSession); @@ -95,29 +99,66 @@ public Call login(@NotNull String email, @NotNull String password, } private HashMap setCredentials(@NotNull String... arguments) { + if (arguments == null || arguments.length < 2) { + throw new IllegalArgumentException("Email and password are required"); + } + if (arguments[0] == null || arguments[0].trim().isEmpty()) { + throw new IllegalArgumentException("Email cannot be null or empty"); + } + if (arguments[1] == null || arguments[1].trim().isEmpty()) { + throw new IllegalArgumentException("Password cannot be null or empty"); + } + HashMap credentials = new HashMap<>(); credentials.put("email", arguments[0]); credentials.put("password", arguments[1]); - if (arguments.length > 2) { + + if (arguments.length > 2 && arguments[2] != null && !arguments[2].trim().isEmpty()) { credentials.put("tfa_token", arguments[2]); } return credentials; } - public Call login(@NotNull String email, @NotNull String password, @NotNull String tfaToken, String mfaSecret) { + public Call login(@NotNull String email, @NotNull String password, String tfaToken, String mfaSecret) { + if (email.trim().isEmpty()) { + throw new IllegalArgumentException("Email cannot be empty"); + } + if (password.trim().isEmpty()) { + throw new IllegalArgumentException("Password cannot be empty"); + } + if ((tfaToken == null || tfaToken.trim().isEmpty()) && (mfaSecret == null || mfaSecret.trim().isEmpty())) { + throw new IllegalArgumentException("Either tfaToken or mfaSecret must be provided"); + } + String finalTfaToken = tfaToken; - if (mfaSecret != null && !mfaSecret.isEmpty()) { - finalTfaToken = generateTOTP(mfaSecret); + if ((tfaToken == null || tfaToken.trim().isEmpty()) && mfaSecret != null && !mfaSecret.trim().isEmpty()) { + try { + finalTfaToken = generateTOTP(mfaSecret); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e); + } + } + if (finalTfaToken == null || finalTfaToken.trim().isEmpty()) { + throw new IllegalArgumentException("Failed to generate valid TOTP token"); } return login(email, password, finalTfaToken); } 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(); - return String.format("%06d", gAuth.getTotpPassword(secret)); + String totp = String.format("%06d", gAuth.getTotpPassword(secret)); + if (!totp.matches("\\d{6}")) { + throw new IllegalArgumentException("Generated TOTP token is invalid"); + } + return totp; + } catch (IllegalArgumentException e) { + throw e; } catch (Exception e) { - throw new IllegalArgumentException("Invalid MFA secret key", e); + throw new IllegalArgumentException("Invalid MFA secret key: " + e.getMessage(), e); } } @@ -146,8 +187,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/TestClient.java b/src/test/java/com/contentstack/cms/TestClient.java index 37444610..74b545d9 100644 --- a/src/test/java/com/contentstack/cms/TestClient.java +++ b/src/test/java/com/contentstack/cms/TestClient.java @@ -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: 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"; diff --git a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java index b73ec23f..5e070556 100644 --- a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java +++ b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java @@ -102,6 +102,7 @@ void getterSetterUserModelLastName() { @Test void getterSetterUserModelUsername() { UserModel userModel = new UserModel(); + // deepcode ignore NoHardcodedCredentials/test: userModel.setUsername("***REMOVED***"); Assertions.assertEquals("***REMOVED***", userModel.getUsername()); diff --git a/src/test/java/com/contentstack/cms/user/UserUnitTests.java b/src/test/java/com/contentstack/cms/user/UserUnitTests.java index 685c4bd9..3d5484d2 100644 --- a/src/test/java/com/contentstack/cms/user/UserUnitTests.java +++ b/src/test/java/com/contentstack/cms/user/UserUnitTests.java @@ -596,4 +596,115 @@ 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 + Request requestInfo = userInstance.login("test@example.com", "password", "123456").request(); + + // Verify request format + Assertions.assertEquals("POST", requestInfo.method()); + Assertions.assertEquals("/v3/user-session", requestInfo.url().encodedPath()); + + // Verify request body + String requestBody = 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 (token should be used) + Request requestInfo = userInstance.login("test@example.com", "password", "123456", "test-secret").request(); + + // Verify request format + Assertions.assertEquals("POST", requestInfo.method()); + Assertions.assertEquals("/v3/user-session", requestInfo.url().encodedPath()); + + // Verify request body uses token + String requestBody = requestInfo.body().toString(); + Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\"")); + } + + @Test + @Order(44) + @DisplayName("Test login validation") + void testLoginValidation() { + // Test with missing required parameters + Assertions.assertThrows(IllegalArgumentException.class, + () -> userInstance.login(null, "password", "123456").request()); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> userInstance.login("test@example.com", null, "123456").request()); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> userInstance.login("test@example.com", "password", null).request()); + } + + @Test + @Order(45) + @DisplayName("Test TOTP generation from MFA secret") + void testTOTPGenerationFromSecret() { + // Test login with MFA secret only + Request requestInfo = userInstance.login("test@example.com", "password", null, "test-secret").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().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 + IllegalArgumentException exception = Assertions.assertThrows( + IllegalArgumentException.class, + () -> userInstance.login("test@example.com", "password", null, "invalid-secret").request() + ); + + Assertions.assertTrue(exception.getMessage().contains("Invalid MFA secret key")); + } + + @Test + @Order(47) + @DisplayName("Test TOTP token priority over MFA secret") + void testTOTPPriority() { + // When both token and secret are provided, token should be used + Request requestInfo = userInstance.login("test@example.com", "password", "123456", "test-secret").request(); + String requestBody = requestInfo.body().toString(); + + // Should use provided token, not generate from secret + Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\"")); + } + + @Test + @Order(48) + @DisplayName("Test null/empty MFA parameters") + void testNullEmptyMFAParams() { + // Test with both null + IllegalArgumentException exception1 = Assertions.assertThrows( + IllegalArgumentException.class, + () -> userInstance.login("test@example.com", "password", null, null).request() + ); + Assertions.assertTrue(exception1.getMessage().contains("Either tfaToken or mfaSecret must be provided")); + + // Test with empty secret + IllegalArgumentException exception2 = Assertions.assertThrows( + IllegalArgumentException.class, + () -> userInstance.login("test@example.com", "password", null, "").request() + ); + Assertions.assertTrue(exception2.getMessage().contains("Either tfaToken or mfaSecret must be provided")); + } } From dc24eb197a5b515e77290fe2b0f7e863e71b0a5f Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 17 Sep 2025 15:07:08 +0530 Subject: [PATCH 3/6] refactor: Revise login method to support flexible TOTP/MFA authentication - Updated login method to accept a map of parameters for TOTP and MFA secret. --- .../com/contentstack/cms/Contentstack.java | 63 ++++----- .../java/com/contentstack/cms/user/User.java | 132 ++++++++++-------- .../contentstack/cms/ContentstackAPITest.java | 7 +- .../cms/ContentstackUnitTest.java | 6 +- .../contentstack/cms/user/UserUnitTests.java | 86 +++++++----- 5 files changed, 162 insertions(+), 132 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 93beae95..ce03705e 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -156,62 +156,53 @@ public Response login(String emailId, String password) throws IOEx } /** - * Login with TOTP token. + * 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 tfaToken The TOTP token + * @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: + *
+     * // Login with direct token
+     * Map params = new HashMap<>();
+     * params.put("tfaToken", "123456");
+     * Response response = contentstack.login(email, password, params);
+     * 
+     * // OR login with MFA secret
+     * Map params = new HashMap<>();
+     * params.put("mfaSecret", "YOUR_SECRET");
+     * Response response = contentstack.login(email, password, params);
+     * 
*/ - public Response login(String emailId, String password, String tfaToken) throws IOException { - return login(emailId, password, tfaToken, null); - } - - /** - * Login with TOTP/MFA support. - * - * @param emailId The email ID of the user - * @param password The user's password - * @param tfaToken The TOTP token (can be null if mfaSecret is provided) - * @param mfaSecret The MFA secret key to generate TOTP (optional if tfaToken is provided) - * @return Response containing login details - * @throws IOException if there's a network error - * @throws IllegalArgumentException if both tfaToken and mfaSecret are null or if secret is invalid - * @throws IllegalStateException if user is already logged in - */ - public Response login(String emailId, String password, String tfaToken, String mfaSecret) throws IOException { + 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 inputs - if (emailId == null || emailId.trim().isEmpty()) { + // Validate basic inputs + if (emailId.trim().isEmpty()) { throw new IllegalArgumentException("Email is required"); } - if (password == null || password.trim().isEmpty()) { + if (password.trim().isEmpty()) { throw new IllegalArgumentException("Password is required"); } - if ((tfaToken == null || tfaToken.trim().isEmpty()) && (mfaSecret == null || mfaSecret.trim().isEmpty())) { - throw new IllegalArgumentException("Either tfaToken or mfaSecret must be provided"); - } - - // Generate TOTP if needed - String finalTfaToken = tfaToken; - if ((tfaToken == null || tfaToken.trim().isEmpty()) && mfaSecret != null && !mfaSecret.trim().isEmpty()) { - try { - GoogleAuthenticator gAuth = new GoogleAuthenticator(); - finalTfaToken = String.format("%06d", gAuth.getTotpPassword(mfaSecret)); - } catch (Exception e) { - throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e); - } + if (params.isEmpty()) { + throw new IllegalArgumentException("Authentication parameters are required"); } // Perform login user = new User(this.instance); - Response response = user.login(emailId, password, finalTfaToken).execute(); + Response response = user.login(emailId, password, params).execute(); setupLoginCredentials(response); return response; } diff --git a/src/main/java/com/contentstack/cms/user/User.java b/src/main/java/com/contentstack/cms/user/User.java index d8839468..31926221 100644 --- a/src/main/java/com/contentstack/cms/user/User.java +++ b/src/main/java/com/contentstack/cms/user/User.java @@ -11,14 +11,11 @@ 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 @@ -27,8 +24,8 @@ 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 @@ -67,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); } @@ -80,85 +82,95 @@ private HashMap loginHeader() { } /** - * Login with TOTP token for two-factor authentication. + * 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 TOTP token for two-factor authentication + * @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 email, password, or tfaToken is null or empty + * @throws IllegalArgumentException if validation fails or if both tfaToken and mfaSecret are provided + * + * Example: + *
+     * // Login with direct token
+     * Map params = new HashMap<>();
+     * params.put("tfaToken", "123456");
+     * Call call = user.login(email, password, params);
+     * 
+     * // OR login with MFA secret
+     * Map params = new HashMap<>();
+     * params.put("mfaSecret", "YOUR_SECRET");
+     * Call call = user.login(email, password, params);
+     * 
*/ - public Call login(@NotNull String email, @NotNull String password, String tfaToken) { - if (tfaToken == null || tfaToken.trim().isEmpty()) { - throw new IllegalArgumentException("TOTP token cannot be null or empty"); - } - HashMap> userSession = new HashMap<>(); - userSession.put("user", setCredentials(email, password, tfaToken)); - JSONObject userDetail = new JSONObject(userSession); - return this.userService.login(loginHeader(), userDetail); - } - - private HashMap setCredentials(@NotNull String... arguments) { - if (arguments == null || arguments.length < 2) { - throw new IllegalArgumentException("Email and password are required"); + 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 (arguments[0] == null || arguments[0].trim().isEmpty()) { - throw new IllegalArgumentException("Email cannot be null or empty"); + if (password.trim().isEmpty()) { + throw new IllegalArgumentException("Password is required"); } - if (arguments[1] == null || arguments[1].trim().isEmpty()) { - throw new IllegalArgumentException("Password cannot be null or empty"); + if (params.isEmpty()) { + throw new IllegalArgumentException("Authentication parameters are required"); } + + String tfaToken = params.get("tfaToken"); + String mfaSecret = params.get("mfaSecret"); - HashMap credentials = new HashMap<>(); - credentials.put("email", arguments[0]); - credentials.put("password", arguments[1]); + // Check for mutual exclusivity + boolean hasTfaToken = tfaToken != null && !tfaToken.trim().isEmpty(); + boolean hasMfaSecret = mfaSecret != null && !mfaSecret.trim().isEmpty(); - if (arguments.length > 2 && arguments[2] != null && !arguments[2].trim().isEmpty()) { - credentials.put("tfa_token", arguments[2]); + if (hasTfaToken && hasMfaSecret) { + throw new IllegalArgumentException("Cannot provide both tfaToken and mfaSecret. Use either one."); } - return credentials; - } - - public Call login(@NotNull String email, @NotNull String password, String tfaToken, String mfaSecret) { - if (email.trim().isEmpty()) { - throw new IllegalArgumentException("Email cannot be empty"); - } - if (password.trim().isEmpty()) { - throw new IllegalArgumentException("Password cannot be empty"); - } - if ((tfaToken == null || tfaToken.trim().isEmpty()) && (mfaSecret == null || mfaSecret.trim().isEmpty())) { - throw new IllegalArgumentException("Either tfaToken or mfaSecret must be provided"); + if (!hasTfaToken && !hasMfaSecret) { + throw new IllegalArgumentException("Must provide either tfaToken or mfaSecret"); } + // Generate TOTP if needed String finalTfaToken = tfaToken; - if ((tfaToken == null || tfaToken.trim().isEmpty()) && mfaSecret != null && !mfaSecret.trim().isEmpty()) { - try { - finalTfaToken = generateTOTP(mfaSecret); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e); - } - } - if (finalTfaToken == null || finalTfaToken.trim().isEmpty()) { - throw new IllegalArgumentException("Failed to generate valid TOTP token"); + if (!hasTfaToken && hasMfaSecret) { + finalTfaToken = generateTOTP(mfaSecret); } - return login(email, password, finalTfaToken); + + // 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", credentials); + + JSONObject userDetail = new JSONObject(userSession); + return this.userService.login(loginHeader(), userDetail); } 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"); + throw new IllegalArgumentException("Generated TOTP token is invalid (not 6 digits)"); } + return totp; } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { - throw new IllegalArgumentException("Invalid MFA secret key: " + e.getMessage(), e); + throw new IllegalArgumentException("Failed to generate TOTP token: " + e.getMessage(), e); } } 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 3d5484d2..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") @@ -602,14 +602,16 @@ void testUserBaseParameters() { @DisplayName("Test login with TOTP token") void testLoginWithTOTPToken() { // Test basic login with TOTP token - Request requestInfo = userInstance.login("test@example.com", "password", "123456").request(); + 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().toString(); + String requestBody = requestInfo.body() != null ? requestInfo.body().toString() : ""; Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\"")); } @@ -617,31 +619,35 @@ void testLoginWithTOTPToken() { @Order(43) @DisplayName("Test login with MFA parameters") void testLoginWithMFAParameters() { - // Test login with both token and secret (token should be used) - Request requestInfo = userInstance.login("test@example.com", "password", "123456", "test-secret").request(); + // Test login with both token and secret (should throw exception) + Map params = new HashMap<>(); + params.put("tfaToken", "123456"); + params.put("mfaSecret", "test-secret"); - // Verify request format - Assertions.assertEquals("POST", requestInfo.method()); - Assertions.assertEquals("/v3/user-session", requestInfo.url().encodedPath()); + IllegalArgumentException exception = Assertions.assertThrows( + IllegalArgumentException.class, + () -> userInstance.login("test@example.com", "password", params).request() + ); - // Verify request body uses token - String requestBody = requestInfo.body().toString(); - Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\"")); + 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(null, "password", "123456").request()); + () -> userInstance.login("", "password", params).request()); Assertions.assertThrows(IllegalArgumentException.class, - () -> userInstance.login("test@example.com", null, "123456").request()); + () -> userInstance.login("test@example.com", "", params).request()); Assertions.assertThrows(IllegalArgumentException.class, - () -> userInstance.login("test@example.com", "password", null).request()); + () -> userInstance.login("test@example.com", "password", new HashMap<>()).request()); } @Test @@ -649,14 +655,16 @@ void testLoginValidation() { @DisplayName("Test TOTP generation from MFA secret") void testTOTPGenerationFromSecret() { // Test login with MFA secret only - Request requestInfo = userInstance.login("test@example.com", "password", null, "test-secret").request(); + 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().toString(); + String requestBody = requestInfo.body() != null ? requestInfo.body().toString() : ""; Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"")); // Extract TOTP token and verify it's 6 digits @@ -669,9 +677,12 @@ void testTOTPGenerationFromSecret() { @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", null, "invalid-secret").request() + () -> userInstance.login("test@example.com", "password", params).request() ); Assertions.assertTrue(exception.getMessage().contains("Invalid MFA secret key")); @@ -679,32 +690,41 @@ void testInvalidMFASecret() { @Test @Order(47) - @DisplayName("Test TOTP token priority over MFA secret") - void testTOTPPriority() { - // When both token and secret are provided, token should be used - Request requestInfo = userInstance.login("test@example.com", "password", "123456", "test-secret").request(); - String requestBody = requestInfo.body().toString(); + @DisplayName("Test empty parameters") + void testEmptyParameters() { + // Test with empty parameters map + Map params = new HashMap<>(); - // Should use provided token, not generate from secret - Assertions.assertTrue(requestBody.contains("\"tfa_token\":\"123456\"")); + 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 null/empty MFA parameters") - void testNullEmptyMFAParams() { - // Test with both null + @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", null, null).request() + () -> userInstance.login("test@example.com", "password", params1).request() ); - Assertions.assertTrue(exception1.getMessage().contains("Either tfaToken or mfaSecret must be provided")); + Assertions.assertTrue(exception1.getMessage().contains("Must provide either tfaToken or mfaSecret")); + + // Test with empty mfaSecret + Map params2 = new HashMap<>(); + params2.put("mfaSecret", ""); - // Test with empty secret IllegalArgumentException exception2 = Assertions.assertThrows( IllegalArgumentException.class, - () -> userInstance.login("test@example.com", "password", null, "").request() + () -> userInstance.login("test@example.com", "password", params2).request() ); - Assertions.assertTrue(exception2.getMessage().contains("Either tfaToken or mfaSecret must be provided")); + Assertions.assertTrue(exception2.getMessage().contains("Must provide either tfaToken or mfaSecret")); } } From ed3ad9bd7b73246cbcbd398a4c55baf9a62b5e5b Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 17 Sep 2025 16:00:15 +0530 Subject: [PATCH 4/6] chore: formatting --- .../java/com/contentstack/cms/Contentstack.java | 15 +++++++-------- src/main/java/com/contentstack/cms/user/User.java | 12 ++++++------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 4673b4fc..1f01dbcb 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -173,14 +173,14 @@ public Response login(String emailId, String password) throws IOEx * Example: *
      * // Login with direct token
-     * Map params = new HashMap<>();
-     * params.put("tfaToken", "123456");
-     * Response response = contentstack.login(email, password, params);
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("tfaToken", "123456");}
+     * {@code Response response = contentstack.login(email, password, params);}
      * 
      * // OR login with MFA secret
-     * Map params = new HashMap<>();
-     * params.put("mfaSecret", "YOUR_SECRET");
-     * Response response = contentstack.login(email, password, params);
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("mfaSecret", "YOUR_SECRET");}
+     * {@code Response response = contentstack.login(email, password, params);}
      * 
*/ public Response login(String emailId, String password, Map params) throws IOException { @@ -691,8 +691,7 @@ public Builder setTimeout(int timeout) { * 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. - *

+ * of inactivity *

* public ConnectionPool() { this(5, 5, TimeUnit.MINUTES); } * diff --git a/src/main/java/com/contentstack/cms/user/User.java b/src/main/java/com/contentstack/cms/user/User.java index 31926221..fb549976 100644 --- a/src/main/java/com/contentstack/cms/user/User.java +++ b/src/main/java/com/contentstack/cms/user/User.java @@ -97,14 +97,14 @@ private HashMap loginHeader() { * Example: *

      * // Login with direct token
-     * Map params = new HashMap<>();
-     * params.put("tfaToken", "123456");
-     * Call call = user.login(email, password, params);
+     * {@code Map params = new HashMap<>();}
+     * {@code params.put("tfaToken", "123456");}
+     * {@code Call call = user.login(email, password, params);}
      * 
      * // OR login with MFA secret
-     * Map params = new HashMap<>();
-     * params.put("mfaSecret", "YOUR_SECRET");
-     * Call call = user.login(email, password, params);
+     * {@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 Map params) { From 9aa874e96a98099b1f53888b8df232296f07094c Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 23 Sep 2025 11:41:11 +0530 Subject: [PATCH 5/6] chore: version bump --- changelog.md | 8 +++++++- pom.xml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 222ab339..3cffab6a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,8 +1,14 @@ # Changelog +## v1.9.0 + +### Oct 06, 2025 + +- Enhancement : TOTP login added + ## 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 4b788008..4a401af9 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.8.0 + 1.9.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach From 110b927869289f1df44efc1fb24c10b61de9b8a6 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 23 Oct 2025 12:50:36 +0530 Subject: [PATCH 6/6] chore: version bump --- changelog.md | 6 ++++++ pom.xml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 95034af3..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 diff --git a/pom.xml b/pom.xml index 4a401af9..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