diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35a7a..eba36e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## [v0.4.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.4.0) + - Feat + - **MFA Support**: Added Multi-Factor Authentication (MFA) support for login operations + - Added `mfaSecret` parameter to `Login` and `LoginAsync` methods for TOTP generation + - Automatic TOTP token generation from Base32-encoded MFA secrets using Otp.NET library + - Comprehensive test coverage for MFA functionality including unit and integration tests + - Supports both explicit token and MFA secret-based authentication flows + ## [v0.3.2](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.3.2) - Fix - Added Test cases for the Release diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs index c500203..cf5824e 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs @@ -175,5 +175,123 @@ public void Test007_Should_Return_Loggedin_User_With_Organizations_detail() Assert.Fail(e.Message); } } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Fail_Login_With_Invalid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string invalidMfaSecret = "INVALID_BASE32_SECRET!@#"; + + try + { + ContentstackResponse contentstackResponse = client.Login(credentials, null, invalidMfaSecret); + Assert.Fail("Expected exception for invalid MFA secret"); + } + catch (ArgumentException) + { + // Expected exception for invalid Base32 encoding + Assert.IsTrue(true); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test009_Should_Generate_TOTP_Token_With_Valid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = client.Login(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async System.Threading.Tasks.Task Test010_Should_Generate_TOTP_Token_With_Valid_MfaSecret_Async() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = await client.LoginAsync(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Prefer_Explicit_Token_Over_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; + string explicitToken = "123456"; + + try + { + // This should fail due to invalid credentials, but should use explicit token + ContentstackResponse contentstackResponse = client.Login(credentials, explicitToken, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials + // The important thing is that it didn't throw an exception about MFA secret processing + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException when explicit token is provided"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } } } diff --git a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs index 2f0f946..ed7dfdd 100644 --- a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs @@ -50,6 +50,107 @@ public void Should_Allow_Credentials_With_Token() Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\",\"tfa_token\":\"token\"}}", Encoding.Default.GetString(loginService.ByteContent)); } + [TestMethod] + public void Should_Allow_Credentials_With_MfaSecret() + { + + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService = new LoginService(serializer, credentials, null, testMfaSecret); + loginService.ContentBody(); + + Assert.IsNotNull(loginService); + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + Assert.IsTrue(contentString.Contains("\"email\":\"name\"")); + Assert.IsTrue(contentString.Contains("\"password\":\"password\"")); + Assert.IsTrue(contentString.Contains("\"tfa_token\":")); + + // Verify the tfa_token is not null or empty in the JSON + Assert.IsFalse(contentString.Contains("\"tfa_token\":null")); + Assert.IsFalse(contentString.Contains("\"tfa_token\":\"\"")); + } + + [TestMethod] + public void Should_Generate_TOTP_Token_When_MfaSecret_Provided() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService1 = new LoginService(serializer, credentials, null, testMfaSecret); + var loginService2 = new LoginService(serializer, credentials, null, testMfaSecret); + + loginService1.ContentBody(); + loginService2.ContentBody(); + + var content1 = Encoding.Default.GetString(loginService1.ByteContent); + var content2 = Encoding.Default.GetString(loginService2.ByteContent); + + // Both should contain tfa_token + Assert.IsTrue(content1.Contains("\"tfa_token\":")); + Assert.IsTrue(content2.Contains("\"tfa_token\":")); + + // Extract the tokens for comparison (tokens should be 6 digits) + var token1Match = System.Text.RegularExpressions.Regex.Match(content1, "\"tfa_token\":\"(\\d{6})\""); + var token2Match = System.Text.RegularExpressions.Regex.Match(content2, "\"tfa_token\":\"(\\d{6})\""); + + Assert.IsTrue(token1Match.Success); + Assert.IsTrue(token2Match.Success); + + // Tokens should be valid 6-digit numbers + Assert.AreEqual(6, token1Match.Groups[1].Value.Length); + Assert.AreEqual(6, token2Match.Groups[1].Value.Length); + } + + [TestMethod] + public void Should_Prefer_Explicit_Token_Over_MfaSecret() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; + // file deepcode ignore NoHardcodedCredentials/test: random test token + string explicitToken = "123456"; + + var loginService = new LoginService(serializer, credentials, explicitToken, testMfaSecret); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should use the explicit token, not generate one from MFA secret + Assert.IsTrue(contentString.Contains("\"tfa_token\":\"123456\"")); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Should_Throw_Exception_For_Invalid_Base32_MfaSecret() + { + // Invalid Base32 secret (contains invalid characters) + string invalidMfaSecret = "INVALID_BASE32_123!@#"; + + var loginService = new LoginService(serializer, credentials, null, invalidMfaSecret); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Empty() + { + var loginService = new LoginService(serializer, credentials, null, ""); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is empty + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Null() + { + var loginService = new LoginService(serializer, credentials, null, null); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is null + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + [TestMethod] public void Should_Override_Authtoken_To_ContentstackOptions_On_Success() { diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index ddbcc25..03904a2 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -334,6 +334,7 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -342,10 +343,10 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
         /// 
///
/// The - public ContentstackResponse Login(ICredentials credentials, string token = null) + public ContentstackResponse Login(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeSync(Login); } @@ -355,6 +356,7 @@ public ContentstackResponse Login(ICredentials credentials, string token = null) /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -363,10 +365,10 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
         /// 
///
/// The Task. - public Task LoginAsync(ICredentials credentials, string token = null) + public Task LoginAsync(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeAsync(Login); } diff --git a/Contentstack.Management.Core/Services/User/LoginService.cs b/Contentstack.Management.Core/Services/User/LoginService.cs index 5c5c180..9dbaf05 100644 --- a/Contentstack.Management.Core/Services/User/LoginService.cs +++ b/Contentstack.Management.Core/Services/User/LoginService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using System.Globalization; using Newtonsoft.Json.Linq; +using OtpNet; using Contentstack.Management.Core.Http; namespace Contentstack.Management.Core.Services.User @@ -16,7 +17,7 @@ internal class LoginService : ContentstackService #endregion #region Constructor - internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null): base(serializer) + internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null, string mfaSecret = null): base(serializer) { this.HttpMethod = "POST"; this.ResourcePath = "user-session"; @@ -27,7 +28,18 @@ internal LoginService(JsonSerializer serializer, ICredentials credentials, strin } _credentials = credentials; - _token = token; + + if (string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(mfaSecret)) + { + var secretBytes = Base32Encoding.ToBytes(mfaSecret); + + var totp = new Totp(secretBytes); + _token = totp.ComputeTotp(); + } + else + { + _token = token; + } } #endregion diff --git a/Contentstack.Management.Core/contentstack.management.core.csproj b/Contentstack.Management.Core/contentstack.management.core.csproj index 1fd8f92..0f14e8c 100644 --- a/Contentstack.Management.Core/contentstack.management.core.csproj +++ b/Contentstack.Management.Core/contentstack.management.core.csproj @@ -11,7 +11,6 @@ LICENSE.txt https://github.com/contentstack/contentstack-management-dotnet README.md - ContentType query issue resolved Contentstack management API $(Version) $(Version) @@ -63,6 +62,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 216b2fd..2627f35 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 0.3.2 + 0.4.0