Skip to content

Commit 53cd860

Browse files
authored
Merge pull request #103 from contentstack/feat/DX-3454-topt
Add MFA support to login methods and update version to 0.4.0
2 parents 6746151 + 6cb68fb commit 53cd860

File tree

7 files changed

+249
-8
lines changed

7 files changed

+249
-8
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
# Changelog
2+
## [v0.4.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.4.0)
3+
- Feat
4+
- **MFA Support**: Added Multi-Factor Authentication (MFA) support for login operations
5+
- Added `mfaSecret` parameter to `Login` and `LoginAsync` methods for TOTP generation
6+
- Automatic TOTP token generation from Base32-encoded MFA secrets using Otp.NET library
7+
- Comprehensive test coverage for MFA functionality including unit and integration tests
8+
- Supports both explicit token and MFA secret-based authentication flows
9+
210
## [v0.3.2](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.3.2)
311
- Fix
412
- Added Test cases for the Release

Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,5 +175,123 @@ public void Test007_Should_Return_Loggedin_User_With_Organizations_detail()
175175
Assert.Fail(e.Message);
176176
}
177177
}
178+
179+
[TestMethod]
180+
[DoNotParallelize]
181+
public void Test008_Should_Fail_Login_With_Invalid_MfaSecret()
182+
{
183+
ContentstackClient client = new ContentstackClient();
184+
NetworkCredential credentials = new NetworkCredential("test_user", "test_password");
185+
string invalidMfaSecret = "INVALID_BASE32_SECRET!@#";
186+
187+
try
188+
{
189+
ContentstackResponse contentstackResponse = client.Login(credentials, null, invalidMfaSecret);
190+
Assert.Fail("Expected exception for invalid MFA secret");
191+
}
192+
catch (ArgumentException)
193+
{
194+
// Expected exception for invalid Base32 encoding
195+
Assert.IsTrue(true);
196+
}
197+
catch (Exception e)
198+
{
199+
Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}");
200+
}
201+
}
202+
203+
[TestMethod]
204+
[DoNotParallelize]
205+
public void Test009_Should_Generate_TOTP_Token_With_Valid_MfaSecret()
206+
{
207+
ContentstackClient client = new ContentstackClient();
208+
NetworkCredential credentials = new NetworkCredential("test_user", "test_password");
209+
string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret
210+
211+
try
212+
{
213+
// This should fail due to invalid credentials, but should succeed in generating TOTP
214+
ContentstackResponse contentstackResponse = client.Login(credentials, null, validMfaSecret);
215+
}
216+
catch (ContentstackErrorException errorException)
217+
{
218+
// Expected to fail due to invalid credentials, but we verify it processed the MFA secret
219+
// The error should be about credentials, not about MFA secret format
220+
Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode);
221+
Assert.IsTrue(errorException.Message.Contains("email or password") ||
222+
errorException.Message.Contains("credentials") ||
223+
errorException.Message.Contains("authentication"));
224+
}
225+
catch (ArgumentException)
226+
{
227+
Assert.Fail("Should not throw ArgumentException for valid MFA secret");
228+
}
229+
catch (Exception e)
230+
{
231+
Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}");
232+
}
233+
}
234+
235+
[TestMethod]
236+
[DoNotParallelize]
237+
public async System.Threading.Tasks.Task Test010_Should_Generate_TOTP_Token_With_Valid_MfaSecret_Async()
238+
{
239+
ContentstackClient client = new ContentstackClient();
240+
NetworkCredential credentials = new NetworkCredential("test_user", "test_password");
241+
string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret
242+
243+
try
244+
{
245+
// This should fail due to invalid credentials, but should succeed in generating TOTP
246+
ContentstackResponse contentstackResponse = await client.LoginAsync(credentials, null, validMfaSecret);
247+
}
248+
catch (ContentstackErrorException errorException)
249+
{
250+
// Expected to fail due to invalid credentials, but we verify it processed the MFA secret
251+
// The error should be about credentials, not about MFA secret format
252+
Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode);
253+
Assert.IsTrue(errorException.Message.Contains("email or password") ||
254+
errorException.Message.Contains("credentials") ||
255+
errorException.Message.Contains("authentication"));
256+
}
257+
catch (ArgumentException)
258+
{
259+
Assert.Fail("Should not throw ArgumentException for valid MFA secret");
260+
}
261+
catch (Exception e)
262+
{
263+
Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}");
264+
}
265+
}
266+
267+
[TestMethod]
268+
[DoNotParallelize]
269+
public void Test011_Should_Prefer_Explicit_Token_Over_MfaSecret()
270+
{
271+
ContentstackClient client = new ContentstackClient();
272+
NetworkCredential credentials = new NetworkCredential("test_user", "test_password");
273+
string validMfaSecret = "JBSWY3DPEHPK3PXP";
274+
string explicitToken = "123456";
275+
276+
try
277+
{
278+
// This should fail due to invalid credentials, but should use explicit token
279+
ContentstackResponse contentstackResponse = client.Login(credentials, explicitToken, validMfaSecret);
280+
}
281+
catch (ContentstackErrorException errorException)
282+
{
283+
// Expected to fail due to invalid credentials
284+
// The important thing is that it didn't throw an exception about MFA secret processing
285+
Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode);
286+
}
287+
catch (ArgumentException)
288+
{
289+
Assert.Fail("Should not throw ArgumentException when explicit token is provided");
290+
}
291+
catch (Exception e)
292+
{
293+
Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}");
294+
}
295+
}
178296
}
179297
}

Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,107 @@ public void Should_Allow_Credentials_With_Token()
5050
Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\",\"tfa_token\":\"token\"}}", Encoding.Default.GetString(loginService.ByteContent));
5151
}
5252

