Skip to content

Commit 3754643

Browse files
committed
Add user multi-factor authentication controls
Add MFA secret generation and activation/deactivation with native request fields and credential response data. Cover routes, payloads, errors and cancellation with 36 tests, and document account permissions and authenticator setup. Core API coverage is now 305 of 608 operations (50.2%). Live checks passed secret generation, TOTP activation and login, deactivation, and restoration of the original server settings.
1 parent d138985 commit 3754643

6 files changed

Lines changed: 260 additions & 6 deletions

File tree

‎API_COVERAGE.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This document maps operations in the official Mattermost OpenAPI specification t
55
- Mattermost API documentation: https://docs.mattermost.com/api
66
- Mattermost OpenAPI source: https://github.com/mattermost/mattermost/tree/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source
77
- Last reviewed: 2026-10-12
8-
- Implemented specification operations: 303/608 (49.8%)
8+
- Implemented specification operations: 305/608 (50.2%)
99
- Additional Calls plugin routes: 3
1010

1111
The SDK uses the current `/api/v4` protocol. Its version is independent of the Mattermost server release number.
@@ -41,7 +41,7 @@ Coverage counts unique HTTP method/path pairs from the canonical core API source
4141
| Threads | 7 / 7 |
4242
| Uploads | 3 / 3 |
4343
| Usage | 3 / 3 |
44-
| Users | 54 / 77 |
44+
| Users | 56 / 77 |
4545
| Webhooks | 11 / 11 |
4646

4747
## Legend
@@ -2368,8 +2368,8 @@ Coverage counts unique HTTP method/path pairs from the canonical core API source
23682368
✅ Implemented
23692369

23702370
## POST /api/v4/users/{user_id}/mfa/generate - Generate MFA secret
2371-
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1683) | `—`
2372-
❌ Not implemented
2371+
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1683) | `IMattermostClient.GenerateMfaSecretAsync`
2372+
✅ Implemented
23732373

23742374
## POST /api/v4/users/{user_id}/promote - Promote a guest to user
23752375
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1766) | `—`
@@ -2520,8 +2520,8 @@ Coverage counts unique HTTP method/path pairs from the canonical core API source
25202520
✅ Implemented
25212521

25222522
## PUT /api/v4/users/{user_id}/mfa - Update a user's MFA
2523-
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1631) | `—`
2524-
❌ Not implemented
2523+
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1631) | `IMattermostClient.UpdateUserMfaAsync`
2524+
✅ Implemented
25252525

