Skip to content

Commit 88eb4e3

Browse files
committed
fix(auth): prevent 2FA secret overwrite, fix recovery code login, improve rate-limit UX
1 parent 4d82c17 commit 88eb4e3

11 files changed

Lines changed: 102 additions & 30 deletions

File tree

DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ public async Task SetupTwoFactor_returns_200_with_setup_payload()
163163
var payload = new TwoFactorSetupResponse("SECRET", "otpauth://totp/x", "SECR ET");
164164
var userId = Guid.NewGuid();
165165
var auth = new Mock<IAuthService>();
166-
auth.Setup(a => a.SetupTwoFactorAsync(userId, It.IsAny<CancellationToken>())).ReturnsAsync(payload);
166+
auth.Setup(a => a.SetupTwoFactorAsync(userId, It.IsAny<CancellationToken>())).ReturnsAsync(((string?)null, payload));
167167
var currentUser = new Mock<ICurrentUser>();
168168
currentUser.SetupGet(c => c.UserId).Returns(userId);
169169

@@ -173,6 +173,21 @@ public async Task SetupTwoFactor_returns_200_with_setup_payload()
173173
Assert.Equal("SECRET", Assert.IsType<ApiResponse<TwoFactorSetupResponse>>(ok.Value).Data!.Secret);
174174
}
175175

176+
[Fact]
177+
public async Task SetupTwoFactor_returns_400_when_already_enabled()
178+
{
179+
var auth = new Mock<IAuthService>();
180+
auth.Setup(a => a.SetupTwoFactorAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
181+
.ReturnsAsync(("Two-factor authentication is already enabled. Disable it first to re-configure.", (TwoFactorSetupResponse?)null));
182+
183+
var result = await NewController(auth.Object, Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>())
184+
.SetupTwoFactor(default);
185+
186+
var bad = Assert.IsType<BadRequestObjectResult>(result);
187+
Assert.Equal("TWO_FACTOR_ALREADY_ENABLED", Assert.IsType<ApiResponse<object>>(bad.Value).Error!.Code);
188+
}
189+
190+
176191
[Fact]
177192
public async Task ConfirmTwoFactor_returns_400_on_error()
178193
{

DocAnalytics.Api/Controllers/AuthController.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,14 @@ public async Task<IActionResult> LoginTwoFactor([FromBody] TwoFactorLoginRequest
194194
[HttpPost("2fa/setup")]
195195
public async Task<IActionResult> SetupTwoFactor(CancellationToken ct)
196196
{
197-
var result = await _auth.SetupTwoFactorAsync(_currentUser.UserId, ct);
198-
return Ok(ApiResponse<TwoFactorSetupResponse>.Ok(result));
197+
var (error, result) = await _auth.SetupTwoFactorAsync(_currentUser.UserId, ct);
198+
if (error is not null)
199+
return BadRequest(ApiResponse<object>.Fail("TWO_FACTOR_ALREADY_ENABLED", error));
200+
201+
return Ok(ApiResponse<TwoFactorSetupResponse>.Ok(result!));
199202
}
200203

204+
201205
/// <summary>Confirms 2FA setup with a valid code: enables 2FA, returns one-time recovery codes.</summary>
202206
[Authorize]
203207
[HttpPost("2fa/confirm")]

DocAnalytics.Api/appsettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@
6363
"PermitLimit": 3,
6464
"WindowSeconds": 60,
6565
"QueueLimit": 0
66+
},
67+
"Mfa": {
68+
"PermitLimit": 5,
69+
"WindowSeconds": 300,
70+
"QueueLimit": 0
6671
}
6772
}
6873

DocAnalytics.Service.Tests/Auth/AuthServiceTests.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,23 +183,25 @@ public async Task SetupTwoFactorAsync_stores_an_encrypted_secret_and_returns_set
183183
var user = ActiveUser("a@org.com", "pw");
184184
var sut = NewSut(Ctx(new[] { user }, Array.Empty<UserSiteAccess>(), Array.Empty<Site>()));
185185

186-
var result = await sut.SetupTwoFactorAsync(user.Id, default);
186+
var (error, result) = await sut.SetupTwoFactorAsync(user.Id, default);
187187

188-
Assert.False(string.IsNullOrWhiteSpace(result.Secret));
188+
Assert.Null(error);
189+
Assert.False(string.IsNullOrWhiteSpace(result!.Secret));
189190
Assert.StartsWith("otpauth://totp/", result.OtpAuthUri);
190191
Assert.NotNull(user.TwoFactorSecret);
191192
Assert.NotEqual(result.Secret, user.TwoFactorSecret); // stored value is encrypted, not plaintext
192193
}
193194