53+
[TestMethod]
54+
public void Should_Allow_Credentials_With_MfaSecret()
55+
{
56+
57+
string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!"
58+
var loginService = new LoginService(serializer, credentials, null, testMfaSecret);
59+
loginService.ContentBody();
60+
61+
Assert.IsNotNull(loginService);
62+
var contentString = Encoding.Default.GetString(loginService.ByteContent);
63+
64+
Assert.IsTrue(contentString.Contains("\"email\":\"name\""));
65+
Assert.IsTrue(contentString.Contains("\"password\":\"password\""));
66+
Assert.IsTrue(contentString.Contains("\"tfa_token\":"));
67+
68+
// Verify the tfa_token is not null or empty in the JSON
69+
Assert.IsFalse(contentString.Contains("\"tfa_token\":null"));
70+
Assert.IsFalse(contentString.Contains("\"tfa_token\":\"\""));
71+
}
72+
73+
[TestMethod]
74+
public void Should_Generate_TOTP_Token_When_MfaSecret_Provided()
75+
{
76+
string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!"
77+
var loginService1 = new LoginService(serializer, credentials, null, testMfaSecret);
78+
var loginService2 = new LoginService(serializer, credentials, null, testMfaSecret);
79+
80+
loginService1.ContentBody();
81+
loginService2.ContentBody();
82+
83+
var content1 = Encoding.Default.GetString(loginService1.ByteContent);
84+
var content2 = Encoding.Default.GetString(loginService2.ByteContent);
85+
86+
// Both should contain tfa_token
87+
Assert.IsTrue(content1.Contains("\"tfa_token\":"));
88+
Assert.IsTrue(content2.Contains("\"tfa_token\":"));
89+
90+
// Extract the tokens for comparison (tokens should be 6 digits)
91+
var token1Match = System.Text.RegularExpressions.Regex.Match(content1, "\"tfa_token\":\"(\\d{6})\"");
92+
var token2Match = System.Text.RegularExpressions.Regex.Match(content2, "\"tfa_token\":\"(\\d{6})\"");
93+
94+
Assert.IsTrue(token1Match.Success);
95+
Assert.IsTrue(token2Match.Success);
96+
97+
// Tokens should be valid 6-digit numbers
98+
Assert.AreEqual(6, token1Match.Groups[1].Value.Length);
99+
Assert.AreEqual(6, token2Match.Groups[1].Value.Length);
100+
}
101+
102+
[TestMethod]
103+
public void Should_Prefer_Explicit_Token_Over_MfaSecret()
104+
{
105+
string testMfaSecret = "JBSWY3DPEHPK3PXP";
106+
// file deepcode ignore NoHardcodedCredentials/test: random test token
107+
string explicitToken = "123456";
108+
109+
var loginService = new LoginService(serializer, credentials, explicitToken, testMfaSecret);
110+
loginService.ContentBody();
111+
112+
var contentString = Encoding.Default.GetString(loginService.ByteContent);
113+
114+
// Should use the explicit token, not generate one from MFA secret
115+
Assert.IsTrue(contentString.Contains("\"tfa_token\":\"123456\""));
116+
}
117+
118+
[TestMethod]
119+
[ExpectedException(typeof(ArgumentException))]
120+
public void Should_Throw_Exception_For_Invalid_Base32_MfaSecret()
121+
{
122+
// Invalid Base32 secret (contains invalid characters)
123+
string invalidMfaSecret = "INVALID_BASE32_123!@#";
124+
125+
var loginService = new LoginService(serializer, credentials, null, invalidMfaSecret);
126+
}
127+
128+
[TestMethod]
129+
public void Should_Not_Generate_Token_When_MfaSecret_Is_Empty()
130+
{
131+
var loginService = new LoginService(serializer, credentials, null, "");
132+
loginService.ContentBody();
133+
134+
var contentString = Encoding.Default.GetString(loginService.ByteContent);
135+
136+
// Should not contain tfa_token when MFA secret is empty
137+
Assert.IsFalse(contentString.Contains("\"tfa_token\":"));
138+
Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString);
139+
}
140+
141+
[TestMethod]
142+
public void Should_Not_Generate_Token_When_MfaSecret_Is_Null()
143+
{
144+
var loginService = new LoginService(serializer, credentials, null, null);
145+
loginService.ContentBody();
146+
147+
var contentString = Encoding.Default.GetString(loginService.ByteContent);
148+
149+
// Should not contain tfa_token when MFA secret is null
150+
Assert.IsFalse(contentString.Contains("\"tfa_token\":"));
151+
Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString);
152+
}
153+
53154
[TestMethod]
54155
public void Should_Override_Authtoken_To_ContentstackOptions_On_Success()
55156
{

Contentstack.Management.Core/ContentstackClient.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
334334
/// </summary>
335335
/// <param name="credentials">User credentials for login.</param>
336336
/// <param name="token">The optional 2FA token.</param>
337+
/// <param name="mfaSecret">The optional MFA Secret for 2FA token.</param>
337338
/// <example>
338339
/// <pre><code>
339340
/// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
@@ -342,10 +343,10 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
342343
/// </code></pre>
343344
/// </example>
344345
/// <returns>The <see cref="ContentstackResponse" /></returns>
345-
public ContentstackResponse Login(ICredentials credentials, string token = null)
346+
public ContentstackResponse Login(ICredentials credentials, string token = null, string mfaSecret = null)
346347
{
347348
ThrowIfAlreadyLoggedIn();
348-
LoginService Login = new LoginService(serializer, credentials, token);
349+
LoginService Login = new LoginService(serializer, credentials, token, mfaSecret);
349350

350351
return InvokeSync(Login);
351352
}
@@ -355,6 +356,7 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
355356
/// </summary>
356357
/// <param name="credentials">User credentials for login.</param>
357358
/// <param name="token">The optional 2FA token.</param>
359+
/// <param name="mfaSecret">The optional MFA Secret for 2FA token.</param>
358360
/// <example>
359361
/// <pre><code>
360362
/// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
@@ -363,10 +365,10 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
363365
/// </code></pre>
364366
/// </example>
365367
/// <returns>The Task.</returns>
366-
public Task<ContentstackResponse> LoginAsync(ICredentials credentials, string token = null)
368+
public Task<ContentstackResponse> LoginAsync(ICredentials credentials, string token = null, string mfaSecret = null)
367369
{
368370
ThrowIfAlreadyLoggedIn();
369-
LoginService Login = new LoginService(serializer, credentials, token);
371+
LoginService Login = new LoginService(serializer, credentials, token, mfaSecret);
370372

371373
return InvokeAsync<LoginService, ContentstackResponse>(Login);
372374
}

Contentstack.Management.Core/Services/User/LoginService.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Newtonsoft.Json;
55
using System.Globalization;
66
using Newtonsoft.Json.Linq;
7+
using OtpNet;
78
using Contentstack.Management.Core.Http;
89

910
namespace Contentstack.Management.Core.Services.User
@@ -16,7 +17,7 @@ internal class LoginService : ContentstackService
1617
#endregion
1718

1819
#region Constructor
19-
internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null): base(serializer)
20+
internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null, string mfaSecret = null): base(serializer)
2021
{
2122
this.HttpMethod = "POST";
2223
this.ResourcePath = "user-session";
@@ -27,7 +28,18 @@ internal LoginService(JsonSerializer serializer, ICredentials credentials, strin
2728
}
2829

2930
_credentials = credentials;
30-
_token = token;
31+
32+
if (string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(mfaSecret))
33+
{
34+
var secretBytes = Base32Encoding.ToBytes(mfaSecret);
35+
36+
var totp = new Totp(secretBytes);
37+
_token = totp.ComputeTotp();
38+
}
39+
else
40+
{
41+
_token = token;
42+
}
3143
}
3244
#endregion
3345

Contentstack.Management.Core/contentstack.management.core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
1212
<PackageProjectUrl>https://github.com/contentstack/contentstack-management-dotnet</PackageProjectUrl>
1313
<PackageReadmeFile>README.md</PackageReadmeFile>
14-
<PackageReleaseNotes>ContentType query issue resolved</PackageReleaseNotes>
1514
<PackageTags>Contentstack management API </PackageTags>
1615
<PackageVersion>$(Version)</PackageVersion>
1716
<ReleaseVersion>$(Version)</ReleaseVersion>
@@ -63,6 +62,7 @@
6362
<Folder Include="Runtime\Pipeline\RertyHandler\" />
6463
</ItemGroup>
6564
<ItemGroup>
65+
<PackageReference Include="Otp.NET" Version="1.4.0" />
6666
<PackageReference Include="System.Net.Http" Version="4.3.4" />
6767
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
6868
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>0.3.2</Version>
3+
<Version>0.4.0</Version>
44
</PropertyGroup>
55
</Project>

0 commit comments

Comments
 (0)