25262526
## PUT /api/v4/users/{user_id}/password - Update a user's password
25272527
[Mattermost API](https://github.com/mattermost/mattermost/blob/18c34098b13ba9b78c9613c517b913ef0a330650/api/v4/source/users.yaml#L1841) | `IMattermostClient.UpdateUserPasswordAsync`

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,23 @@ The response uses the existing `ChannelViewResponse`: status and channel IDs map
561561

562562
Live checks on Mattermost 12.0.0 with the feature enabled passed actual DM/GM unread clearing, team channel and followed-thread unread clearing, exact native membership view times and preservation of other scopes. Both methods returned HTTP 403 for an ordinary bot attempting another user's read state. Server configuration remained unchanged. Disabled-feature responses and push notification delivery were not tested live.
563563

564+
## Manage user multi-factor authentication
565+
566+
```csharp
567+
using Mattermost.Models.Users;
568+
569+
MfaSecret setup = await client.GenerateMfaSecretAsync("me", cancellationToken);
570+
byte[] qrPng = Convert.FromBase64String(setup.QRCode);
571+
await client.UpdateUserMfaAsync("me", true, authenticatorCode, cancellationToken);
572+
await client.UpdateUserMfaAsync("me", false, cancellationToken: cancellationToken);
573+
```
574+
575+
MFA generation and activation require the server's `EnableMultifactorAuthentication` setting. These methods do not change server settings. Use the current user or an account you have permission to edit; OAuth sessions cannot call these endpoints. Activation supports email/password and LDAP accounts, and the server may impose additional MFA requirements when editing another user.
576+
577+
Generation immediately replaces the account's stored secret, even if MFA is already active, but does not activate MFA. `Secret` is base32 and `QRCode` is a base64 PNG containing the authenticator setup URI. Treat both as credentials; display the QR securely and do not log either value. Obtain `authenticatorCode` from the configured authenticator. Activation requires a nonempty code, preserved exactly, and consumes it; use a fresh code for the next login. Deactivation ignores the code and clears the stored secret. The server sends an MFA-change email. Cancellation cannot undo an accepted secret or state change, and native errors are propagated without retries.
578+
579+
Live checks on Mattermost 12.0.0 with a separate email/password account passed secret and PNG generation, inactive state after generation, invalid-code rejection, TOTP activation, password-only login rejection, login with a fresh TOTP, deactivation and restored password-only login. The original server configuration was restored and verified unchanged. LDAP, OAuth rejection and MFA-change email delivery were not tested live.
580+
564581
## Request an administrator upgrade notification
565582

566583
```csharp
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using Mattermost.Exceptions;
2+
using Mattermost.Models.Users;
3+
using System.Net;
4+
using System.Text.Json;
5+
6+
namespace Mattermost.Tests
7+
{
8+
[TestFixture]
9+
internal class UserMfaTests
10+
{
11+
[Test]
12+
public void MfaSecret_UsesExactNativeCredentialFields()
13+
{
14+
const string json = "{\"secret\":\"TEST-SECRET\",\"qr_code\":\"cG5n\"}";
15+
MfaSecret model = JsonSerializer.Deserialize<MfaSecret>(json)!;
16+
Assert.That(JsonSerializer.Serialize(model), Is.EqualTo(json));
17+
}
18+
19+
[Test]
20+
public async Task GenerateMfaSecret_UsesNativePostAndReturnsBothCredentials()
21+
{
22+
using HttpClient http = ApiTestHttp.Create((request, _) =>
23+
{
24+
Assert.That(request.Method, Is.EqualTo(HttpMethod.Post));
25+
Assert.That(request.RequestUri!.AbsoluteUri, Is.EqualTo("https://mattermost.example/chat/api/v4/users/user%2F1%3Fx%23y/mfa/generate"));
26+
Assert.That(request.Content, Is.Null);
27+
return Task.FromResult(ApiTestHttp.Json(HttpStatusCode.OK, "{\"secret\":\"TEST-SECRET\",\"qr_code\":\"cG5n\"}"));
28+
});
29+
using MattermostClient client = ApiTestHttp.Client(http);
30+
MfaSecret result = await client.GenerateMfaSecretAsync(" user/1?x#y ");
31+
Assert.That(result.Secret, Is.EqualTo("TEST-SECRET"));
32+
Assert.That(result.QRCode, Is.EqualTo("cG5n"));
33+
}
34+
35+
[TestCase(true, " 123456 ", "{\"activate\":true,\"code\":\" 123456 \"}")]
36+
[TestCase(false, null, "{\"activate\":false}")]
37+
[TestCase(false, "ignored", "{\"activate\":false}")]
38+
public async Task UpdateUserMfa_SendsNativeFieldsAndDoesNotSendInactiveCode(bool activate, string? code, string expected)
39+
{
40+
using HttpClient http = ApiTestHttp.Create(async (request, token) =>
41+
{
42+
Assert.That(request.Method, Is.EqualTo(HttpMethod.Put));
43+
Assert.That(request.RequestUri!.AbsoluteUri, Is.EqualTo("https://mattermost.example/chat/api/v4/users/user%2F1%3Fx%23y/mfa"));
44+
Assert.That(await request.Content!.ReadAsStringAsync(token), Is.EqualTo(expected));
45+
return ApiTestHttp.Json(HttpStatusCode.OK, "{\"status\":\"OK\"}");
46+
});
47+
using MattermostClient client = ApiTestHttp.Client(http);
48+
await client.UpdateUserMfaAsync(" user/1?x#y ", activate, code);
49+
}
50+
51+
[TestCase(false)]
52+
[TestCase(true)]
53+
public async Task Mfa_CurrentUserAliasIsPreserved(bool generate)
54+
{
55+
using HttpClient http = ApiTestHttp.Create((request, _) =>
56+
{
57+
Assert.That(request.RequestUri!.AbsolutePath, Does.Contain("/users/me/mfa"));
58+
return Task.FromResult(ApiTestHttp.Json(HttpStatusCode.OK, "{\"secret\":\"test\",\"qr_code\":\"test\"}"));
59+
});
60+
using MattermostClient client = ApiTestHttp.Client(http);
61+
await InvokeAsync(client, generate, "me");
62+
}
63+
64+
[TestCase(false, null)]
65+
[TestCase(false, "")]
66+
[TestCase(false, " \t")]
67+
[TestCase(true, null)]
68+
[TestCase(true, "")]
69+
[TestCase(true, " \t")]
70+
public void Mfa_BlankUserIsRejected(bool generate, string? userId)
71+
{
72+
using HttpClient http = ApiTestHttp.RejectRequests();
73+
using MattermostClient client = ApiTestHttp.Client(http);
74+
ArgumentException error = Assert.Throws<ArgumentException>(() => InvokeAsync(client, generate, userId!))!;
75+
Assert.That(error.ParamName, Is.EqualTo("userId"));
76+
}
77+
78+
[TestCase(null)]
79+
[TestCase("")]
80+
public void UpdateUserMfa_ActivationWithoutCodeIsRejected(string? code)
81+
{
82+
using HttpClient http = ApiTestHttp.RejectRequests();
83+
using MattermostClient client = ApiTestHttp.Client(http);
84+
ArgumentException error = Assert.Throws<ArgumentException>(() => client.UpdateUserMfaAsync("me", true, code))!;
85+
Assert.That(error.ParamName, Is.EqualTo("code"));
86+
}
87+
88+
[TestCase(false, HttpStatusCode.BadRequest)]
89+
[TestCase(false, HttpStatusCode.Unauthorized)]
90+
[TestCase(false, HttpStatusCode.Forbidden)]
91+
[TestCase(false, HttpStatusCode.NotFound)]
92+
[TestCase(false, HttpStatusCode.InternalServerError)]
93+
[TestCase(false, HttpStatusCode.NotImplemented)]
94+
[TestCase(true, HttpStatusCode.BadRequest)]
95+
[TestCase(true, HttpStatusCode.Unauthorized)]
96+
[TestCase(true, HttpStatusCode.Forbidden)]
97+
[TestCase(true, HttpStatusCode.NotFound)]
98+
[TestCase(true, HttpStatusCode.InternalServerError)]
99+
[TestCase(true, HttpStatusCode.NotImplemented)]
100+
public async Task Mfa_NativeErrorIsPropagatedWithoutRetry(bool generate, HttpStatusCode status)
101+
{
102+
int requests = 0;
103+
using HttpClient http = ApiTestHttp.Create((_, _) =>
104+
{
105+
requests++;
106+
return Task.FromResult(ApiTestHttp.Json(status, "{\"message\":\"MFA rejected\"}"));
107+
});
108+
using MattermostClient client = ApiTestHttp.Client(http);
109+
MattermostClientException error = (await Assert.ThrowsAsync<MattermostClientException>(() => InvokeAsync(client, generate)))!;
110+
Assert.That(error.StatusCode, Is.EqualTo(status));
111+
Assert.That(error.Message, Is.EqualTo("MFA rejected"));
112+
Assert.That(requests, Is.EqualTo(1));
113+
}
114+
115+
[TestCase("null")]
116+
[TestCase("[]")]
117+
[TestCase("{broken")]
118+
public async Task GenerateMfaSecret_InvalidResponseIsRejected(string response)
119+
{
120+
using HttpClient http = ApiTestHttp.Create((_, _) => Task.FromResult(ApiTestHttp.Json(HttpStatusCode.OK, response)));
121+
using MattermostClient client = ApiTestHttp.Client(http);
122+
await Assert.CatchAsync<JsonException>(() => client.GenerateMfaSecretAsync("me"));
123+
}
124+
125+
[TestCase(false)]
126+
[TestCase(true)]
127+
public void Mfa_CancelledAndDisposedCallsSendNothing(bool generate)
128+
{
129+
using HttpClient http = ApiTestHttp.RejectRequests();
130+
using MattermostClient client = ApiTestHttp.Client(http);
131+
using CancellationTokenSource cancellation = new CancellationTokenSource();
132+
cancellation.Cancel();
133+
Assert.Throws<OperationCanceledException>(() => InvokeAsync(client, generate, token: cancellation.Token));
134+
client.Dispose();
135+
Assert.Throws<ObjectDisposedException>(() => InvokeAsync(client, generate));
136+
}
137+
138+
[TestCase(false, false)]
139+
[TestCase(false, true)]
140+
[TestCase(true, false)]
141+
[TestCase(true, true)]
142+
public async Task Mfa_CancellationStopsAuthenticationOrRequest(bool generate, bool authentication)
143+
{
144+
using CancellationTokenSource cancellation = new CancellationTokenSource();
145+
TaskCompletionSource<bool> started = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
146+
using HttpClient http = new HttpClient(new DelegateHttpMessageHandler(async (request, token) =>
147+
{
148+
if (!authentication && request.RequestUri!.AbsolutePath.EndsWith("/users/me", StringComparison.Ordinal))
149+
{
150+
return ApiTestHttp.Json(HttpStatusCode.OK, "{\"id\":\"user-1\"}");
151+
}
152+
started.TrySetResult(true);
153+
await Task.Delay(Timeout.InfiniteTimeSpan, token);
154+
throw new AssertionException("Expected cancellation.");
155+
}));
156+
using MattermostClient client = ApiTestHttp.Client(http);
157+
Task pending = InvokeAsync(client, generate, token: cancellation.Token);
158+
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
159+
await cancellation.CancelAsync();
160+
await Assert.CatchAsync<OperationCanceledException>(async () => await pending.WaitAsync(TimeSpan.FromSeconds(5)));
161+
}
162+
163+
private static Task InvokeAsync(IMattermostUsersClient client, bool generate, string userId = "user-1", CancellationToken token = default)
164+
{
165+
if (generate) { return client.GenerateMfaSecretAsync(userId, token); }
166+
return client.UpdateUserMfaAsync(userId, true, "123456", token);
167+
}
168+
}
169+
}

‎Sources/Mattermost/ClientInterfaces/IMattermostUsersClient.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ namespace Mattermost
1515
/// </summary>
1616
public interface IMattermostUsersClient
1717
{
18+
/// <summary>Generate and store a new multi-factor authentication secret for an account.</summary>
19+
/// <param name="userId">User identifier or me for the current user.</param>
20+
/// <param name="cancellationToken">Cancels authentication and the request, not a secret already stored.</param>
21+
/// <returns>Base32 TOTP secret and base64 PNG QR image. Both values are credentials.</returns>
22+
/// <remarks>Requires enabled MFA, a non-OAuth session and permission to edit the user. Replaces the stored secret even if MFA is already active; does not activate MFA. The server controls permissions and configuration errors.</remarks>
23+
Task<MfaSecret> GenerateMfaSecretAsync(string userId, CancellationToken cancellationToken = default);
24+
25+
/// <summary>Activate or deactivate multi-factor authentication for an account.</summary>
26+
/// <param name="userId">User identifier or me for the current user.</param>
27+
/// <param name="activate">True to activate with an authenticator code; false to deactivate and clear the stored secret.</param>
28+
/// <param name="code">Nonempty TOTP code required for activation, preserved exactly. Ignored and omitted when deactivating.</param>
29+
/// <param name="cancellationToken">Cancels authentication and the request, not an update already applied.</param>
30+
/// <remarks>Requires a non-OAuth session and permission to edit the user. Activation requires enabled MFA and an email/password or LDAP account. The server may impose additional MFA requirements for editing another user and sends an MFA-change email. Activation consumes the code; obtain a fresh code for the next login.</remarks>
31+
Task UpdateUserMfaAsync(string userId, bool activate, string? code = null, CancellationToken cancellationToken = default);
32+
1833
/// <summary>Record the current user's request to notify administrators about a paid feature.</summary>
1934
/// <param name="requiredPlan">Native plan identifier, trimmed. The server accepts professional or enterprise for built-in paid features; plugin-specific requests can use an empty plan.</param>
2035
/// <param name="requiredFeature">Nonblank native feature identifier, trimmed. The server validates support; this is not a display label.</param>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Mattermost.Constants;
2+
using Mattermost.Models.Users;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Net.Http;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
namespace Mattermost
10+
{
11+
public partial class MattermostClient
12+
{
13+
/// <inheritdoc />
14+
public Task<MfaSecret> GenerateMfaSecretAsync(string userId, CancellationToken cancellationToken = default)
15+
{
16+
CheckDisposed();
17+
string route = Routes.Users + "/" + EscapeReadIdentifier(userId, nameof(userId)) + "/mfa/generate";
18+
cancellationToken.ThrowIfCancellationRequested();
19+
return SendRequestAsync<MfaSecret>(HttpMethod.Post, route, cancellationToken: cancellationToken);
20+
}
21+
22+
/// <inheritdoc />
23+
public Task UpdateUserMfaAsync(string userId, bool activate, string? code = null, CancellationToken cancellationToken = default)
24+
{
25+
CheckDisposed();
26+
string route = Routes.Users + "/" + EscapeReadIdentifier(userId, nameof(userId)) + "/mfa";
27+
Dictionary<string, object> body = new Dictionary<string, object> { ["activate"] = activate };
28+
if (activate)
29+
{
30+
if (code is null || code.Length == 0) { throw new ArgumentException("A code is required to activate MFA.", nameof(code)); }
31+
body["code"] = code;
32+
}
33+
cancellationToken.ThrowIfCancellationRequested();
34+
return SendRequestAsync(HttpMethod.Put, route, body, cancellationToken);
35+
}
36+
}
37+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Mattermost.Models.Users
4+
{
5+
/// <summary>Generated multi-factor authentication secret and its QR image. Treat both values as credentials.</summary>
6+
public class MfaSecret
7+
{
8+
/// <summary>Base32-encoded TOTP secret, already stored by the server for this account.</summary>
9+
[JsonPropertyName("secret")]
10+
public string Secret { get; set; } = string.Empty;
11+
12+
/// <summary>Base64-encoded PNG containing the authenticator setup URI.</summary>
13+
[JsonPropertyName("qr_code")]
14+
public string QRCode { get; set; } = string.Empty;
15+
}
16+
}

0 commit comments

Comments
 (0)