195+
194196
[Fact]
195197
public async Task ConfirmTwoFactorAsync_enables_2fa_and_returns_recovery_codes_on_valid_code()
196198
{
197199
var twoFactor = new TwoFactorService();
198200
var user = ActiveUser("a@org.com", "pw");
199201
var sut = NewSut(Ctx(new[] { user }, Array.Empty<UserSiteAccess>(), Array.Empty<Site>()), twoFactor: twoFactor);
200202

201-
var setup = await sut.SetupTwoFactorAsync(user.Id, default);
202-
var code = new OtpNet.Totp(OtpNet.Base32Encoding.ToBytes(setup.Secret)).ComputeTotp();
203+
var (setupError, setup) = await sut.SetupTwoFactorAsync(user.Id, default);
204+
var code = new OtpNet.Totp(OtpNet.Base32Encoding.ToBytes(setup!.Secret)).ComputeTotp();
203205

204206
var (error, result) = await sut.ConfirmTwoFactorAsync(user.Id, code, default);
205207

DocAnalytics.Service.Tests/Auth/AuthServiceTwoFactorTests.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,30 @@ public async Task SetupTwoFactorAsync_StoresEncryptedSecret_AndReturnsSetupPaylo
199199
.Returns(("SECRETBASE32", "otpauth://totp/...", "SECR ETBA SE32"));
200200
var sut = NewSut(db);
201201

202-
var response = await sut.SetupTwoFactorAsync(user.Id, CancellationToken.None);
202+
var (error, response) = await sut.SetupTwoFactorAsync(user.Id, CancellationToken.None);
203203

204-
Assert.Equal("SECRETBASE32", response.Secret);
204+
Assert.Null(error);
205+
Assert.Equal("SECRETBASE32", response!.Secret);
205206
var stored = await db.Users.SingleAsync();
206207
Assert.NotNull(stored.TwoFactorSecret);
207208
Assert.NotEqual("SECRETBASE32", stored.TwoFactorSecret); // must be encrypted
208209
}
209210

