Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
10 changes: 6 additions & 4 deletions Contentstack.Management.Core/ContentstackClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
/// </summary>
/// <param name="credentials">User credentials for login.</param>
/// <param name="token">The optional 2FA token.</param>
/// <param name="mfaSecret">The optional MFA Secret for 2FA token.</param>
/// <example>
/// <pre><code>
/// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
Expand All @@ -342,10 +343,10 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
/// </code></pre>
/// </example>
/// <returns>The <see cref="ContentstackResponse" /></returns>
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);
}
Expand All @@ -355,6 +356,7 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
/// </summary>
/// <param name="credentials">User credentials for login.</param>
/// <param name="token">The optional 2FA token.</param>
/// <param name="mfaSecret">The optional MFA Secret for 2FA token.</param>
/// <example>
/// <pre><code>
/// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
Expand All @@ -363,10 +365,10 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
/// </code></pre>
/// </example>
/// <returns>The Task.</returns>
public Task<ContentstackResponse> LoginAsync(ICredentials credentials, string token = null)
public Task<ContentstackResponse> 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<LoginService, ContentstackResponse>(Login);
}
Expand Down
16 changes: 14 additions & 2 deletions Contentstack.Management.Core/Services/User/LoginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
<PackageProjectUrl>https://github.com/contentstack/contentstack-management-dotnet</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>ContentType query issue resolved</PackageReleaseNotes>
<PackageTags>Contentstack management API </PackageTags>
<PackageVersion>$(Version)</PackageVersion>
<ReleaseVersion>$(Version)</ReleaseVersion>
Expand Down Expand Up @@ -63,6 +62,7 @@
<Folder Include="Runtime\Pipeline\RertyHandler\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>0.3.2</Version>
<Version>0.4.0</Version>
</PropertyGroup>
</Project>
Loading