211+
[Fact]
212+
public async Task SetupTwoFactorAsync_ReturnsError_WhenTwoFactorAlreadyEnabled()
213+
{
214+
using var db = NewDb();
215+
var user = NewUser(db, twoFactorEnabled: true);
216+
var sut = NewSut(db);
217+
218+
var (error, response) = await sut.SetupTwoFactorAsync(user.Id, CancellationToken.None);
219+
220+
Assert.NotNull(error);
221+
Assert.Null(response);
222+
_twoFactor.Verify(t => t.GenerateSetup(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
223+
}
224+
225+
210226
[Fact]
211227
public async Task ConfirmTwoFactorAsync_EnablesTwoFactor_AndIssuesRecoveryCodes_OnValidCode()
212228
{

DocAnalytics.Service/Auth/AuthService.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,25 @@ public AuthService(
8686
}
8787

8888
/// <inheritdoc />
89-
public async Task<TwoFactorSetupResponse> SetupTwoFactorAsync(Guid userId, CancellationToken ct)
89+
public async Task<(string? Error, TwoFactorSetupResponse? Result)> SetupTwoFactorAsync(Guid userId, CancellationToken ct)
9090
{
9191
var user = await _db.Users.FirstAsync(u => u.Id == userId, ct);
92+
93+
if (user.TwoFactorEnabled)
94+
{
95+
return ("Two-factor authentication is already enabled. Disable it first to re-configure.", null);
96+
}
97+
9298
var (secret, uri, manualKey) = _twoFactor.GenerateSetup(user.Email);
9399

94100
// Store encrypted immediately so /confirm can validate against it; NOT enabled until confirmed.
95101
user.TwoFactorSecret = _protector.Protect(secret);
96102
await _db.SaveChangesAsync(ct);
97103

98-
return new TwoFactorSetupResponse(secret, uri, manualKey);
104+
return (null, new TwoFactorSetupResponse(secret, uri, manualKey));
99105
}
100106

107+
101108
/// <inheritdoc />
102109
public async Task<(string? Error, TwoFactorConfirmResponse? Result)> ConfirmTwoFactorAsync(Guid userId, string code, CancellationToken ct)
103110
{

DocAnalytics.Service/Auth/IAuthService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public interface IAuthService
3232

3333

3434
/// <summary>Starts 2FA setup for an authenticated user: generates + stores an encrypted secret, returns the QR payload.</summary>
35-
Task<TwoFactorSetupResponse> SetupTwoFactorAsync(Guid userId, CancellationToken ct);
35+
Task<(string? Error, TwoFactorSetupResponse? Result)> SetupTwoFactorAsync(Guid userId, CancellationToken ct);
36+
3637

3738
/// <summary>Confirms 2FA setup with a valid TOTP code: flips TwoFactorEnabled, returns one-time recovery codes.</summary>
3839
Task<(string? Error, TwoFactorConfirmResponse? Result)> ConfirmTwoFactorAsync(Guid userId, string code, CancellationToken ct);

docanalytics-web/src/app/features/auth/login.component.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export class LoginComponent {
4545
}
4646

4747
submit(): void {
48+
if (this.loading()) return; // ⬅️ guard against double/rapid submits
49+
4850
this.errorMessage.set(null);
4951

5052
if (this.form.invalid) {
@@ -70,7 +72,6 @@ export class LoginComponent {
7072
return;
7173
}
7274

73-
// Forced first-login password reset takes priority over everything.
7475
if (data.must_change_password) {
7576
this.router.navigate(['/change-password']);
7677
return;
@@ -81,10 +82,11 @@ export class LoginComponent {
8182
this.auth.logout();
8283
}
8384
},
84-
// Login 401 is handled HERE locally (not via the global "Session expired" toast).
8585
error: (err: HttpErrorResponse) => {
8686
this.loading.set(false);
87-
if (err.status === 401) {
87+
if (err.status === 429) {
88+
this.errorMessage.set(this.rateLimitMessage(err));
89+
} else if (err.status === 401) {
8890
this.errorMessage.set('Invalid email or password.');
8991
} else if (err.status === 0) {
9092
this.errorMessage.set('Cannot reach the server. Check your connection and try again.');
@@ -118,13 +120,28 @@ export class LoginComponent {
118120
this.auth.logout();
119121
}
120122
},
121-
error: () => {
123+
error: (err: HttpErrorResponse) => {
122124
this.mfaLoading.set(false);
123-
this.mfaError.set('Invalid or expired code. Try again or use a recovery code.');
125+
if (err.status === 429) {
126+
this.mfaError.set(this.rateLimitMessage(err));
127+
} else {
128+
this.mfaError.set('Invalid or expired code. Try again or use a recovery code.');
129+
}
124130
},
125131
});
126132
}
127133

134+
/** Builds a friendly rate-limit message, using Retry-After header if the server sends one. */
135+
private rateLimitMessage(err: HttpErrorResponse): string {
136+
const retryAfter = err.headers?.get?.('Retry-After');
137+
const seconds = retryAfter ? parseInt(retryAfter, 10) : null;
138+
if (seconds && !isNaN(seconds) && seconds > 0) {
139+
return `Too many attempts. Please wait ${seconds}s and try again.`;
140+
}
141+
return 'Too many attempts. Please wait a moment and try again.';
142+
}
143+
144+
128145
backToCredentials(): void {
129146
this.step.set('credentials');
130147
this.challengeToken.set(null);

docanalytics-web/src/app/features/security/two-factor-setup.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export class TwoFactorSetupComponent {
3434
private startSetup(): void {
3535
this.auth.setupTwoFactor().subscribe({
3636
next: async (res) => {
37+
if (res.error) {
38+
this.error.set(res.error.message);
39+
return;
40+
}
3741
if (!res.data) {
3842
this.error.set('Could not start 2FA setup.');
3943
return;
@@ -47,6 +51,7 @@ export class TwoFactorSetupComponent {
4751
});
4852
}
4953

54+
5055
protected confirm(): void {
5156
if (this.code().length !== 6) return;
5257
this.loading.set(true);

perf-results/perf-report.csv

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
operation,samples,p50_ms,p90_ms,max_ms
2-
batch_list_page,10,0.9,3.2,23.3
3-
concurrent_dashboard_summary,10,1.1,12.9,23.9
4-
concurrent_total_wall_time,1,53.7,53.7,53.7
5-
dashboard_summary,10,1.4,21.8,103.0
6-
error_list_page,10,197.1,318.3,497.7
7-
recent_failures_page,10,62.9,189.4,230.4
2+
batch_list_page,10,0.8,3.0,30.8
3+
concurrent_dashboard_summary,10,1.1,12.7,25.1
4+
concurrent_total_wall_time,1,54.4,54.4,54.4
5+
dashboard_summary,10,1.2,21.8,101.1
6+
error_list_page,10,196.3,306.2,530.3
7+
recent_failures_page,10,58.8,191.4,228.5

0 commit comments

Comments
 (0)